This commit is contained in:
2026-06-17 04:55:15 +03:00
commit da3d9ef7b5
74 changed files with 5307 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# --- CMake / Qt build trees ---
/build/
/build-*/
/build-test/
/out/
/cmake-build-*/
# --- CMake generated files (outside build dirs) ---
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
install_manifest.txt
Makefile
*.ninja
.ninja_deps
.ninja_log
compile_commands.json
CTestTestfile.cmake
Testing/
# --- Qt Creator ---
CMakeLists.txt.user
CMakeLists.txt.user.*
*.autosave
*.pro.user
*.pro.user.*
# --- Compiled binaries / libs ---
*.exe
*.dll
*.so
*.so.*
*.dylib
*.a
*.lib
*.obj
*.o
*.pdb
*.ilk
*.exp
# --- Qt MOC / RCC / UIC ---
*_autogen/
moc_*.cpp
mocs_compilation.cpp
qrc_*.cpp
ui_*.h
# --- Deployment bundles (windeployqt / manual copy) ---
/Deploy/
/WinDeploy/
/Dep/
# --- Python (webconfig) ---
__pycache__/
*.py[cod]
*.pyo
.Python
.venv/
venv/
env/
# --- Secrets / local runtime ---
config/.webconfig.secret
**/.webconfig.secret
# --- IDE / OS ---
.vs/
.vscode/
.idea/
*.swp
*~
.DS_Store
Thumbs.db
desktop.ini
+75
View File
@@ -0,0 +1,75 @@
cmake_minimum_required(VERSION 3.10)
project(ButtonTask VERSION 2.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt5 5.9 REQUIRED COMPONENTS Core Quick Qml Network)
set(SOURCES
main.cpp
src/configmanager.cpp
src/configmanager.h
src/buttonsmodel.cpp
src/buttonsmodel.h
src/buttoncontroller.cpp
src/buttoncontroller.h
src/settingscontainer.cpp
src/settingscontainer.h
src/systeminfo.cpp
src/systeminfo.h
)
set(RESOURCES
resources/qml.qrc
resources/icons.qrc
)
add_executable(ButtonTask ${SOURCES} ${RESOURCES})
target_include_directories(ButtonTask PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(ButtonTask PRIVATE
Qt5::Core
Qt5::Quick
Qt5::Qml
Qt5::Network
)
include(GNUInstallDirs)
install(TARGETS ButtonTask
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
install(FILES config/config.json
DESTINATION ${CMAKE_INSTALL_BINDIR}/config
)
install(DIRECTORY resources/default-icons/
DESTINATION ${CMAKE_INSTALL_BINDIR}/icons
FILES_MATCHING PATTERN "*.png" PATTERN "*.jpg"
)
install(DIRECTORY webconfig/
DESTINATION share/buttontask/webconfig
)
# Privileged helper scripts (network + OTA). Installed with execute perms so
# they work even when the source tree was checked out on a non-POSIX host.
install(PROGRAMS
webconfig/scripts/bt-netconfig
webconfig/scripts/bt-update
webconfig/scripts/bt-service
webconfig/scripts/make-package.sh
DESTINATION share/buttontask/scripts
)
install(FILES webconfig/scripts/buttontask.sudoers
DESTINATION share/buttontask/scripts
)
+217
View File
@@ -0,0 +1,217 @@
# ButtonTask v2
Configurable touch-screen button panel for embedded Linux (Qt5 + QML), with a
companion Flask web configurator. Both share a single JSON config file.
## Features
- Dynamic list of buttons (1..N), each fires HTTP GET/POST on tap
- Layout: grid (configurable column count) or list
- Configurable background: animated gradient, solid color or image
- Configurable feedback ring: color and width of the OK / error glow around buttons
- In-app settings dialog (tabbed pages): CRUD buttons, drag-and-drop reorder,
layout/background/feedback/password, Ethernet configuration and update info
- Web configurator (Flask blueprints + tabbed UI + SortableJS): same operations
remotely, plus icon upload, Ethernet configuration and OTA updates
- Ethernet configuration via NetworkManager (`nmcli`) from the web UI
- OTA software update with automatic rollback on a failed launch (health-check)
- Hot reload: app picks up config changes via `QFileSystemWatcher`
## Layout
```
ButtonTask/
CMakeLists.txt Qt5 build
main.cpp
src/ C++ sources (ConfigManager, ButtonsModel, ButtonController,
SettingsContainer, SystemInfo)
qml/ Main.qml, BackgroundLayer.qml, ButtonDelegate.qml, SettingsDialog.qml
components/ BackHeader.qml, LabeledSlider.qml
settings/ MainPage / ButtonsPage / LayoutPage / BackgroundPage /
FeedbackPage / NetworkInfoPage / UpdatePage / ButtonEditorDialog
resources/ qml.qrc, icons.qrc
config/config.json default config (shipped next to the binary)
webconfig/ Flask service (modular):
app.py application factory + /healthz
config_store.py load/save/defaults (in sync with C++ ensureDefaults)
auth.py login/logout/session
api.py buttons/layout/background/feedback/settings/icons
network.py Ethernet via nmcli
updater.py OTA upload + status
paths.py install layout helpers
templates/ static/ *.service, requirements.txt
scripts/ bt-netconfig, bt-update, bt-service, buttontask.sudoers,
make-package.sh
```
The Qt app reads the config from (in order of priority):
1. `--config <path>` command-line argument
2. `BUTTONTASK_CONFIG` environment variable
3. `<applicationDir>/config/config.json` (default)
The Flask service reads the same paths.
## Building (embedded Linux, Qt5)
```sh
sudo apt install qtbase5-dev qtdeclarative5-dev \
qml-module-qtquick-controls2 qml-module-qtquick-layouts \
qml-module-qtgraphicaleffects qml-module-qtqml-models2 \
cmake build-essential
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make -j$(nproc)
sudo make install
```
Binary lands in `/usr/local/bin/ButtonTask`. To run on a framebuffer/EGL device:
```sh
QT_QPA_PLATFORM=eglfs ButtonTask --config /opt/buttontask/config/config.json
```
## Web configurator
```sh
cd webconfig
pip3 install -r requirements.txt
BUTTONTASK_CONFIG=../config/config.json python3 app.py
```
Open `http://<device-ip>:8080`. Login: `admin` + the password from `settings.password`.
## Deployment with systemd (versioned layout)
The device uses an A/B-style versioned layout so OTA updates can roll back:
```
/opt/buttontask/
versions/<ver>/ ButtonTask + webconfig/ (one dir per deployed version)
current -> versions/<ver> (active version symlink)
config/ icons/ shared, never replaced by an update
run/ qt.alive heartbeat, update-status.json
scripts/ bt-netconfig, bt-update, bt-service (root-owned)
```
First install:
```sh
sudo useradd -r -s /bin/false buttontask
sudo mkdir -p /opt/buttontask/{versions/2.0,config,icons,run,scripts}
sudo cp build/ButtonTask /opt/buttontask/versions/2.0/
sudo cp -r webconfig /opt/buttontask/versions/2.0/
sudo ln -sfn /opt/buttontask/versions/2.0 /opt/buttontask/current
sudo cp config/config.json /opt/buttontask/config/
# privileged helpers (root-owned) + sudoers whitelist
sudo install -m 0755 webconfig/scripts/bt-netconfig webconfig/scripts/bt-update \
webconfig/scripts/bt-service /opt/buttontask/scripts/
sudo install -m 0440 webconfig/scripts/buttontask.sudoers /etc/sudoers.d/buttontask
sudo visudo -cf /etc/sudoers.d/buttontask
sudo chown -R buttontask:buttontask /opt/buttontask
sudo chown root:root /opt/buttontask/scripts/bt-* # scripts must be root-owned
sudo cp webconfig/buttontask.service /etc/systemd/system/
sudo cp webconfig/buttontask-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now buttontask buttontask-web
```
## Network configuration (Ethernet)
Open the web UI → tab **Сеть**. It reads the live state via `nmcli` and can
apply DHCP or a static IP/prefix/gateway/DNS. The change is performed by the
root helper `bt-netconfig` (whitelisted in sudoers); the unprivileged web
service never touches the network directly. The same settings can also be
applied from the device itself via the in-app **Сеть** page (it calls the same
`bt-netconfig` helper through sudo, so the Qt UI must run as the `buttontask`
user covered by the sudoers rule).
## OTA software update
Build a package and upload it from the web UI → tab **Обновление**:
```sh
./webconfig/scripts/make-package.sh 2.1 build dist
# -> dist/buttontask-2.1.tar.gz (+ sha256 to paste for integrity)
```
On upload, `bt-update` (detached, root) extracts the package into
`versions/<ver>`, flips the `current` symlink, restarts both services, then runs
a health-check: the web `/healthz` must answer, the Qt UI heartbeat
(`run/qt.alive`) must be fresh, and the `buttontask` unit must be active within
`BUTTONTASK_HEALTH_TIMEOUT` (default 30s). If any check fails it automatically
switches `current` back to the previous version and restarts. Progress is shown
live and is also readable in the app's **Обновление** page.
## JSON config schema
```jsonc
{
"version": 2,
"layout": {
"mode": "grid", // "grid" | "list"
"columns": 2, // grid only
"spacing": 10,
"showLabels": true
},
"background": {
"type": "gradient", // "gradient" | "solid" | "image"
"color1": "#00007b",
"color2": "#7b007b",
"animated": true,
"imagePath": "" // filename inside iconsDir
},
"feedback": {
"okColor": "#00cc44", // ring/glow color on success
"errorColor": "#cc2200", // ring/glow color on error
"okWidth": 6, // ring width (px) on success
"errorWidth": 6, // ring width (px) on error
"glowRadius": 20 // glow radius (0 disables the glow)
},
"settings": {
"darkMode": true,
"password": "admin", // shared with web auth
"kioskMode": false,
"iconsDir": "./icons", // dir for user icons & background images
"webPort": 8080
},
"buttons": [
{
"id": "uuid",
"label": "Кнопка",
"iconPath": "cleaner1.png", // relative to iconsDir, or absolute
"action": {
"type": "http_get", // "http_get" | "http_post"
"url": "http://server/btn",
"headers": {}, // e.g. {"Authorization": "Bearer <token>"}
"body": "",
"timeoutMs": 7000, // optional request timeout (default 7000)
"acceptAnyResponse": false // optional: true = any HTTP reply is success
},
"feedback": {
"successText": "Отправлено",
"errorText": "Ошибка",
"fadeMs": 5000
}
}
]
}
```
## Notes
- Both Qt and Flask write the config atomically (`QSaveFile` / `os.replace`).
- Qt watches the file via `QFileSystemWatcher` and reloads automatically when
the web configurator saves changes.
- Button success criteria: a request is **green** on a 1xx/2xx/3xx HTTP reply
(redirects are followed) and **red** on 4xx/5xx or a transport failure
(timeout / connection refused / DNS). Set `action.acceptAnyResponse: true` to
treat *any* received HTTP reply as success (only timeouts/refused fail), and
`action.timeoutMs` to tune the timeout. Add an API key/token via
`action.headers` if the target endpoint requires one.
- Web configurator endpoints are all session-protected (`@login_required` after
password login); only `/healthz` is public (it exposes just `ok` + version,
used by the OTA rollback health-check).
- Default credentials are `admin` / `admin` — change on first run.
- v1 (Android/RS485/voice) was removed; only the icon set was kept.
+65
View File
@@ -0,0 +1,65 @@
{
"version": 2,
"layout": {
"mode": "list",
"columns": 1,
"spacing": 10,
"showLabels": true
},
"background": {
"type": "gradient",
"color1": "#00007b",
"color2": "#7b007b",
"animated": true,
"imagePath": ""
},
"feedback": {
"okColor": "#00cc44",
"errorColor": "#cc2200",
"okWidth": 3,
"errorWidth": 2,
"glowRadius": 28
},
"settings": {
"darkMode": true,
"password": "admin",
"kioskMode": false,
"iconsDir": "./icons",
"webPort": 8080
},
"buttons": [
{
"id": "example-1",
"label": "Пример",
"iconPath": "cleaner1.png",
"action": {
"type": "http_get",
"url": "http://localhost/btn?bid=01",
"headers": {},
"body": ""
},
"feedback": {
"successText": "Отправлено",
"errorText": "Ошибка",
"fadeMs": 5000
}
},
{
"id": "cbd6791cdcb74a58861554b30239c65e",
"label": "dfg",
"iconPath": "cogwheel1.png",
"action": {
"type": "http_get",
"url": "http://example.com",
"headers": {},
"body": ""
},
"feedback": {
"successText": "",
"errorText": "",
"fadeMs": 5000
},
"color": "#ab26ab"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+59
View File
@@ -0,0 +1,59 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QCommandLineParser>
#include <QFileInfo>
#include <QDir>
#include <QDebug>
#include "configmanager.h"
#include "buttonsmodel.h"
#include "buttoncontroller.h"
#include "settingscontainer.h"
#include "systeminfo.h"
int main(int argc, char *argv[])
{
qputenv("QT_IM_MODULE", "qtvirtualkeyboard");
QGuiApplication app(argc, argv);
QCoreApplication::setApplicationName("ButtonTask");
QCoreApplication::setOrganizationName("ButtonTask");
QCoreApplication::setApplicationVersion("2.0");
QCommandLineParser parser;
parser.setApplicationDescription("ButtonTask v2 - configurable button panel");
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption configOpt(QStringList() << "c" << "config",
"Path to config.json",
"path");
parser.addOption(configOpt);
parser.process(app);
QString configPath = parser.value(configOpt);
if (configPath.isEmpty())
configPath = qEnvironmentVariable("BUTTONTASK_CONFIG");
if (configPath.isEmpty())
configPath = QCoreApplication::applicationDirPath() + "/config/config.json";
qInfo() << "Using config:" << configPath;
ConfigManager config(configPath);
ButtonsModel model(&config);
ButtonController controller(&config, &model);
SettingsContainer settings(&config);
SystemInfo systemInfo;
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("config", &config);
engine.rootContext()->setContextProperty("buttonsModel", &model);
engine.rootContext()->setContextProperty("btnController", &controller);
engine.rootContext()->setContextProperty("settingsContainer", &settings);
engine.rootContext()->setContextProperty("systemInfo", &systemInfo);
engine.load(QUrl(QStringLiteral("qrc:/qml/Main.qml")));
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}
+92
View File
@@ -0,0 +1,92 @@
import QtQuick 2.12
import QtGraphicalEffects 1.12
Item {
id: root
property var bg: settingsContainer
property int bx1: 0
property int by1: 0
property int br1: 200
property int bx2: 0
property int by2: 0
property int br2: 200
Rectangle {
anchors.fill: parent
color: "black"
}
// Solid color
Rectangle {
anchors.fill: parent
visible: settingsContainer.backgroundType === "solid"
color: settingsContainer.backgroundColor1
}
// Image background
Image {
anchors.fill: parent
visible: settingsContainer.backgroundType === "image" && source != ""
fillMode: Image.PreserveAspectCrop
source: {
var p = settingsContainer.backgroundImage
if (!p) return ""
if (p.indexOf("file://") === 0 || p.indexOf("qrc:") === 0 || p.indexOf("http") === 0) return p
if (p.charAt(0) === "/" || /^[A-Za-z]:[\\\/]/.test(p)) return "file://" + p
return "file://" + settingsContainer.iconsDir + "/" + p
}
}
// Animated gradient (default)
Item {
anchors.fill: parent
visible: settingsContainer.backgroundType === "gradient"
RadialGradient {
anchors.fill: parent
horizontalOffset: bx1
verticalOffset: by1
horizontalRadius: br1
verticalRadius: br1
gradient: Gradient {
GradientStop { position: 0.0; color: settingsContainer.backgroundColor1 }
GradientStop { position: 1.0; color: "transparent" }
}
Behavior on horizontalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on verticalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on horizontalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on verticalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
}
RadialGradient {
anchors.fill: parent
horizontalOffset: bx2
verticalOffset: by2
horizontalRadius: br2
verticalRadius: br2
gradient: Gradient {
GradientStop { position: 0.0; color: settingsContainer.backgroundColor2 }
GradientStop { position: 1.0; color: "transparent" }
}
Behavior on horizontalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on verticalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on horizontalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
Behavior on verticalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
}
Timer {
running: settingsContainer.backgroundAnimated && settingsContainer.backgroundType === "gradient"
repeat: true
interval: 2500
triggeredOnStart: true
onTriggered: {
bx1 = Math.random() * root.width - root.width / 2
by1 = Math.random() * root.height - root.height / 2
br1 = Math.random() * 300 + 200
bx2 = Math.random() * root.width - root.width / 2
by2 = Math.random() * root.height - root.height / 2
br2 = Math.random() * 300 + 200
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtGraphicalEffects 1.12
Item {
id: delegateRoot
property bool showLabel: true
property bool darkMode: settingsContainer.darkMode
// Resolved feedback colour/width for the current status (ok / error).
readonly property color okColor: settingsContainer.feedbackOkColor
readonly property color errorColor: settingsContainer.feedbackErrorColor
readonly property color feedbackColor: status === 1 ? okColor
: status === 2 ? errorColor
: "#ffffff"
readonly property int feedbackWidth: status === 1 ? settingsContainer.feedbackOkWidth
: status === 2 ? settingsContainer.feedbackErrorWidth
: 2
// Glow: white=in-progress, ok/error use configured colours
Rectangle {
id: glowRect
anchors.centerIn: parent
width: circle.width * glowScale
height: width
radius: width / 2
color: delegateRoot.feedbackColor
opacity: status === 0 ? 0 : 0.5
visible: status !== 0 && settingsContainer.feedbackGlowRadius > 0
property real glowScale: 1.0
Behavior on opacity { NumberAnimation { duration: 300 } }
Behavior on color { ColorAnimation { duration: 200 } }
SequentialAnimation on glowScale {
running: status === 3
loops: Animation.Infinite
NumberAnimation { to: 1.4; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { to: 1.0; duration: 700; easing.type: Easing.InOutSine }
}
NumberAnimation on glowScale {
running: status !== 3
to: 1.0; duration: 300
}
layer.enabled: true
layer.effect: Glow {
radius: settingsContainer.feedbackGlowRadius
samples: 24
color: delegateRoot.feedbackColor
transparentBorder: true
}
}
// Circle button — fixed color from config; only the ring reacts to status.
Rectangle {
id: circle
anchors.centerIn: parent
width: Math.min(parent.width, parent.height) * 0.82
height: width
radius: width / 2
color: btnColor || "#7b007b"
border.color: status === 1 ? delegateRoot.okColor
: status === 2 ? delegateRoot.errorColor
: Qt.lighter(btnColor || "#7b007b", 1.5)
border.width: status === 1 || status === 2 ? delegateRoot.feedbackWidth : 1
Behavior on border.width { NumberAnimation { duration: 200 } }
Behavior on border.color { ColorAnimation { duration: 200 } }
Image {
anchors.centerIn: parent
width: parent.width * 0.8
height: width
fillMode: Image.PreserveAspectFit
source: iconPath
visible: iconPath !== ""
asynchronous: true
}
MouseArea {
anchors.fill: parent
onClicked: btnController.invokeButton(btnId)
}
}
Text {
anchors.top: circle.bottom
anchors.topMargin: 4
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width
visible: showLabel
text: statusText !== "" ? statusText : label
color: darkMode ? "white" : "black"
font.pixelSize: Math.max(10, parent.width * 0.1)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
+174
View File
@@ -0,0 +1,174 @@
import QtQuick 2.12
import QtQuick.Window 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import QtGraphicalEffects 1.12
import QtQuick.VirtualKeyboard 2.4
Window {
id: root
visible: true
width: 480
height: 480
title: "ButtonTask"
color: "black"
visibility: Window.FullScreen
property bool darkMode: settingsContainer.darkMode
BackgroundLayer {
id: bg
anchors.fill: parent
}
// Main content area
Item {
id: content
anchors.fill: parent
anchors.margins: 10
Loader {
id: viewLoader
anchors.fill: parent
sourceComponent: settingsContainer.layoutMode === "list" ? listComp : gridComp
}
Component {
id: gridComp
GridView {
id: gridView
anchors.fill: parent
cellWidth: Math.max(64, width / Math.max(1, settingsContainer.layoutColumns))
cellHeight: cellWidth
model: buttonsModel
clip: true
interactive: true
delegate: ButtonDelegate {
width: gridView.cellWidth - settingsContainer.layoutSpacing
height: gridView.cellHeight - settingsContainer.layoutSpacing
showLabel: settingsContainer.showLabels
}
}
}
Component {
id: listComp
SwipeView {
id: swipeView
anchors.fill: parent
clip: true
Repeater {
model: buttonsModel
Item {
ButtonDelegate {
anchors.centerIn: parent
width: Math.min(swipeView.width, swipeView.height) * 0.8
height: width
showLabel: settingsContainer.showLabels
}
}
}
}
}
Text {
anchors.centerIn: parent
visible: buttonsModel.count === 0
text: "Нет кнопок.\nОткройте настройки и добавьте кнопку."
color: root.darkMode ? "#cccccc" : "#222222"
font.pixelSize: 18
horizontalAlignment: Text.AlignHCenter
}
}
// Settings gear (top-right)
Rectangle {
id: settingsBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
width: Math.max(40, parent.width / 14)
height: width
color: "transparent"
opacity: 0.85
Rectangle {
anchors.fill: parent
radius: width / 2
color: root.darkMode ? "#40ffffff" : "#40000000"
}
Text {
anchors.centerIn: parent
text: "\u2699"
font.pixelSize: parent.width * 0.6
color: root.darkMode ? "white" : "black"
}
MouseArea {
anchors.fill: parent
onClicked: passwordDialog.open()
}
}
// Password dialog
Dialog {
id: passwordDialog
modal: true
title: "Пароль"
anchors.centerIn: parent
width: Math.min(420, parent.width * 0.8)
closePolicy: Popup.CloseOnEscape
standardButtons: Dialog.Ok | Dialog.Cancel
contentItem: ColumnLayout {
spacing: 8
TextField {
id: pwdField
Layout.fillWidth: true
placeholderText: "Введите пароль"
echoMode: TextInput.Password
onAccepted: passwordDialog.accept()
}
Text {
id: wrongPwd
Layout.fillWidth: true
visible: false
color: "red"
text: "Неверный пароль"
}
}
onOpened: {
pwdField.text = ""
wrongPwd.visible = false
pwdField.forceActiveFocus()
}
onAccepted: {
if (btnController.checkPassword(pwdField.text)) {
wrongPwd.visible = false
pwdField.text = ""
settingsDialog.open()
} else {
wrongPwd.visible = true
Qt.callLater(passwordDialog.open)
}
}
}
// Settings dialog (single instance)
SettingsDialog {
id: settingsDialog
}
// Virtual keyboard — parented to Overlay so it's above dialogs
InputPanel {
id: inputPanel
parent: Overlay.overlay
z: 999
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: active
}
}
+68
View File
@@ -0,0 +1,68 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "settings"
Dialog {
id: dlg
modal: true
title: "Настройки"
width: Math.min(720, parent ? parent.width * 0.95 : 720)
height: Math.min(560, parent ? parent.height * 0.95 : 560)
anchors.centerIn: parent
standardButtons: Dialog.Close
StackView {
id: stack
anchors.fill: parent
initialItem: mainPageComp
}
Component {
id: mainPageComp
MainPage {
onOpenButtons: stack.push(buttonsPageComp)
onOpenLayout: stack.push(layoutPageComp)
onOpenBackground: stack.push(backgroundPageComp)
onOpenFeedback: stack.push(feedbackPageComp)
onOpenNetwork: stack.push(networkPageComp)
onOpenUpdate: stack.push(updatePageComp)
onChangePassword: changePwdDialog.open()
}
}
Component {
id: buttonsPageComp
ButtonsPage {
onCreateRequested: editorDialog.openForCreate()
onEditRequested: function(data) { editorDialog.openForEdit(data) }
}
}
Component { id: layoutPageComp; LayoutPage {} }
Component { id: backgroundPageComp; BackgroundPage {} }
Component { id: feedbackPageComp; FeedbackPage {} }
Component { id: networkPageComp; NetworkInfoPage {} }
Component { id: updatePageComp; UpdatePage {} }
ButtonEditorDialog { id: editorDialog }
Dialog {
id: changePwdDialog
modal: true
title: "Сменить пароль"
anchors.centerIn: parent
standardButtons: Dialog.Ok | Dialog.Cancel
TextField {
id: newPwdField
anchors.fill: parent
placeholderText: "Новый пароль"
echoMode: TextInput.Password
}
onAccepted: {
if (newPwdField.text.length > 0)
btnController.setPassword(newPwdField.text)
newPwdField.text = ""
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
RowLayout {
id: header
property string title: ""
signal back()
Layout.fillWidth: true
spacing: 8
Button {
text: "\u2190 Назад"
onClicked: header.back()
}
Label {
Layout.fillWidth: true
text: header.title
font.bold: true
font.pixelSize: 16
color: settingsContainer.darkMode ? "white" : "black"
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Item { Layout.preferredWidth: 88 }
}
+37
View File
@@ -0,0 +1,37 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
RowLayout {
id: control
property string label: ""
property real from: 0
property real to: 100
property real stepSize: 1
property real value: 0
signal moved(real value)
Layout.fillWidth: true
spacing: 10
Label {
text: control.label
color: settingsContainer.darkMode ? "white" : "black"
Layout.preferredWidth: 150
}
Slider {
id: slider
Layout.fillWidth: true
from: control.from
to: control.to
stepSize: control.stepSize
value: control.value
onMoved: control.moved(Math.round(value))
}
Label {
text: Math.round(slider.value)
color: settingsContainer.darkMode ? "white" : "black"
Layout.preferredWidth: 36
horizontalAlignment: Text.AlignRight
}
}
+108
View File
@@ -0,0 +1,108 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../components"
Item {
id: page
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
BackHeader { title: "Фон"; onBack: page.StackView.view.pop() }
RowLayout {
Layout.fillWidth: true
Text {
text: "Тип:"
color: settingsContainer.darkMode ? "white" : "black"
}
Item { Layout.fillWidth: true }
Button {
text: "Градиент"
highlighted: settingsContainer.backgroundType === "gradient"
onClicked: btnController.setBackgroundType("gradient")
}
Button {
text: "Цвет"
highlighted: settingsContainer.backgroundType === "solid"
onClicked: btnController.setBackgroundType("solid")
}
Button {
text: "Картинка"
highlighted: settingsContainer.backgroundType === "image"
onClicked: btnController.setBackgroundType("image")
}
}
GridLayout {
Layout.fillWidth: true
columns: 2
visible: settingsContainer.backgroundType !== "image"
Text { text: "Цвет 1:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField {
id: c1
Layout.fillWidth: true
text: settingsContainer.backgroundColor1
onEditingFinished: btnController.setBackgroundColors(text, c2.text)
}
Text {
text: "Цвет 2:"
color: settingsContainer.darkMode ? "white" : "black"
visible: settingsContainer.backgroundType === "gradient"
}
TextField {
id: c2
Layout.fillWidth: true
text: settingsContainer.backgroundColor2
visible: settingsContainer.backgroundType === "gradient"
onEditingFinished: btnController.setBackgroundColors(c1.text, text)
}
}
CheckBox {
visible: settingsContainer.backgroundType === "gradient"
text: "Анимация"
checked: settingsContainer.backgroundAnimated
onToggled: btnController.setBackgroundAnimated(checked)
}
ColumnLayout {
visible: settingsContainer.backgroundType === "image"
Layout.fillWidth: true
Text {
text: "Имя файла фона (из папки иконок):"
color: settingsContainer.darkMode ? "white" : "black"
}
RowLayout {
Layout.fillWidth: true
ComboBox {
id: bgImageCombo
Layout.fillWidth: true
model: btnController.listIcons()
Component.onCompleted: {
var i = model.indexOf(settingsContainer.backgroundImage)
if (i >= 0) currentIndex = i
}
onActivated: btnController.setBackgroundImage(currentText)
}
Button {
text: "\u21BB"
onClicked: bgImageCombo.model = btnController.listIcons()
}
}
Text {
Layout.fillWidth: true
text: "Папка иконок:\n" + settingsContainer.iconsDir
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 11
wrapMode: Text.Wrap
}
}
Item { Layout.fillHeight: true }
}
}
+116
View File
@@ -0,0 +1,116 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
Dialog {
id: editorDialog
modal: true
title: editId === "" ? "Новая кнопка" : "Редактирование"
anchors.centerIn: parent
standardButtons: Dialog.Ok | Dialog.Cancel
property string editId: ""
function openForCreate() {
editId = ""
labelF.text = ""
urlF.text = ""
successF.text = ""
errorF.text = "Ошибка"
colorF.text = "#7b007b"
iconCombo.model = btnController.listIcons()
iconCombo.currentIndex = 0
typeCombo.currentIndex = 0
open()
}
function openForEdit(o) {
editId = o.btnId
labelF.text = o.label
urlF.text = o.actionUrl
successF.text = o.successText
errorF.text = o.errorText
colorF.text = o.btnColor || "#7b007b"
iconCombo.model = btnController.listIcons()
var ip = o.iconPath || ""
var fname = ip
var slash = ip.lastIndexOf("/")
if (slash >= 0) fname = ip.substring(slash + 1)
var idx = iconCombo.model.indexOf(fname)
iconCombo.currentIndex = idx >= 0 ? idx : 0
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
open()
}
ColumnLayout {
anchors.fill: parent
spacing: 6
TextField {
id: labelF
Layout.fillWidth: true
placeholderText: "Подпись"
}
RowLayout {
Layout.fillWidth: true
Text { text: "Иконка:"; color: settingsContainer.darkMode ? "white" : "black" }
ComboBox {
id: iconCombo
Layout.fillWidth: true
model: btnController.listIcons()
}
Button {
text: "\u21BB"
onClicked: iconCombo.model = btnController.listIcons()
}
}
RowLayout {
Layout.fillWidth: true
Text { text: "Метод:"; color: settingsContainer.darkMode ? "white" : "black" }
ComboBox {
id: typeCombo
model: ["http_get", "http_post"]
}
}
TextField {
id: urlF
Layout.fillWidth: true
placeholderText: "URL"
}
TextField {
id: successF
Layout.fillWidth: true
placeholderText: "Текст успеха"
}
TextField {
id: errorF
Layout.fillWidth: true
placeholderText: "Текст ошибки"
}
RowLayout {
Layout.fillWidth: true
Text { text: "Цвет:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField {
id: colorF
Layout.fillWidth: true
placeholderText: "#7b007b"
}
Rectangle {
width: 28; height: 28; radius: 14
color: colorF.text || "#7b007b"
border.color: "#80ffffff"; border.width: 1
}
}
}
onAccepted: {
if (editId === "") {
btnController.addButton(labelF.text, iconCombo.currentText,
typeCombo.currentText, urlF.text,
successF.text, errorF.text, colorF.text)
} else {
btnController.updateButton(editId, labelF.text, iconCombo.currentText,
typeCombo.currentText, urlF.text,
successF.text, errorF.text, colorF.text)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../components"
Item {
id: page
signal createRequested()
signal editRequested(var data)
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
RowLayout {
Layout.fillWidth: true
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
Label {
Layout.fillWidth: true
text: "Кнопки"
font.bold: true
font.pixelSize: 16
color: settingsContainer.darkMode ? "white" : "black"
horizontalAlignment: Text.AlignHCenter
}
Button {
text: "+ Добавить"
onClicked: page.createRequested()
}
}
Text {
Layout.fillWidth: true
text: "Удерживайте «≡» и перетащите для переупорядочивания"
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
}
ListView {
id: btnList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 4
model: buttonsModel
delegate: Item {
id: row
width: btnList.width
height: 60
Rectangle {
anchors.fill: parent
radius: 6
color: ma.drag.active
? "#80ffd966"
: (settingsContainer.darkMode ? "#40222c58" : "#40ffffff")
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 8
Image {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
fillMode: Image.PreserveAspectFit
source: iconPath
}
ColumnLayout {
Layout.fillWidth: true
Text {
Layout.fillWidth: true
text: label
color: settingsContainer.darkMode ? "white" : "black"
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: actionType + ": " + actionUrl
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 11
elide: Text.ElideRight
}
}
Rectangle {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
radius: 9
color: btnColor || "#7b007b"
border.color: "#80ffffff"; border.width: 1
}
Button {
text: "\u270E"
onClicked: page.editRequested(buttonsModel.get(index))
}
Button {
text: "\u2715"
onClicked: btnController.removeButton(btnId)
}
Text {
text: "\u2261"
font.pixelSize: 22
color: settingsContainer.darkMode ? "#cccccc" : "#666666"
Layout.preferredWidth: 24
horizontalAlignment: Text.AlignHCenter
}
}
}
MouseArea {
id: ma
anchors.right: parent.right
width: 30
height: parent.height
drag.target: row
drag.axis: Drag.YAxis
drag.minimumY: -row.height
drag.maximumY: btnList.height
onPressed: row.z = 2
onReleased: {
row.z = 0
var dy = row.y - index * (row.height + btnList.spacing)
var delta = Math.round(dy / (row.height + btnList.spacing))
var target = Math.max(0, Math.min(buttonsModel.count - 1, index + delta))
row.y = index * (row.height + btnList.spacing)
if (target !== index)
btnController.moveButton(index, target)
}
}
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import QtGraphicalEffects 1.12
import "../components"
Item {
id: page
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
BackHeader { title: "Обводка отклика"; onBack: page.StackView.view.pop() }
Text {
Layout.fillWidth: true
text: "Цвет и ширина свечения вокруг кнопки при успехе (ОК) и ошибке."
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
wrapMode: Text.Wrap
}
// OK color
RowLayout {
Layout.fillWidth: true
Text { text: "Цвет «ОК»:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 150 }
TextField {
id: okColor
Layout.fillWidth: true
text: settingsContainer.feedbackOkColor
onEditingFinished: btnController.setFeedbackColors(text, errorColor.text)
}
Rectangle {
width: 28; height: 28; radius: 14
color: okColor.text || "#00cc44"
border.color: "#80ffffff"; border.width: 1
}
}
LabeledSlider {
label: "Ширина «ОК»"
from: 0; to: 30; stepSize: 1
value: settingsContainer.feedbackOkWidth
onMoved: btnController.setFeedbackWidths(value, settingsContainer.feedbackErrorWidth)
}
// Error color
RowLayout {
Layout.fillWidth: true
Text { text: "Цвет «Ошибка»:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 150 }
TextField {
id: errorColor
Layout.fillWidth: true
text: settingsContainer.feedbackErrorColor
onEditingFinished: btnController.setFeedbackColors(okColor.text, text)
}
Rectangle {
width: 28; height: 28; radius: 14
color: errorColor.text || "#cc2200"
border.color: "#80ffffff"; border.width: 1
}
}
LabeledSlider {
label: "Ширина «Ошибка»"
from: 0; to: 30; stepSize: 1
value: settingsContainer.feedbackErrorWidth
onMoved: btnController.setFeedbackWidths(settingsContainer.feedbackOkWidth, value)
}
LabeledSlider {
label: "Радиус свечения"
from: 0; to: 60; stepSize: 1
value: settingsContainer.feedbackGlowRadius
onMoved: btnController.setFeedbackGlowRadius(value)
}
// Preview
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 8
spacing: 32
Item { Layout.fillWidth: true }
Repeater {
model: [
{ c: settingsContainer.feedbackOkColor, w: settingsContainer.feedbackOkWidth, t: "ОК" },
{ c: settingsContainer.feedbackErrorColor, w: settingsContainer.feedbackErrorWidth, t: "Ошибка" }
]
delegate: Rectangle {
width: 84; height: 84; radius: 42
color: "#7b007b"
border.color: modelData.c
border.width: modelData.w
Text {
anchors.centerIn: parent
text: modelData.t
color: "white"
font.bold: true
}
layer.enabled: settingsContainer.feedbackGlowRadius > 0
layer.effect: Glow {
radius: settingsContainer.feedbackGlowRadius
samples: 24
color: modelData.c
transparentBorder: true
}
}
}
Item { Layout.fillWidth: true }
}
Item { Layout.fillHeight: true }
}
}
+58
View File
@@ -0,0 +1,58 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../components"
Item {
id: page
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
BackHeader { title: "Расположение"; onBack: page.StackView.view.pop() }
RowLayout {
Layout.fillWidth: true
Text {
text: "Режим:"
color: settingsContainer.darkMode ? "white" : "black"
}
Item { Layout.fillWidth: true }
Button {
text: "Сетка"
highlighted: settingsContainer.layoutMode === "grid"
onClicked: btnController.setLayoutMode("grid")
}
Button {
text: "Список"
highlighted: settingsContainer.layoutMode === "list"
onClicked: btnController.setLayoutMode("list")
}
}
LabeledSlider {
visible: settingsContainer.layoutMode === "grid"
label: "Колонок"
from: 1; to: 8; stepSize: 1
value: settingsContainer.layoutColumns
onMoved: btnController.setLayoutColumns(value)
}
LabeledSlider {
label: "Отступ"
from: 0; to: 40; stepSize: 1
value: settingsContainer.layoutSpacing
onMoved: btnController.setLayoutSpacing(value)
}
CheckBox {
text: "Показывать подписи"
checked: settingsContainer.showLabels
onToggled: btnController.setLayoutShowLabels(checked)
}
Item { Layout.fillHeight: true }
}
}
+83
View File
@@ -0,0 +1,83 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
Item {
id: page
signal openButtons()
signal openLayout()
signal openBackground()
signal openFeedback()
signal openNetwork()
signal openUpdate()
signal changePassword()
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Button {
Layout.fillWidth: true
text: "Кнопки (CRUD + порядок)"
onClicked: page.openButtons()
}
Button {
Layout.fillWidth: true
text: "Расположение: " + settingsContainer.layoutMode +
" (" + (settingsContainer.layoutMode === "grid"
? settingsContainer.layoutColumns + " колонок"
: "список") + ")"
onClicked: page.openLayout()
}
Button {
Layout.fillWidth: true
text: "Фон"
onClicked: page.openBackground()
}
Button {
Layout.fillWidth: true
text: "Обводка отклика (ОК / Ошибка)"
onClicked: page.openFeedback()
}
Button {
Layout.fillWidth: true
text: "Сеть"
onClicked: page.openNetwork()
}
Button {
Layout.fillWidth: true
text: "Обновление ПО"
onClicked: page.openUpdate()
}
Button {
Layout.fillWidth: true
text: "Тема: " + (settingsContainer.darkMode ? "тёмная" : "светлая")
onClicked: btnController.setDarkMode(!settingsContainer.darkMode)
}
Button {
Layout.fillWidth: true
text: "Сменить пароль"
onClicked: page.changePassword()
}
Item { Layout.fillHeight: true }
Text {
Layout.fillWidth: true
text: "Веб-конфигуратор: порт " + settingsContainer.webPort
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.fillWidth: true
text: "Конфиг: " + config.configPath
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 11
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../components"
Item {
id: page
property var devices: []
property string resultText: ""
property bool resultOk: false
function reload() {
devices = systemInfo.networkInfo()
var names = []
for (var i = 0; i < devices.length; ++i)
names.push(devices[i].device)
var prev = ifaceCombo.currentText
ifaceCombo.model = names
var idx = names.indexOf(prev)
if (idx >= 0) ifaceCombo.currentIndex = idx
}
function applyNow() {
if (!ifaceCombo.currentText) {
page.resultOk = false
page.resultText = "Выберите интерфейс"
return
}
var mode = staticSwitch.checked ? "static" : "dhcp"
var r
if (mode === "static") {
r = systemInfo.applyNetwork(ifaceCombo.currentText, "static",
addrField.text.trim(),
parseInt(prefixField.text) || 24,
gwField.text.trim(),
dnsField.text.trim())
} else {
r = systemInfo.applyNetwork(ifaceCombo.currentText, "dhcp")
}
page.resultOk = r.ok === true
page.resultText = r.ok ? "Настройки применены" : ("Ошибка: " + (r.error || "неизвестно"))
reloadTimer.start()
}
Component.onCompleted: reload()
Timer {
id: reloadTimer
interval: 1500
onTriggered: page.reload()
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
RowLayout {
Layout.fillWidth: true
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
Label {
Layout.fillWidth: true
text: "Сеть (Ethernet)"
font.bold: true
font.pixelSize: 16
color: settingsContainer.darkMode ? "white" : "black"
horizontalAlignment: Text.AlignHCenter
}
Button { text: "\u21BB"; onClicked: page.reload() }
}
// ----- Current state -----
Text {
Layout.fillWidth: true
visible: page.devices.length === 0
text: "Ethernet-интерфейсы не найдены (или nmcli недоступен)."
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
wrapMode: Text.Wrap
}
Repeater {
model: page.devices
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: col.implicitHeight + 16
radius: 6
color: settingsContainer.darkMode ? "#40222c58" : "#40ffffff"
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
border.width: 1
ColumnLayout {
id: col
anchors.fill: parent
anchors.margins: 8
spacing: 2
Text {
text: modelData.device + " (" + modelData.state + ")"
color: settingsContainer.darkMode ? "white" : "black"
font.bold: true
}
Text {
text: "Соединение: " + (modelData.connection || "—")
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
}
Text {
text: "IP: " + (modelData.addresses || "—")
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
}
Text {
text: "Шлюз: " + (modelData.gateway || "—") + " DNS: " + (modelData.dns || "—")
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
}
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: settingsContainer.darkMode ? "#40ffffff" : "#40000000" }
// ----- Configuration form -----
Text {
Layout.fillWidth: true
text: "Изменение настроек может разорвать соединение и сменить IP устройства."
color: "#e0a020"
font.pixelSize: 12
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Text { text: "Интерфейс:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 120 }
ComboBox {
id: ifaceCombo
Layout.fillWidth: true
model: []
}
}
RowLayout {
Layout.fillWidth: true
Text { text: "Статический IP:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 120 }
Switch {
id: staticSwitch
checked: false
}
Text {
text: staticSwitch.checked ? "Статический" : "DHCP (авто)"
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
}
Item { Layout.fillWidth: true }
}
GridLayout {
Layout.fillWidth: true
columns: 2
visible: staticSwitch.checked
Text { text: "IP-адрес:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField { id: addrField; Layout.fillWidth: true; placeholderText: "192.168.1.50" }
Text { text: "Префикс:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField { id: prefixField; Layout.fillWidth: true; text: "24"; inputMethodHints: Qt.ImhDigitsOnly }
Text { text: "Шлюз:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField { id: gwField; Layout.fillWidth: true; placeholderText: "192.168.1.1" }
Text { text: "DNS:"; color: settingsContainer.darkMode ? "white" : "black" }
TextField { id: dnsField; Layout.fillWidth: true; placeholderText: "8.8.8.8, 1.1.1.1" }
}
RowLayout {
Layout.fillWidth: true
Button {
text: "Применить"
highlighted: true
onClicked: confirmDialog.open()
}
Text {
Layout.fillWidth: true
text: page.resultText
color: page.resultOk ? "#30c050" : "#e05050"
wrapMode: Text.Wrap
}
}
Item { Layout.fillHeight: true }
}
Dialog {
id: confirmDialog
modal: true
title: "Применить настройки сети?"
anchors.centerIn: parent
standardButtons: Dialog.Ok | Dialog.Cancel
Text {
width: parent ? parent.width : 300
text: "Соединение может прерваться. Продолжить?"
color: settingsContainer.darkMode ? "white" : "black"
wrapMode: Text.Wrap
}
onAccepted: page.applyNow()
}
}
+92
View File
@@ -0,0 +1,92 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../components"
Item {
id: page
property var status: ({ state: "idle" })
function stateText(s) {
switch (s) {
case "staging": return "Распаковка пакета…"
case "switching": return "Переключение версии…"
case "health_check": return "Проверка работоспособности…"
case "success": return "Обновление успешно применено"
case "rolledback": return "Запуск новой версии не удался — выполнен откат"
case "rollback_done": return "Откат выполнен"
case "error": return "Ошибка обновления"
default: return "Готово к обновлению"
}
}
function reload() {
status = systemInfo.updateStatus()
systemInfo.refresh()
}
Component.onCompleted: reload()
Timer {
interval: 2500
running: true
repeat: true
onTriggered: page.reload()
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
RowLayout {
Layout.fillWidth: true
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
Label {
Layout.fillWidth: true
text: "Обновление ПО"
font.bold: true
font.pixelSize: 16
color: settingsContainer.darkMode ? "white" : "black"
horizontalAlignment: Text.AlignHCenter
}
Button { text: "\u21BB"; onClicked: page.reload() }
}
Text {
Layout.fillWidth: true
text: "Текущая версия: " + systemInfo.currentVersion
color: settingsContainer.darkMode ? "white" : "black"
font.pixelSize: 14
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: stTxt.implicitHeight + 20
radius: 6
color: settingsContainer.darkMode ? "#40222c58" : "#40ffffff"
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
border.width: 1
Text {
id: stTxt
anchors.fill: parent
anchors.margins: 10
text: "Статус: " + page.stateText(page.status.state)
+ (page.status.message ? "\n" + page.status.message : "")
color: settingsContainer.darkMode ? "white" : "black"
wrapMode: Text.Wrap
}
}
Text {
Layout.fillWidth: true
text: "Загрузка нового пакета ПО выполняется через веб-конфигуратор. После применения выполняется проверка запуска; при ошибке произойдёт автоматический откат на предыдущую версию."
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 12
wrapMode: Text.Wrap
}
Item { Layout.fillHeight: true }
}
}
+4
View File
@@ -0,0 +1,4 @@
<RCC>
<qresource prefix="/">
</qresource>
</RCC>
+18
View File
@@ -0,0 +1,18 @@
<RCC>
<qresource prefix="/qml">
<file alias="Main.qml">../qml/Main.qml</file>
<file alias="BackgroundLayer.qml">../qml/BackgroundLayer.qml</file>
<file alias="ButtonDelegate.qml">../qml/ButtonDelegate.qml</file>
<file alias="SettingsDialog.qml">../qml/SettingsDialog.qml</file>
<file alias="components/BackHeader.qml">../qml/components/BackHeader.qml</file>
<file alias="components/LabeledSlider.qml">../qml/components/LabeledSlider.qml</file>
<file alias="settings/MainPage.qml">../qml/settings/MainPage.qml</file>
<file alias="settings/ButtonsPage.qml">../qml/settings/ButtonsPage.qml</file>
<file alias="settings/LayoutPage.qml">../qml/settings/LayoutPage.qml</file>
<file alias="settings/BackgroundPage.qml">../qml/settings/BackgroundPage.qml</file>
<file alias="settings/FeedbackPage.qml">../qml/settings/FeedbackPage.qml</file>
<file alias="settings/NetworkInfoPage.qml">../qml/settings/NetworkInfoPage.qml</file>
<file alias="settings/UpdatePage.qml">../qml/settings/UpdatePage.qml</file>
<file alias="settings/ButtonEditorDialog.qml">../qml/settings/ButtonEditorDialog.qml</file>
</qresource>
</RCC>
+293
View File
@@ -0,0 +1,293 @@
#include "buttoncontroller.h"
#include "configmanager.h"
#include "buttonsmodel.h"
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTimer>
#include <QUrl>
#include <QDir>
#include <QFileInfo>
#include <QPointer>
#include <QDebug>
ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent)
: QObject(parent)
, m_config(config)
, m_model(model)
, m_nam(new QNetworkAccessManager(this))
{
}
void ButtonController::invokeButton(const QString &id)
{
QJsonArray arr = m_config->buttons();
QJsonObject btn;
bool found = false;
for (const auto &v : arr) {
QJsonObject b = v.toObject();
if (b.value("id").toString() == id) {
btn = b;
found = true;
break;
}
}
if (!found) {
qWarning() << "invokeButton: id not found" << id;
return;
}
QJsonObject action = btn.value("action").toObject();
QJsonObject feedback = btn.value("feedback").toObject();
QString type = action.value("type").toString("http_get");
QString url = action.value("url").toString();
int fadeMs = feedback.value("fadeMs").toInt(5000);
int timeoutMs = action.value("timeoutMs").toInt(7000);
// When true, ANY received HTTP response (even 4xx/5xx) counts as success;
// only transport errors (timeout / refused / DNS) fail. Default: success on
// 1xx/2xx/3xx, failure on 4xx/5xx and transport errors.
bool acceptAnyResponse = action.value("acceptAnyResponse").toBool(false);
QString successText = feedback.value("successText").toString(btn.value("label").toString());
QString errorText = feedback.value("errorText").toString(QStringLiteral("Ошибка"));
if (url.isEmpty()) {
qWarning() << "invokeButton: empty url for" << id;
m_model->setStatus(id, 2, errorText);
emit buttonInvoked(id, false, errorText);
return;
}
m_model->setStatus(id, 3, QStringLiteral("..."));
QNetworkRequest req((QUrl(url)));
// Follow redirects so a 3xx that lands on a final 200 is treated correctly.
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
QJsonObject headers = action.value("headers").toObject();
for (auto it = headers.begin(); it != headers.end(); ++it)
req.setRawHeader(it.key().toUtf8(), it.value().toString().toUtf8());
QNetworkReply *reply = nullptr;
if (type == "http_post") {
QString body = action.value("body").toString();
if (!req.header(QNetworkRequest::ContentTypeHeader).isValid())
req.setHeader(QNetworkRequest::ContentTypeHeader, "text/plain");
reply = m_nam->post(req, body.toUtf8());
} else {
reply = m_nam->get(req);
}
QTimer *timeout = new QTimer(reply);
timeout->setSingleShot(true);
timeout->start(timeoutMs > 0 ? timeoutMs : 7000);
connect(timeout, &QTimer::timeout, reply, [reply]() {
if (reply && reply->isRunning()) reply->abort();
});
connect(reply, &QNetworkReply::finished, this,
[this, reply, id, successText, errorText, fadeMs, acceptAnyResponse]() {
const int httpStatus =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
bool ok;
if (httpStatus > 0) {
// We got an HTTP response: server is reachable and replied.
ok = acceptAnyResponse ? true : (httpStatus < 400);
} else {
// No HTTP status -> transport-level outcome (timeout/refused/DNS).
ok = (reply->error() == QNetworkReply::NoError);
}
QString msg = ok ? successText : errorText;
m_model->setStatus(id, ok ? 1 : 2, msg);
emit buttonInvoked(id, ok, msg);
QString idCopy = id;
QPointer<ButtonsModel> modelPtr = m_model;
QTimer::singleShot(fadeMs, this, [modelPtr, idCopy]() {
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
});
reply->deleteLater();
});
}
bool ButtonController::checkPassword(const QString &pwd) const
{
QString stored = m_config->settingsObj().value("password").toString("admin");
return pwd == stored;
}
void ButtonController::addButton(const QString &label,
const QString &iconPath,
const QString &actionType,
const QString &actionUrl,
const QString &successText,
const QString &errorText,
const QString &color)
{
QJsonObject action;
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
action.insert("url", actionUrl);
action.insert("headers", QJsonObject());
action.insert("body", "");
QJsonObject feedback;
feedback.insert("successText", successText);
feedback.insert("errorText", errorText);
feedback.insert("fadeMs", 5000);
QJsonObject btn;
btn.insert("label", label);
btn.insert("iconPath", iconPath);
btn.insert("color", color.isEmpty() ? "#7b007b" : color);
btn.insert("action", action);
btn.insert("feedback", feedback);
m_config->addButton(btn);
}
void ButtonController::updateButton(const QString &id,
const QString &label,
const QString &iconPath,
const QString &actionType,
const QString &actionUrl,
const QString &successText,
const QString &errorText,
const QString &color)
{
QJsonArray arr = m_config->buttons();
for (const auto &v : arr) {
QJsonObject b = v.toObject();
if (b.value("id").toString() != id) continue;
QJsonObject action = b.value("action").toObject();
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
action.insert("url", actionUrl);
QJsonObject feedback = b.value("feedback").toObject();
feedback.insert("successText", successText);
feedback.insert("errorText", errorText);
if (!feedback.contains("fadeMs")) feedback.insert("fadeMs", 5000);
b.insert("label", label);
b.insert("iconPath", iconPath);
b.insert("color", color.isEmpty() ? "#7b007b" : color);
b.insert("action", action);
b.insert("feedback", feedback);
m_config->updateButton(id, b);
return;
}
}
void ButtonController::removeButton(const QString &id)
{
m_config->removeButton(id);
}
void ButtonController::moveButton(int from, int to)
{
m_config->moveButton(from, to);
}
void ButtonController::setLayoutMode(const QString &mode)
{
QJsonObject l = m_config->layoutObj();
l.insert("mode", mode);
m_config->setLayout(l);
}
void ButtonController::setLayoutColumns(int columns)
{
QJsonObject l = m_config->layoutObj();
l.insert("columns", columns);
m_config->setLayout(l);
}
void ButtonController::setLayoutSpacing(int spacing)
{
QJsonObject l = m_config->layoutObj();
l.insert("spacing", spacing);
m_config->setLayout(l);
}
void ButtonController::setLayoutShowLabels(bool show)
{
QJsonObject l = m_config->layoutObj();
l.insert("showLabels", show);
m_config->setLayout(l);
}
void ButtonController::setBackgroundType(const QString &type)
{
QJsonObject b = m_config->backgroundObj();
b.insert("type", type);
m_config->setBackground(b);
}
void ButtonController::setBackgroundColors(const QString &c1, const QString &c2)
{
QJsonObject b = m_config->backgroundObj();
b.insert("color1", c1);
b.insert("color2", c2);
m_config->setBackground(b);
}
void ButtonController::setBackgroundAnimated(bool animated)
{
QJsonObject b = m_config->backgroundObj();
b.insert("animated", animated);
m_config->setBackground(b);
}
void ButtonController::setBackgroundImage(const QString &path)
{
QJsonObject b = m_config->backgroundObj();
b.insert("imagePath", path);
m_config->setBackground(b);
}
void ButtonController::setFeedbackColors(const QString &okColor, const QString &errorColor)
{
QJsonObject f = m_config->feedbackObj();
f.insert("okColor", okColor);
f.insert("errorColor", errorColor);
m_config->setFeedback(f);
}
void ButtonController::setFeedbackWidths(int okWidth, int errorWidth)
{
QJsonObject f = m_config->feedbackObj();
f.insert("okWidth", okWidth);
f.insert("errorWidth", errorWidth);
m_config->setFeedback(f);
}
void ButtonController::setFeedbackGlowRadius(int radius)
{
QJsonObject f = m_config->feedbackObj();
f.insert("glowRadius", radius);
m_config->setFeedback(f);
}
void ButtonController::setDarkMode(bool dark)
{
QJsonObject s = m_config->settingsObj();
s.insert("darkMode", dark);
m_config->setSettings(s);
}
void ButtonController::setPassword(const QString &pwd)
{
QJsonObject s = m_config->settingsObj();
s.insert("password", pwd);
m_config->setSettings(s);
}
QString ButtonController::iconsDir() const
{
return m_config->iconsDir();
}
QStringList ButtonController::listIcons() const
{
QDir d(m_config->iconsDir());
QStringList filters;
filters << "*.png" << "*.jpg" << "*.jpeg" << "*.svg" << "*.bmp";
return d.entryList(filters, QDir::Files | QDir::Readable, QDir::Name);
}
+71
View File
@@ -0,0 +1,71 @@
#ifndef BUTTONCONTROLLER_H
#define BUTTONCONTROLLER_H
#include <QObject>
#include <QString>
#include <QJsonObject>
#include <QNetworkAccessManager>
class ConfigManager;
class ButtonsModel;
class ButtonController : public QObject
{
Q_OBJECT
public:
explicit ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent = nullptr);
Q_INVOKABLE void invokeButton(const QString &id);
Q_INVOKABLE bool checkPassword(const QString &pwd) const;
// CRUD wrappers (so QML can call without touching ConfigManager directly)
Q_INVOKABLE void addButton(const QString &label,
const QString &iconPath,
const QString &actionType,
const QString &actionUrl,
const QString &successText,
const QString &errorText,
const QString &color = QString());
Q_INVOKABLE void updateButton(const QString &id,
const QString &label,
const QString &iconPath,
const QString &actionType,
const QString &actionUrl,
const QString &successText,
const QString &errorText,
const QString &color = QString());
Q_INVOKABLE void removeButton(const QString &id);
Q_INVOKABLE void moveButton(int from, int to);
// Layout / background helpers
Q_INVOKABLE void setLayoutMode(const QString &mode);
Q_INVOKABLE void setLayoutColumns(int columns);
Q_INVOKABLE void setLayoutSpacing(int spacing);
Q_INVOKABLE void setLayoutShowLabels(bool show);
Q_INVOKABLE void setBackgroundType(const QString &type);
Q_INVOKABLE void setBackgroundColors(const QString &c1, const QString &c2);
Q_INVOKABLE void setBackgroundAnimated(bool animated);
Q_INVOKABLE void setBackgroundImage(const QString &path);
Q_INVOKABLE void setFeedbackColors(const QString &okColor, const QString &errorColor);
Q_INVOKABLE void setFeedbackWidths(int okWidth, int errorWidth);
Q_INVOKABLE void setFeedbackGlowRadius(int radius);
Q_INVOKABLE void setDarkMode(bool dark);
Q_INVOKABLE void setPassword(const QString &pwd);
Q_INVOKABLE QString iconsDir() const;
Q_INVOKABLE QStringList listIcons() const;
signals:
void buttonInvoked(QString id, bool success, QString message);
private:
ConfigManager *m_config;
ButtonsModel *m_model;
QNetworkAccessManager *m_nam;
};
#endif // BUTTONCONTROLLER_H
+125
View File
@@ -0,0 +1,125 @@
#include "buttonsmodel.h"
#include "configmanager.h"
#include <QJsonDocument>
#include <QFileInfo>
#include <QDir>
ButtonsModel::ButtonsModel(ConfigManager *config, QObject *parent)
: QAbstractListModel(parent)
, m_config(config)
{
connect(m_config, &ConfigManager::buttonsChanged, this, &ButtonsModel::reload);
reload();
}
void ButtonsModel::reload()
{
beginResetModel();
m_buttons = m_config->buttons();
// prune runtime state for removed ids
QSet<QString> ids;
for (const auto &v : m_buttons) ids.insert(v.toObject().value("id").toString());
for (auto it = m_runtime.begin(); it != m_runtime.end(); ) {
if (!ids.contains(it.key())) it = m_runtime.erase(it);
else ++it;
}
endResetModel();
emit countChanged();
}
int ButtonsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_buttons.size();
}
QVariant ButtonsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_buttons.size())
return {};
QJsonObject b = m_buttons.at(index.row()).toObject();
QString id = b.value("id").toString();
QJsonObject action = b.value("action").toObject();
QJsonObject feedback = b.value("feedback").toObject();
RuntimeState rt = m_runtime.value(id);
switch (role) {
case IdRole: return id;
case LabelRole: return b.value("label").toString();
case IconPathRole: {
QString p = b.value("iconPath").toString();
if (p.isEmpty()) return QString();
if (p.startsWith("qrc:/") || p.startsWith(":/") || p.startsWith("file://") || p.startsWith("http"))
return p;
if (QFileInfo(p).isAbsolute())
return QString("file:///") + p;
return QString("file:///") + QDir(m_config->iconsDir()).filePath(p);
}
case ActionTypeRole: return action.value("type").toString("http_get");
case ActionUrlRole: return action.value("url").toString();
case ActionHeadersRole: return QJsonDocument(action.value("headers").toObject()).toJson(QJsonDocument::Compact);
case ActionBodyRole: return action.value("body").toString();
case SuccessTextRole: return feedback.value("successText").toString();
case ErrorTextRole: return feedback.value("errorText").toString();
case FadeMsRole: return feedback.value("fadeMs").toInt(5000);
case StatusRole: return rt.status;
case StatusTextRole: return rt.text;
case BtnColorRole: return b.value("color").toString("#7b007b");
}
return {};
}
QHash<int, QByteArray> ButtonsModel::roleNames() const
{
return {
{IdRole, "btnId"},
{LabelRole, "label"},
{IconPathRole, "iconPath"},
{ActionTypeRole, "actionType"},
{ActionUrlRole, "actionUrl"},
{ActionHeadersRole, "actionHeaders"},
{ActionBodyRole, "actionBody"},
{SuccessTextRole, "successText"},
{ErrorTextRole, "errorText"},
{FadeMsRole, "fadeMs"},
{StatusRole, "status"},
{StatusTextRole, "statusText"},
{BtnColorRole, "btnColor"}
};
}
QVariantMap ButtonsModel::get(int row) const
{
QVariantMap m;
if (row < 0 || row >= m_buttons.size()) return m;
auto rn = roleNames();
for (auto it = rn.begin(); it != rn.end(); ++it)
m.insert(QString::fromUtf8(it.value()), data(index(row, 0), it.key()));
return m;
}
int ButtonsModel::indexOfId(const QString &id) const
{
for (int i = 0; i < m_buttons.size(); ++i)
if (m_buttons.at(i).toObject().value("id").toString() == id)
return i;
return -1;
}
void ButtonsModel::setStatus(const QString &id, int status, const QString &statusText)
{
int row = indexOfId(id);
if (row < 0) return;
RuntimeState &rt = m_runtime[id];
rt.status = status;
rt.text = statusText;
QModelIndex idx = index(row, 0);
emit dataChanged(idx, idx, {StatusRole, StatusTextRole});
}
QVariantMap ButtonsModel::buttonToVariant(const QJsonObject &b, int row) const
{
Q_UNUSED(b); Q_UNUSED(row);
return {};
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef BUTTONSMODEL_H
#define BUTTONSMODEL_H
#include <QAbstractListModel>
#include <QJsonObject>
#include <QJsonArray>
class ConfigManager;
class ButtonsModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public:
enum Roles {
IdRole = Qt::UserRole + 1,
LabelRole,
IconPathRole,
ActionTypeRole,
ActionUrlRole,
ActionHeadersRole,
ActionBodyRole,
SuccessTextRole,
ErrorTextRole,
FadeMsRole,
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
StatusTextRole,
BtnColorRole
};
explicit ButtonsModel(ConfigManager *config, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE QVariantMap get(int row) const;
Q_INVOKABLE int indexOfId(const QString &id) const;
// Per-button transient runtime state (status / status text)
void setStatus(const QString &id, int status, const QString &statusText);
signals:
void countChanged();
private slots:
void reload();
private:
QVariantMap buttonToVariant(const QJsonObject &b, int row) const;
ConfigManager *m_config;
QJsonArray m_buttons;
// runtime: id -> {status, text}
struct RuntimeState { int status = 0; QString text; };
QHash<QString, RuntimeState> m_runtime;
};
#endif // BUTTONSMODEL_H
+280
View File
@@ -0,0 +1,280 @@
#include "configmanager.h"
#include <QFile>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QDir>
#include <QFileInfo>
#include <QCoreApplication>
#include <QDebug>
#include <QUuid>
ConfigManager::ConfigManager(const QString &configPath, QObject *parent)
: QObject(parent)
, m_configPath(configPath)
{
connect(&m_watcher, &QFileSystemWatcher::fileChanged,
this, &ConfigManager::onFileChanged);
load();
}
bool ConfigManager::load()
{
QFile f(m_configPath);
if (!f.exists()) {
qWarning() << "Config not found at" << m_configPath << "- creating defaults";
ensureDefaults();
save();
rewatch();
emit configChanged();
emit buttonsChanged();
return true;
}
if (!f.open(QIODevice::ReadOnly)) {
qWarning() << "Cannot open config:" << f.errorString();
return false;
}
QByteArray raw = f.readAll();
f.close();
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "Config parse error:" << err.errorString();
return false;
}
m_root = doc.object();
ensureDefaults();
rewatch();
emit configChanged();
emit buttonsChanged();
return true;
}
bool ConfigManager::save()
{
QFileInfo fi(m_configPath);
QDir().mkpath(fi.absolutePath());
QSaveFile f(m_configPath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qWarning() << "Cannot open config for writing:" << f.errorString();
return false;
}
m_writingOurselves = true;
QJsonDocument doc(m_root);
f.write(doc.toJson(QJsonDocument::Indented));
bool ok = f.commit();
m_writingOurselves = false;
if (!ok) {
qWarning() << "Failed to commit config:" << f.errorString();
return false;
}
rewatch();
return true;
}
void ConfigManager::onFileChanged(const QString &path)
{
Q_UNUSED(path);
if (m_writingOurselves)
return;
// Debounce: re-add watcher (some editors replace file)
rewatch();
QFile f(m_configPath);
if (!f.exists()) return;
if (!f.open(QIODevice::ReadOnly)) return;
QByteArray raw = f.readAll();
f.close();
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject())
return;
QJsonObject newRoot = doc.object();
if (newRoot == m_root)
return;
m_root = newRoot;
ensureDefaults();
emit configChanged();
emit buttonsChanged();
}
void ConfigManager::rewatch()
{
if (!m_watcher.files().isEmpty())
m_watcher.removePaths(m_watcher.files());
if (QFile::exists(m_configPath))
m_watcher.addPath(m_configPath);
}
void ConfigManager::ensureDefaults()
{
if (!m_root.contains("version"))
m_root.insert("version", 2);
QJsonObject layout = m_root.value("layout").toObject();
if (!layout.contains("mode")) layout.insert("mode", "grid");
if (!layout.contains("columns")) layout.insert("columns", 2);
if (!layout.contains("spacing")) layout.insert("spacing", 10);
if (!layout.contains("showLabels")) layout.insert("showLabels", true);
m_root.insert("layout", layout);
QJsonObject bg = m_root.value("background").toObject();
if (!bg.contains("type")) bg.insert("type", "gradient");
if (!bg.contains("color1")) bg.insert("color1", "#00007b");
if (!bg.contains("color2")) bg.insert("color2", "#7b007b");
if (!bg.contains("animated")) bg.insert("animated", true);
if (!bg.contains("imagePath")) bg.insert("imagePath", "");
m_root.insert("background", bg);
QJsonObject fb = m_root.value("feedback").toObject();
if (!fb.contains("okColor")) fb.insert("okColor", "#00cc44");
if (!fb.contains("errorColor")) fb.insert("errorColor", "#cc2200");
if (!fb.contains("okWidth")) fb.insert("okWidth", 6);
if (!fb.contains("errorWidth")) fb.insert("errorWidth", 6);
if (!fb.contains("glowRadius")) fb.insert("glowRadius", 20);
m_root.insert("feedback", fb);
QJsonObject st = m_root.value("settings").toObject();
if (!st.contains("darkMode")) st.insert("darkMode", true);
if (!st.contains("password")) st.insert("password", "admin");
if (!st.contains("kioskMode")) st.insert("kioskMode", false);
if (!st.contains("iconsDir")) {
QString defDir = QCoreApplication::applicationDirPath() + "/icons";
st.insert("iconsDir", defDir);
}
if (!st.contains("webPort")) st.insert("webPort", 8080);
m_root.insert("settings", st);
if (!m_root.contains("buttons") || !m_root.value("buttons").isArray())
m_root.insert("buttons", QJsonArray());
// ensure ids on each button
QJsonArray btns = m_root.value("buttons").toArray();
bool changed = false;
for (int i = 0; i < btns.size(); ++i) {
QJsonObject b = btns[i].toObject();
if (!b.contains("id") || b.value("id").toString().isEmpty()) {
b.insert("id", QUuid::createUuid().toString(QUuid::WithoutBraces));
btns[i] = b;
changed = true;
}
}
if (changed) m_root.insert("buttons", btns);
QDir().mkpath(iconsDir());
}
QJsonArray ConfigManager::buttons() const
{
return m_root.value("buttons").toArray();
}
QJsonObject ConfigManager::layoutObj() const
{
return m_root.value("layout").toObject();
}
QJsonObject ConfigManager::backgroundObj() const
{
return m_root.value("background").toObject();
}
QJsonObject ConfigManager::feedbackObj() const
{
return m_root.value("feedback").toObject();
}
QJsonObject ConfigManager::settingsObj() const
{
return m_root.value("settings").toObject();
}
QString ConfigManager::iconsDir() const
{
return settingsObj().value("iconsDir").toString();
}
void ConfigManager::setButtons(const QJsonArray &arr)
{
m_root.insert("buttons", arr);
save();
emit configChanged();
emit buttonsChanged();
}
void ConfigManager::setLayout(const QJsonObject &obj)
{
m_root.insert("layout", obj);
save();
emit configChanged();
}
void ConfigManager::setBackground(const QJsonObject &obj)
{
m_root.insert("background", obj);
save();
emit configChanged();
}
void ConfigManager::setFeedback(const QJsonObject &obj)
{
m_root.insert("feedback", obj);
save();
emit configChanged();
}
void ConfigManager::setSettings(const QJsonObject &obj)
{
m_root.insert("settings", obj);
save();
emit configChanged();
}
void ConfigManager::addButton(const QJsonObject &btn)
{
QJsonArray arr = buttons();
QJsonObject b = btn;
if (!b.contains("id") || b.value("id").toString().isEmpty())
b.insert("id", QUuid::createUuid().toString(QUuid::WithoutBraces));
arr.append(b);
setButtons(arr);
}
void ConfigManager::removeButton(const QString &id)
{
QJsonArray arr = buttons();
for (int i = 0; i < arr.size(); ++i) {
if (arr[i].toObject().value("id").toString() == id) {
arr.removeAt(i);
setButtons(arr);
return;
}
}
}
void ConfigManager::updateButton(const QString &id, const QJsonObject &btn)
{
QJsonArray arr = buttons();
for (int i = 0; i < arr.size(); ++i) {
if (arr[i].toObject().value("id").toString() == id) {
QJsonObject merged = btn;
merged.insert("id", id);
arr[i] = merged;
setButtons(arr);
return;
}
}
}
void ConfigManager::moveButton(int from, int to)
{
QJsonArray arr = buttons();
if (from < 0 || from >= arr.size() || to < 0 || to >= arr.size() || from == to)
return;
QJsonValue v = arr.at(from);
arr.removeAt(from);
arr.insert(to, v);
setButtons(arr);
}
+66
View File
@@ -0,0 +1,66 @@
#ifndef CONFIGMANAGER_H
#define CONFIGMANAGER_H
#include <QObject>
#include <QString>
#include <QJsonObject>
#include <QJsonArray>
#include <QFileSystemWatcher>
class ConfigManager : public QObject
{
Q_OBJECT
Q_PROPERTY(QJsonObject layout READ layoutObj NOTIFY configChanged)
Q_PROPERTY(QJsonObject background READ backgroundObj NOTIFY configChanged)
Q_PROPERTY(QJsonObject feedback READ feedbackObj NOTIFY configChanged)
Q_PROPERTY(QJsonObject settings READ settingsObj NOTIFY configChanged)
Q_PROPERTY(QString iconsDir READ iconsDir NOTIFY configChanged)
Q_PROPERTY(QString configPath READ configPath CONSTANT)
public:
explicit ConfigManager(const QString &configPath, QObject *parent = nullptr);
bool load();
bool save();
QJsonObject root() const { return m_root; }
QJsonArray buttons() const;
QJsonObject layoutObj() const;
QJsonObject backgroundObj() const;
QJsonObject feedbackObj() const;
QJsonObject settingsObj() const;
QString configPath() const { return m_configPath; }
QString iconsDir() const;
// Mutators (used by QML/ButtonController/SettingsContainer)
void setButtons(const QJsonArray &arr);
void setLayout(const QJsonObject &obj);
void setBackground(const QJsonObject &obj);
void setFeedback(const QJsonObject &obj);
void setSettings(const QJsonObject &obj);
// Convenience
void addButton(const QJsonObject &btn);
void removeButton(const QString &id);
void updateButton(const QString &id, const QJsonObject &btn);
void moveButton(int from, int to);
signals:
void configChanged();
void buttonsChanged();
private slots:
void onFileChanged(const QString &path);
private:
void ensureDefaults();
void rewatch();
QString m_configPath;
QJsonObject m_root;
QFileSystemWatcher m_watcher;
bool m_writingOurselves = false;
};
#endif // CONFIGMANAGER_H
+99
View File
@@ -0,0 +1,99 @@
#include "settingscontainer.h"
#include "configmanager.h"
SettingsContainer::SettingsContainer(ConfigManager *config, QObject *parent)
: QObject(parent)
, m_config(config)
{
connect(m_config, &ConfigManager::configChanged, this, &SettingsContainer::changed);
}
bool SettingsContainer::darkMode() const
{
return m_config->settingsObj().value("darkMode").toBool(true);
}
bool SettingsContainer::kioskMode() const
{
return m_config->settingsObj().value("kioskMode").toBool(false);
}
int SettingsContainer::webPort() const
{
return m_config->settingsObj().value("webPort").toInt(8080);
}
QString SettingsContainer::iconsDir() const
{
return m_config->iconsDir();
}
QString SettingsContainer::layoutMode() const
{
return m_config->layoutObj().value("mode").toString("grid");
}
int SettingsContainer::layoutColumns() const
{
return m_config->layoutObj().value("columns").toInt(2);
}
int SettingsContainer::layoutSpacing() const
{
return m_config->layoutObj().value("spacing").toInt(10);
}
bool SettingsContainer::showLabels() const
{
return m_config->layoutObj().value("showLabels").toBool(true);
}
QString SettingsContainer::backgroundType() const
{
return m_config->backgroundObj().value("type").toString("gradient");
}
QString SettingsContainer::backgroundColor1() const
{
return m_config->backgroundObj().value("color1").toString("#00007b");
}
QString SettingsContainer::backgroundColor2() const
{
return m_config->backgroundObj().value("color2").toString("#7b007b");
}
bool SettingsContainer::backgroundAnimated() const
{
return m_config->backgroundObj().value("animated").toBool(true);
}
QString SettingsContainer::backgroundImage() const
{
return m_config->backgroundObj().value("imagePath").toString();
}
QString SettingsContainer::feedbackOkColor() const
{
return m_config->feedbackObj().value("okColor").toString("#00cc44");
}
QString SettingsContainer::feedbackErrorColor() const
{
return m_config->feedbackObj().value("errorColor").toString("#cc2200");
}
int SettingsContainer::feedbackOkWidth() const
{
return m_config->feedbackObj().value("okWidth").toInt(6);
}
int SettingsContainer::feedbackErrorWidth() const
{
return m_config->feedbackObj().value("errorWidth").toInt(6);
}
int SettingsContainer::feedbackGlowRadius() const
{
return m_config->feedbackObj().value("glowRadius").toInt(20);
}
+62
View File
@@ -0,0 +1,62 @@
#ifndef SETTINGSCONTAINER_H
#define SETTINGSCONTAINER_H
#include <QObject>
class ConfigManager;
class SettingsContainer : public QObject
{
Q_OBJECT
Q_PROPERTY(bool darkMode READ darkMode NOTIFY changed)
Q_PROPERTY(bool kioskMode READ kioskMode NOTIFY changed)
Q_PROPERTY(int webPort READ webPort NOTIFY changed)
Q_PROPERTY(QString iconsDir READ iconsDir NOTIFY changed)
Q_PROPERTY(QString layoutMode READ layoutMode NOTIFY changed)
Q_PROPERTY(int layoutColumns READ layoutColumns NOTIFY changed)
Q_PROPERTY(int layoutSpacing READ layoutSpacing NOTIFY changed)
Q_PROPERTY(bool showLabels READ showLabels NOTIFY changed)
Q_PROPERTY(QString backgroundType READ backgroundType NOTIFY changed)
Q_PROPERTY(QString backgroundColor1 READ backgroundColor1 NOTIFY changed)
Q_PROPERTY(QString backgroundColor2 READ backgroundColor2 NOTIFY changed)
Q_PROPERTY(bool backgroundAnimated READ backgroundAnimated NOTIFY changed)
Q_PROPERTY(QString backgroundImage READ backgroundImage NOTIFY changed)
Q_PROPERTY(QString feedbackOkColor READ feedbackOkColor NOTIFY changed)
Q_PROPERTY(QString feedbackErrorColor READ feedbackErrorColor NOTIFY changed)
Q_PROPERTY(int feedbackOkWidth READ feedbackOkWidth NOTIFY changed)
Q_PROPERTY(int feedbackErrorWidth READ feedbackErrorWidth NOTIFY changed)
Q_PROPERTY(int feedbackGlowRadius READ feedbackGlowRadius NOTIFY changed)
public:
explicit SettingsContainer(ConfigManager *config, QObject *parent = nullptr);
bool darkMode() const;
bool kioskMode() const;
int webPort() const;
QString iconsDir() const;
QString layoutMode() const;
int layoutColumns() const;
int layoutSpacing() const;
bool showLabels() const;
QString backgroundType() const;
QString backgroundColor1() const;
QString backgroundColor2() const;
bool backgroundAnimated() const;
QString backgroundImage() const;
QString feedbackOkColor() const;
QString feedbackErrorColor() const;
int feedbackOkWidth() const;
int feedbackErrorWidth() const;
int feedbackGlowRadius() const;
signals:
void changed();
private:
ConfigManager *m_config;
};
#endif // SETTINGSCONTAINER_H
+214
View File
@@ -0,0 +1,214 @@
#include "systeminfo.h"
#include <QCoreApplication>
#include <QProcess>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDateTime>
#include <QProcessEnvironment>
#include <QRegExp>
#include <QHostAddress>
SystemInfo::SystemInfo(QObject *parent)
: QObject(parent)
{
// Heartbeat for the OTA health-check: touch run/qt.alive periodically.
writeHeartbeat();
connect(&m_heartbeat, &QTimer::timeout, this, &SystemInfo::writeHeartbeat);
m_heartbeat.start(5000);
}
QString SystemInfo::installRoot() const
{
QString env = qEnvironmentVariable("BUTTONTASK_ROOT");
if (!env.isEmpty())
return env;
return QStringLiteral("/opt/buttontask");
}
QString SystemInfo::runDir() const
{
QString d = installRoot() + "/run";
QDir().mkpath(d);
return d;
}
QString SystemInfo::scriptsDir() const
{
// Prefer the installed scripts under <root>/scripts; fall back to the copy
// shipped next to the binary (dev / source tree).
QString installed = installRoot() + "/scripts";
if (QFileInfo::exists(installed))
return installed;
QString local = QCoreApplication::applicationDirPath() + "/webconfig/scripts";
if (QFileInfo::exists(local))
return local;
return installed;
}
void SystemInfo::writeHeartbeat()
{
QSaveFile f(runDir() + "/qt.alive");
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
return;
QByteArray ts = QByteArray::number(QDateTime::currentSecsSinceEpoch());
f.write(ts);
f.commit();
}
QString SystemInfo::appVersion() const
{
return QCoreApplication::applicationVersion();
}
QString SystemInfo::currentVersion() const
{
QString manifestPath = installRoot() + "/current/manifest.json";
QFile f(manifestPath);
if (!f.open(QIODevice::ReadOnly))
return appVersion();
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
f.close();
if (!doc.isObject())
return appVersion();
return doc.object().value("version").toString(appVersion());
}
QVariantList SystemInfo::networkInfo() const
{
QVariantList result;
QProcess proc;
proc.start("nmcli", {"-t", "-f",
"DEVICE,TYPE,STATE,CONNECTION",
"device", "status"});
if (!proc.waitForStarted(2000))
return result;
if (!proc.waitForFinished(4000))
return result;
const QString out = QString::fromUtf8(proc.readAllStandardOutput());
const QStringList lines = out.split('\n', QString::SkipEmptyParts);
for (const QString &line : lines) {
const QStringList parts = line.split(':');
if (parts.size() < 4)
continue;
if (parts.at(1) != "ethernet")
continue;
QVariantMap dev;
dev.insert("device", parts.at(0));
dev.insert("state", parts.at(2));
dev.insert("connection", parts.at(3));
// Per-device IPv4 details.
QProcess det;
det.start("nmcli", {"-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS",
"device", "show", parts.at(0)});
if (det.waitForStarted(1500) && det.waitForFinished(3000)) {
const QStringList dlines =
QString::fromUtf8(det.readAllStandardOutput())
.split('\n', QString::SkipEmptyParts);
QStringList addrs, dns;
QString gw;
for (const QString &dl : dlines) {
const int colon = dl.indexOf(':');
if (colon < 0) continue;
const QString key = dl.left(colon);
const QString val = dl.mid(colon + 1).trimmed();
if (val.isEmpty()) continue;
if (key.startsWith("IP4.ADDRESS")) addrs << val;
else if (key == "IP4.GATEWAY") gw = val;
else if (key.startsWith("IP4.DNS")) dns << val;
}
dev.insert("addresses", addrs.join(", "));
dev.insert("gateway", gw);
dev.insert("dns", dns.join(", "));
}
result.append(dev);
}
return result;
}
QVariantMap SystemInfo::applyNetwork(const QString &iface,
const QString &mode,
const QString &address,
int prefix,
const QString &gateway,
const QString &dns)
{
QVariantMap res;
res.insert("ok", false);
// Validation (defence in depth; bt-netconfig validates again).
QRegExp ifaceRe("^[A-Za-z0-9_.:-]{1,32}$");
if (!ifaceRe.exactMatch(iface)) {
res.insert("error", "Некорректное имя интерфейса");
return res;
}
if (mode != "dhcp" && mode != "static") {
res.insert("error", "Режим должен быть dhcp или static");
return res;
}
QStringList args;
args << "-n" << (scriptsDir() + "/bt-netconfig") << iface << mode;
if (mode == "static") {
QHostAddress addr;
if (address.isEmpty() || !addr.setAddress(address)) {
res.insert("error", "Некорректный IP-адрес");
return res;
}
if (prefix < 1 || prefix > 32) {
res.insert("error", "Префикс должен быть 1..32");
return res;
}
QHostAddress gw;
if (!gateway.isEmpty() && !gw.setAddress(gateway)) {
res.insert("error", "Некорректный шлюз");
return res;
}
args << address << QString::number(prefix) << gateway << dns;
}
QProcess proc;
proc.start("sudo", args);
if (!proc.waitForStarted(3000)) {
res.insert("error", "Не удалось запустить sudo/bt-netconfig");
return res;
}
if (!proc.waitForFinished(30000)) {
proc.kill();
res.insert("error", "Таймаут применения настроек");
return res;
}
const QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
const QString errOut = QString::fromUtf8(proc.readAllStandardError()).trimmed();
if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
res.insert("error", errOut.isEmpty() ? out : errOut);
return res;
}
res.insert("ok", true);
res.insert("output", out);
return res;
}
QVariantMap SystemInfo::updateStatus() const
{
QVariantMap result;
result.insert("state", "idle");
QFile f(runDir() + "/update-status.json");
if (!f.open(QIODevice::ReadOnly))
return result;
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
f.close();
if (doc.isObject())
return doc.object().toVariantMap();
return result;
}
void SystemInfo::refresh()
{
emit changed();
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef SYSTEMINFO_H
#define SYSTEMINFO_H
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include <QTimer>
// Read-only system information exposed to QML: application version, current
// installed (OTA) version, a live Ethernet summary via nmcli, and the latest
// update status. Also writes a periodic heartbeat file consumed by the OTA
// health-check to confirm the UI actually started.
class SystemInfo : public QObject
{
Q_OBJECT
Q_PROPERTY(QString appVersion READ appVersion CONSTANT)
Q_PROPERTY(QString currentVersion READ currentVersion NOTIFY changed)
public:
explicit SystemInfo(QObject *parent = nullptr);
QString appVersion() const;
QString currentVersion() const;
Q_INVOKABLE QVariantList networkInfo() const;
Q_INVOKABLE QVariantMap applyNetwork(const QString &iface,
const QString &mode,
const QString &address = QString(),
int prefix = 24,
const QString &gateway = QString(),
const QString &dns = QString());
Q_INVOKABLE QVariantMap updateStatus() const;
Q_INVOKABLE void refresh();
signals:
void changed();
private:
QString installRoot() const;
QString runDir() const;
QString scriptsDir() const;
void writeHeartbeat();
QTimer m_heartbeat;
};
#endif // SYSTEMINFO_H
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Core configuration REST API (buttons, layout, background, feedback,
settings, icons) for the ButtonTask web configurator."""
import uuid
from pathlib import Path
from flask import Blueprint, abort, jsonify, request, send_from_directory
from auth import login_required
from config_store import (ALLOWED_ICON_EXT, icons_dir, list_icons, load_config,
save_config)
api_bp = Blueprint("api", __name__)
@api_bp.route("/icons/<path:fname>")
@login_required
def serve_icon(fname):
return send_from_directory(str(icons_dir()), fname)
# --- config ---
@api_bp.route("/api/config", methods=["GET"])
@login_required
def api_get_config():
return jsonify(load_config())
@api_bp.route("/api/config", methods=["PUT"])
@login_required
def api_put_config():
data = request.get_json(force=True, silent=True)
if not isinstance(data, dict):
abort(400, "JSON object required")
save_config(data)
return jsonify({"ok": True})
# --- buttons ---
@api_bp.route("/api/buttons", methods=["POST"])
@login_required
def api_add_button():
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
btn = {
"id": data.get("id") or uuid.uuid4().hex,
"label": data.get("label", "Кнопка"),
"iconPath": data.get("iconPath", ""),
"action": {
"type": data.get("actionType", "http_get"),
"url": data.get("url", ""),
"headers": data.get("headers", {}),
"body": data.get("body", ""),
},
"feedback": {
"successText": data.get("successText", ""),
"errorText": data.get("errorText", "Ошибка"),
"fadeMs": int(data.get("fadeMs", 5000)),
},
"color": data.get("color", "#7b007b"),
}
cfg.setdefault("buttons", []).append(btn)
save_config(cfg)
return jsonify(btn)
@api_bp.route("/api/buttons/<bid>", methods=["PUT"])
@login_required
def api_update_button(bid):
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
for b in cfg.get("buttons", []):
if b.get("id") == bid:
b["label"] = data.get("label", b.get("label"))
b["iconPath"] = data.get("iconPath", b.get("iconPath"))
action = b.setdefault("action", {})
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
if "headers" in data:
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
feedback = b.setdefault("feedback", {})
feedback["successText"] = data.get("successText", feedback.get("successText", ""))
feedback["errorText"] = data.get("errorText", feedback.get("errorText", "Ошибка"))
feedback["fadeMs"] = int(data.get("fadeMs", feedback.get("fadeMs", 5000)))
if "color" in data:
b["color"] = data["color"]
save_config(cfg)
return jsonify(b)
abort(404)
@api_bp.route("/api/buttons/<bid>", methods=["DELETE"])
@login_required
def api_delete_button(bid):
cfg = load_config()
cfg["buttons"] = [b for b in cfg.get("buttons", []) if b.get("id") != bid]
save_config(cfg)
return jsonify({"ok": True})
@api_bp.route("/api/buttons/reorder", methods=["POST"])
@login_required
def api_reorder_buttons():
data = request.get_json(force=True, silent=True) or {}
order = data.get("order", [])
cfg = load_config()
by_id = {b.get("id"): b for b in cfg.get("buttons", [])}
new_list = [by_id[i] for i in order if i in by_id]
for b in cfg.get("buttons", []):
if b not in new_list:
new_list.append(b)
cfg["buttons"] = new_list
save_config(cfg)
return jsonify({"ok": True, "count": len(new_list)})
# --- layout / background / feedback / settings ---
@api_bp.route("/api/layout", methods=["PUT"])
@login_required
def api_set_layout():
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
layout = cfg.setdefault("layout", {})
for k in ("mode", "columns", "spacing", "showLabels"):
if k in data:
layout[k] = data[k]
save_config(cfg)
return jsonify(layout)
@api_bp.route("/api/background", methods=["PUT"])
@login_required
def api_set_background():
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
bg = cfg.setdefault("background", {})
for k in ("type", "color1", "color2", "animated", "imagePath"):
if k in data:
bg[k] = data[k]
save_config(cfg)
return jsonify(bg)
@api_bp.route("/api/feedback", methods=["PUT"])
@login_required
def api_set_feedback():
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
fb = cfg.setdefault("feedback", {})
for k in ("okColor", "errorColor"):
if k in data:
fb[k] = data[k]
for k in ("okWidth", "errorWidth", "glowRadius"):
if k in data:
try:
fb[k] = int(data[k])
except (TypeError, ValueError):
pass
save_config(cfg)
return jsonify(fb)
@api_bp.route("/api/settings", methods=["PUT"])
@login_required
def api_set_settings():
data = request.get_json(force=True, silent=True) or {}
cfg = load_config()
st = cfg.setdefault("settings", {})
for k in ("darkMode", "password", "kioskMode", "iconsDir", "webPort"):
if k in data:
st[k] = data[k]
save_config(cfg)
return jsonify({"ok": True})
# --- icons ---
@api_bp.route("/api/icons", methods=["GET"])
@login_required
def api_list_icons():
return jsonify(list_icons())
@api_bp.route("/api/icons", methods=["POST"])
@login_required
def api_upload_icon():
if "file" not in request.files:
abort(400, "file required")
fobj = request.files["file"]
fname = Path(fobj.filename).name
if not fname:
abort(400, "empty filename")
ext = Path(fname).suffix.lower()
if ext not in ALLOWED_ICON_EXT:
abort(400, f"extension {ext} not allowed")
target = icons_dir() / fname
fobj.save(str(target))
return jsonify({"ok": True, "name": fname})
@api_bp.route("/api/icons/<path:fname>", methods=["DELETE"])
@login_required
def api_delete_icon(fname):
p = icons_dir() / fname
try:
p.resolve().relative_to(icons_dir().resolve())
except ValueError:
abort(400)
if p.exists():
p.unlink()
return jsonify({"ok": True})
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""ButtonTask v2 web configurator (Flask).
Application factory that wires together the modular blueprints:
- auth : login / logout / session
- api : buttons, layout, background, feedback, settings, icons
- network : Ethernet configuration via nmcli
- updater : OTA software update with rollback
Reads/writes the same JSON config used by the Qt5 application.
"""
import os
import time
from flask import Flask, jsonify, render_template
from api import api_bp
from auth import auth_bp, load_or_create_secret, login_required
from config_store import list_icons, load_config
from network import network_bp
from updater import current_version, updater_bp
def create_app():
app = Flask(__name__, template_folder="templates", static_folder="static")
app.secret_key = load_or_create_secret()
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=60 * 60 * 8, # 8 hours
MAX_CONTENT_LENGTH=512 * 1024 * 1024, # 512 MB upload cap (OTA packages)
)
app.register_blueprint(auth_bp)
app.register_blueprint(api_bp)
app.register_blueprint(network_bp)
app.register_blueprint(updater_bp)
@app.route("/")
@login_required
def index():
cfg = load_config()
return render_template("index.html", config=cfg, icons=list_icons(),
version=current_version())
@app.route("/healthz")
def healthz():
# Unauthenticated, lightweight: used by the OTA health-check.
return jsonify({"ok": True, "ts": time.time(),
"version": current_version().get("version")})
return app
app = create_app()
def main():
cfg = load_config()
port = int(cfg.get("settings", {}).get("webPort", 8080))
host = os.environ.get("BUTTONTASK_HOST", "0.0.0.0")
app.run(host=host, port=port, debug=False)
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Authentication for the ButtonTask web configurator.
Shares the password from settings.password (form login + session auth).
"""
import os
import secrets
from functools import wraps
from pathlib import Path
from flask import (Blueprint, jsonify, redirect, render_template, request,
session, url_for)
from config_store import config_path, load_config
auth_bp = Blueprint("auth", __name__)
def load_or_create_secret():
"""Persistent secret key so sessions survive restarts."""
secret_file = Path(os.environ.get(
"BUTTONTASK_SECRET",
str(config_path().parent / ".webconfig.secret"),
))
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
if secret_file.exists():
data = secret_file.read_bytes().strip()
if data:
return data
token = secrets.token_bytes(32)
secret_file.write_bytes(token)
try:
os.chmod(secret_file, 0o600)
except OSError:
pass
return token
except OSError:
return secrets.token_bytes(32)
def is_logged_in():
return bool(session.get("authed"))
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not is_logged_in():
if request.path.startswith("/api/"):
return jsonify({"error": "auth required"}), 401
return redirect(url_for("auth.login", next=request.path))
return f(*args, **kwargs)
return decorated
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
pwd = request.form.get("password", "")
cfg = load_config()
expected = cfg.get("settings", {}).get("password", "admin")
if secrets.compare_digest(pwd, expected):
session.clear()
session["authed"] = True
session.permanent = True
nxt = request.args.get("next") or request.form.get("next") or url_for("index")
if not nxt.startswith("/"):
nxt = url_for("index")
return redirect(nxt)
error = "Неверный пароль"
return render_template("login.html", error=error,
next=request.args.get("next", ""))
@auth_bp.route("/logout", methods=["POST", "GET"])
def logout():
session.clear()
return redirect(url_for("auth.login"))
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=ButtonTask Web Configurator
After=network.target
[Service]
Type=simple
User=buttontask
Group=buttontask
WorkingDirectory=/opt/buttontask/current/webconfig
Environment=BUTTONTASK_ROOT=/opt/buttontask
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
Environment=BUTTONTASK_HOST=0.0.0.0
ExecStart=/usr/bin/python3 /opt/buttontask/current/webconfig/app.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=ButtonTask Qt5 UI
After=network.target
[Service]
Type=simple
User=buttontask
Group=buttontask
WorkingDirectory=/opt/buttontask/current
Environment=BUTTONTASK_ROOT=/opt/buttontask
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
Environment=QT_QPA_PLATFORM=eglfs
ExecStart=/opt/buttontask/current/ButtonTask --config /opt/buttontask/config/config.json
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Config storage for the ButtonTask web configurator.
Reads/writes the same JSON config file consumed by the Qt5 application.
Defaults here must stay in sync with ConfigManager::ensureDefaults() in C++.
"""
import copy
import json
import os
import tempfile
from pathlib import Path
ALLOWED_ICON_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".gif"}
def config_path():
"""Resolve the active config path.
Priority:
1. BUTTONTASK_CONFIG environment variable.
2. <repo>/config/config.json next to this package (dev fallback).
"""
env = os.environ.get("BUTTONTASK_CONFIG")
if env:
return Path(env)
# webconfig/ -> repo root -> config/config.json
return (Path(__file__).resolve().parent.parent / "config" / "config.json")
def _defaults():
return {
"version": 2,
"layout": {"mode": "grid", "columns": 2, "spacing": 10, "showLabels": True},
"background": {
"type": "gradient",
"color1": "#00007b",
"color2": "#7b007b",
"animated": True,
"imagePath": "",
},
"feedback": {
"okColor": "#00cc44",
"errorColor": "#cc2200",
"okWidth": 6,
"errorWidth": 6,
"glowRadius": 20,
},
"settings": {
"darkMode": True,
"password": "admin",
"kioskMode": False,
"iconsDir": "./icons",
"webPort": 8080,
},
"buttons": [],
}
def ensure_defaults(cfg):
"""Fill in any missing sections/keys without overwriting existing values."""
base = _defaults()
if not isinstance(cfg, dict):
return base
cfg.setdefault("version", base["version"])
for section in ("layout", "background", "feedback", "settings"):
sec = cfg.setdefault(section, {})
if not isinstance(sec, dict):
sec = {}
cfg[section] = sec
for k, v in base[section].items():
sec.setdefault(k, v)
if not isinstance(cfg.get("buttons"), list):
cfg["buttons"] = []
return cfg
def load_config():
p = config_path()
if not p.exists():
cfg = ensure_defaults(_defaults())
# point iconsDir at a sensible absolute default for the web side
cfg["settings"]["iconsDir"] = str((p.parent.parent / "icons"))
return cfg
with p.open("r", encoding="utf-8") as f:
cfg = json.load(f)
return ensure_defaults(cfg)
def save_config(cfg):
p = config_path()
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".cfg.", suffix=".tmp", dir=str(p.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
os.replace(tmp, p)
except Exception:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def icons_dir():
cfg = load_config()
d = cfg.get("settings", {}).get("iconsDir") or "./icons"
p = Path(d)
if not p.is_absolute():
p = (config_path().parent.parent / d).resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def list_icons():
return sorted(
f.name for f in icons_dir().iterdir()
if f.is_file() and f.suffix.lower() in ALLOWED_ICON_EXT
)
def default_config():
return copy.deepcopy(_defaults())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Ethernet network configuration via NetworkManager (nmcli).
Reading current state is done directly (read-only nmcli). Applying a new
configuration is delegated to the privileged helper script `bt-netconfig`
(invoked through sudo) so the web service can keep running unprivileged.
"""
import ipaddress
import re
import subprocess
from flask import Blueprint, abort, jsonify, request
from auth import login_required
from paths import scripts_dir
network_bp = Blueprint("network", __name__)
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
def _run(args, timeout=10):
try:
out = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return out.returncode, out.stdout, out.stderr
except FileNotFoundError:
return 127, "", f"{args[0]} not found"
except subprocess.TimeoutExpired:
return 124, "", "timeout"
def list_ethernet_devices():
"""Return [{device, state, connection}] for ethernet interfaces."""
rc, out, _ = _run([
"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status",
])
devices = []
if rc != 0:
return devices
for line in out.splitlines():
parts = line.split(":")
if len(parts) < 4:
continue
device, dtype, state, connection = parts[0], parts[1], parts[2], parts[3]
if dtype != "ethernet":
continue
devices.append({"device": device, "state": state, "connection": connection})
return devices
def device_details(device):
"""Return current IP4 config for a device."""
rc, out, _ = _run([
"nmcli", "-t", "-f",
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION",
"device", "show", device,
])
info = {"device": device, "addresses": [], "gateway": "", "dns": [],
"connection": ""}
if rc != 0:
return info
for line in out.splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
val = val.strip()
if not val:
continue
if key.startswith("IP4.ADDRESS"):
info["addresses"].append(val)
elif key == "IP4.GATEWAY":
info["gateway"] = val
elif key.startswith("IP4.DNS"):
info["dns"].append(val)
elif key == "GENERAL.CONNECTION":
info["connection"] = val
return info
def current_state():
devices = list_ethernet_devices()
for d in devices:
d.update({k: v for k, v in device_details(d["device"]).items()
if k != "device"})
return {"devices": devices}
def _valid_ip(value):
try:
ipaddress.ip_address(value)
return True
except ValueError:
return False
def _validate_payload(data):
iface = (data.get("iface") or "").strip()
if not _IFACE_RE.match(iface):
return None, "invalid iface"
mode = (data.get("mode") or "dhcp").strip().lower()
if mode not in ("dhcp", "static"):
return None, "mode must be dhcp or static"
payload = {"iface": iface, "mode": mode}
if mode == "static":
address = (data.get("address") or "").strip()
if not _valid_ip(address):
return None, "invalid address"
try:
prefix = int(data.get("prefix", 24))
except (TypeError, ValueError):
return None, "invalid prefix"
if not (1 <= prefix <= 32):
return None, "prefix must be 1..32"
gateway = (data.get("gateway") or "").strip()
if gateway and not _valid_ip(gateway):
return None, "invalid gateway"
dns = [d.strip() for d in (data.get("dns") or "").replace(";", ",").split(",")
if d.strip()]
for d in dns:
if not _valid_ip(d):
return None, f"invalid dns: {d}"
payload.update({
"address": address,
"prefix": prefix,
"gateway": gateway,
"dns": ",".join(dns),
})
return payload, None
@network_bp.route("/api/network", methods=["GET"])
@login_required
def api_get_network():
return jsonify(current_state())
@network_bp.route("/api/network", methods=["PUT"])
@login_required
def api_set_network():
data = request.get_json(force=True, silent=True) or {}
payload, err = _validate_payload(data)
if err:
abort(400, err)
script = str(scripts_dir() / "bt-netconfig")
args = ["sudo", "-n", script, payload["iface"], payload["mode"]]
if payload["mode"] == "static":
args += [payload["address"], str(payload["prefix"]),
payload.get("gateway", ""), payload.get("dns", "")]
rc, out, err_out = _run(args, timeout=30)
if rc != 0:
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
or f"exit {rc}"}), 500
return jsonify({"ok": True, "output": out.strip()})
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Filesystem layout helpers shared by the web configurator and updater.
On the target device the layout is:
/opt/buttontask/
versions/<ver>/ (ButtonTask + webconfig/)
current -> versions/<ver>
config/ icons/ (shared, never replaced)
run/ (heartbeat, update-status.json)
scripts/ (privileged helpers, see scripts/)
"""
import os
from pathlib import Path
def install_root():
"""Resolve the ButtonTask install root (the directory holding
versions/, current, config/, run/)."""
env = os.environ.get("BUTTONTASK_ROOT")
if env:
return Path(env)
return Path("/opt/buttontask")
def run_dir():
p = install_root() / "run"
try:
p.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return p
def versions_dir():
return install_root() / "versions"
def current_link():
return install_root() / "current"
def scripts_dir():
"""Directory with the privileged helper scripts.
Prefer the installed copy under <root>/scripts, fall back to the copy
that ships next to this package (dev/source tree)."""
installed = install_root() / "scripts"
if installed.is_dir():
return installed
return Path(__file__).resolve().parent / "scripts"
QT_HEARTBEAT = "qt.alive"
UPDATE_STATUS = "update-status.json"
+1
View File
@@ -0,0 +1 @@
Flask>=2.0,<4.0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# bt-netconfig - apply Ethernet configuration via NetworkManager.
#
# Usage:
# bt-netconfig <iface> dhcp
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv]
#
# Intended to be invoked through sudo by the (unprivileged) web configurator.
# Keep this script tightly scoped: it only touches the connection bound to the
# requested ethernet interface.
set -euo pipefail
err() { echo "bt-netconfig: $*" >&2; exit 1; }
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static> ..."
IFACE="$1"
MODE="$2"
# Validate interface name (defence in depth; the web side validates too).
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
# Resolve (or create) a connection profile bound to this interface.
CON="$(nmcli -t -f GENERAL.CONNECTION device show "$IFACE" 2>/dev/null \
| sed 's/^GENERAL.CONNECTION://')"
if [ -z "$CON" ] || [ "$CON" = "--" ]; then
CON="buttontask-$IFACE"
if ! nmcli -t -f NAME connection show | grep -Fxq "$CON"; then
nmcli connection add type ethernet ifname "$IFACE" con-name "$CON" >/dev/null
fi
fi
case "$MODE" in
dhcp)
nmcli connection modify "$CON" \
ipv4.method auto \
ipv4.addresses "" \
ipv4.gateway "" \
ipv4.dns ""
;;
static)
[ "$#" -ge 4 ] || err "static requires <address> <prefix>"
ADDR="$3"
PREFIX="$4"
GW="${5:-}"
DNS="${6:-}"
[[ "$PREFIX" =~ ^[0-9]{1,2}$ ]] && [ "$PREFIX" -ge 1 ] && [ "$PREFIX" -le 32 ] \
|| err "invalid prefix"
nmcli connection modify "$CON" \
ipv4.method manual \
ipv4.addresses "${ADDR}/${PREFIX}" \
ipv4.gateway "$GW" \
ipv4.dns "$DNS"
;;
*)
err "mode must be dhcp or static"
;;
esac
# Re-apply the connection so changes take effect.
nmcli connection up "$CON" >/dev/null
echo "applied $MODE on $IFACE ($CON)"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
#
# bt-service - restart/start/stop the ButtonTask systemd units.
# Invoked through sudo by the web configurator (tightly scoped).
set -euo pipefail
ACTION="${1:-}"
case "$ACTION" in
start|stop|restart)
systemctl "$ACTION" buttontask-web buttontask
;;
*)
echo "usage: bt-service {start|stop|restart}" >&2
exit 2
;;
esac
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
#
# bt-update - OTA update with automatic rollback for ButtonTask.
#
# Usage:
# bt-update stage <package.tar.gz> # deploy + health-check + commit/rollback
# bt-update rollback # revert to the previous version
#
# Layout (see paths.py):
# $ROOT/versions/<ver>/ deployed versions (ButtonTask + webconfig/)
# $ROOT/current -> versions/<ver>
# $ROOT/run/update-status.json progress for the web UI
# $ROOT/run/qt.alive heartbeat written by the Qt UI
# $ROOT/.previous path of the previously active version
#
# Designed to be launched detached (setsid) via sudo by the web service, so it
# survives the service restart performed mid-update.
set -uo pipefail
ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}"
VERSIONS="$ROOT/versions"
CURRENT="$ROOT/current"
RUN="$ROOT/run"
STATUS="$RUN/update-status.json"
HEARTBEAT="$RUN/qt.alive"
PREV_MARK="$ROOT/.previous"
HEALTH_TIMEOUT="${BUTTONTASK_HEALTH_TIMEOUT:-30}"
mkdir -p "$RUN" "$VERSIONS"
write_status() {
local state="$1"; local msg="${2:-}"
msg="${msg//\"/}"
printf '{"state":"%s","message":"%s","ts":%s}\n' "$state" "$msg" "$(date +%s)" > "$STATUS"
}
web_port() {
python3 - <<'PY' 2>/dev/null || echo 8080
import json, os
root = os.environ.get("BUTTONTASK_ROOT", "/opt/buttontask")
try:
with open(os.path.join(root, "config", "config.json")) as f:
print(int(json.load(f).get("settings", {}).get("webPort", 8080)))
except Exception:
print(8080)
PY
}
restart_services() {
systemctl restart buttontask-web buttontask 2>/dev/null || true
}
# Returns 0 when the freshly started version looks healthy.
health_check() {
local port deadline now hb_age
port="$(web_port)"
deadline=$(( $(date +%s) + HEALTH_TIMEOUT ))
while [ "$(date +%s)" -lt "$deadline" ]; do
# 1) web answers /healthz
if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
# 2) Qt UI heartbeat is fresh (file touched in the last 20s)
if [ -f "$HEARTBEAT" ]; then
now=$(date +%s)
hb_age=$(( now - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) ))
if [ "$hb_age" -le 20 ]; then
# 3) Qt service is active
if systemctl is-active --quiet buttontask 2>/dev/null; then
return 0
fi
fi
fi
fi
sleep 2
done
return 1
}
switch_to() {
# Atomically repoint the current symlink.
ln -sfn "$1" "$CURRENT"
}
cmd_stage() {
local pkg="$1"
[ -f "$pkg" ] || { write_status error "package not found"; exit 1; }
write_status staging "распаковка"
local tmp ver dest
tmp="$(mktemp -d "$VERSIONS/.stage.XXXXXX")"
if ! tar -xzf "$pkg" -C "$tmp"; then
rm -rf "$tmp"; write_status error "bad archive"; exit 1
fi
# Some packages wrap content in a single top dir; flatten if so.
if [ ! -f "$tmp/manifest.json" ]; then
local inner
inner="$(find "$tmp" -maxdepth 2 -name manifest.json | head -n1)"
[ -n "$inner" ] && tmp="$(dirname "$inner")"
fi
[ -f "$tmp/manifest.json" ] || { rm -rf "$tmp"; write_status error "manifest.json missing"; exit 1; }
[ -x "$tmp/ButtonTask" ] || chmod +x "$tmp/ButtonTask" 2>/dev/null || true
ver="$(python3 -c "import json,sys;print(json.load(open('$tmp/manifest.json')).get('version','unknown'))" 2>/dev/null || echo unknown)"
dest="$VERSIONS/$ver"
# Remember the current version for rollback.
if [ -L "$CURRENT" ]; then
readlink -f "$CURRENT" > "$PREV_MARK"
fi
# Avoid clobbering the running version directory.
if [ "$(readlink -f "$CURRENT" 2>/dev/null)" = "$(readlink -f "$dest" 2>/dev/null)" ]; then
dest="${dest}-$(date +%s)"
fi
rm -rf "$dest"
mv "$tmp" "$dest"
chown -R buttontask:buttontask "$dest" 2>/dev/null || true
rm -f "$pkg"
write_status switching "$ver"
switch_to "$dest"
restart_services
write_status health_check "$ver"
if health_check; then
write_status success "$ver"
exit 0
fi
# Rollback.
if [ -s "$PREV_MARK" ]; then
switch_to "$(cat "$PREV_MARK")"
restart_services
write_status rolledback "запуск $ver не удался"
else
write_status error "health-check failed, no previous version"
fi
exit 1
}
cmd_rollback() {
[ -s "$PREV_MARK" ] || { write_status error "no previous version"; exit 1; }
write_status switching "rollback"
switch_to "$(cat "$PREV_MARK")"
restart_services
write_status rollback_done "откат выполнен"
}
case "${1:-}" in
stage) [ "$#" -ge 2 ] || { echo "usage: bt-update stage <pkg>"; exit 2; }; cmd_stage "$2" ;;
rollback) cmd_rollback ;;
*) echo "usage: bt-update {stage <pkg>|rollback}"; exit 2 ;;
esac
+12
View File
@@ -0,0 +1,12 @@
# ButtonTask: allow the unprivileged web configurator to run only the three
# tightly-scoped helper scripts as root, without a password.
#
# Install:
# sudo install -m 0440 buttontask.sudoers /etc/sudoers.d/buttontask
# sudo visudo -cf /etc/sudoers.d/buttontask # validate
#
# The scripts themselves must be root-owned and not writable by others:
# sudo chown root:root /opt/buttontask/scripts/bt-*
# sudo chmod 0755 /opt/buttontask/scripts/bt-*
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-netconfig, /opt/buttontask/scripts/bt-update, /opt/buttontask/scripts/bt-service
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# make-package.sh - build an OTA update package for ButtonTask.
#
# Produces dist/buttontask-<version>.tar.gz containing:
# ButtonTask (the compiled binary)
# webconfig/ (the web configurator + scripts)
# manifest.json ({"version": ..., "created": ...})
# and prints the package's sha256 (paste it into the web UI for integrity).
#
# Usage:
# make-package.sh <version> [build-dir] [out-dir]
# Example:
# ./webconfig/scripts/make-package.sh 2.1 build dist
set -euo pipefail
VERSION="${1:?usage: make-package.sh <version> [build-dir] [out-dir]}"
BUILD_DIR="${2:-build}"
OUT_DIR="${3:-dist}"
# Resolve repo root (two levels up from this script).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BIN="$REPO_ROOT/$BUILD_DIR/ButtonTask"
[ -x "$BIN" ] || { echo "binary not found: $BIN (build first)"; exit 1; }
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp "$BIN" "$STAGE/ButtonTask"
cp -r "$REPO_ROOT/webconfig" "$STAGE/webconfig"
# Drop dev artefacts from the packaged webconfig.
rm -rf "$STAGE/webconfig/__pycache__" "$STAGE/webconfig/.webconfig.secret"
cat > "$STAGE/manifest.json" <<EOF
{
"version": "$VERSION",
"created": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
mkdir -p "$REPO_ROOT/$OUT_DIR"
PKG="$REPO_ROOT/$OUT_DIR/buttontask-$VERSION.tar.gz"
tar -czf "$PKG" -C "$STAGE" .
echo "package: $PKG"
if command -v sha256sum >/dev/null 2>&1; then
echo "sha256: $(sha256sum "$PKG" | awk '{print $1}')"
fi
+336
View File
@@ -0,0 +1,336 @@
// ButtonTask v2 — web configurator client logic
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
if (r.status === 401) {
window.location.href = "/login?next=" + encodeURIComponent(window.location.pathname);
throw new Error("auth required");
}
if (!r.ok) {
let msg = `${method} ${path} -> ${r.status}`;
try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {}
throw new Error(msg);
}
return r.headers.get("content-type")?.includes("application/json")
? r.json() : r.text();
}
function flash(text, isError) {
const t = document.createElement("div");
t.className = "toast" + (isError ? " error" : "");
t.textContent = text;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2800);
}
// ---------- Tabs ----------
$$(".tab").forEach(tab => {
tab.addEventListener("click", () => {
$$(".tab").forEach(t => t.classList.remove("active"));
$$(".panel").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
$("#tab-" + tab.dataset.tab).classList.add("active");
if (tab.dataset.tab === "network") loadNetwork();
if (tab.dataset.tab === "update") loadUpdateStatus();
});
});
// ---------- Layout ----------
$("#save-layout")?.addEventListener("click", async () => {
try {
await api("PUT", "/api/layout", {
mode: $("#layout-mode").value,
columns: parseInt($("#layout-columns").value, 10),
spacing: parseInt($("#layout-spacing").value, 10),
showLabels: $("#layout-show-labels").checked,
});
flash("Расположение сохранено");
} catch (e) { flash(e.message, true); }
});
// ---------- Background ----------
$("#save-bg")?.addEventListener("click", async () => {
try {
await api("PUT", "/api/background", {
type: $("#bg-type").value,
color1: $("#bg-c1").value,
color2: $("#bg-c2").value,
animated: $("#bg-animated").checked,
imagePath: $("#bg-image").value,
});
flash("Фон сохранён");
} catch (e) { flash(e.message, true); }
});
// ---------- Feedback (borders) ----------
function syncFeedbackPreview() {
const ok = $("#fb-preview-ok"), err = $("#fb-preview-error");
if (!ok) return;
const okColor = $("#fb-ok-color").value;
const errColor = $("#fb-error-color").value;
const okW = $("#fb-ok-width").value;
const errW = $("#fb-error-width").value;
const glow = $("#fb-glow").value;
$("#fb-ok-width-val").textContent = okW;
$("#fb-error-width-val").textContent = errW;
$("#fb-glow-val").textContent = glow;
ok.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${okColor}`;
ok.style.border = `${okW}px solid ${okColor}`;
err.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${errColor}`;
err.style.border = `${errW}px solid ${errColor}`;
}
["fb-ok-color", "fb-error-color", "fb-ok-width", "fb-error-width", "fb-glow"]
.forEach(id => $("#" + id)?.addEventListener("input", syncFeedbackPreview));
syncFeedbackPreview();
$("#save-feedback")?.addEventListener("click", async () => {
try {
await api("PUT", "/api/feedback", {
okColor: $("#fb-ok-color").value,
errorColor: $("#fb-error-color").value,
okWidth: parseInt($("#fb-ok-width").value, 10),
errorWidth: parseInt($("#fb-error-width").value, 10),
glowRadius: parseInt($("#fb-glow").value, 10),
});
flash("Обводка сохранена");
} catch (e) { flash(e.message, true); }
});
// ---------- Icons ----------
$("#upload-icon")?.addEventListener("click", async () => {
const file = $("#icon-file").files[0];
if (!file) { flash("Выберите файл", true); return; }
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/icons", { method: "POST", body: fd });
if (!r.ok) { flash("Ошибка загрузки", true); return; }
location.reload();
});
$$(".icon-tile .del").forEach(btn => {
btn.addEventListener("click", async (e) => {
const name = e.target.dataset.name;
if (!confirm(`Удалить ${name}?`)) return;
await api("DELETE", `/api/icons/${encodeURIComponent(name)}`);
location.reload();
});
});
// ---------- Password ----------
$("#save-password")?.addEventListener("click", async () => {
const pwd = $("#new-password").value;
if (!pwd) { flash("Введите пароль", true); return; }
await api("PUT", "/api/settings", { password: pwd });
flash("Пароль сохранён. Действует со следующего входа.");
$("#new-password").value = "";
});
// ---------- Buttons drag-drop ----------
const list = $("#buttons-list");
if (list && window.Sortable) {
new Sortable(list, {
handle: ".handle",
animation: 150,
onEnd: async () => {
const order = Array.from(list.children).map(li => li.dataset.id);
await api("POST", "/api/buttons/reorder", { order });
flash("Порядок сохранён");
},
});
}
// ---------- Buttons CRUD ----------
const dlg = $("#btn-dialog");
const form = $("#btn-form");
function openDialogFor(btn) {
$("#btn-dialog-title").textContent = btn ? "Редактирование" : "Новая кнопка";
$("#b-id").value = btn?.id || "";
$("#b-label").value = btn?.label || "";
$("#b-icon").value = btn?.iconPath || "";
$("#b-type").value = btn?.action?.type || "http_get";
$("#b-url").value = btn?.action?.url || "";
$("#b-success").value = btn?.feedback?.successText || "";
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
$("#b-color").value = btn?.color || "#7b007b";
dlg.showModal();
}
$("#add-btn")?.addEventListener("click", () => openDialogFor(null));
$$(".buttons-list .edit").forEach(btn => {
btn.addEventListener("click", async (e) => {
const id = e.target.dataset.id;
const cfg = await api("GET", "/api/config");
const b = cfg.buttons.find(x => x.id === id);
if (b) openDialogFor(b);
});
});
$$(".buttons-list .del").forEach(btn => {
btn.addEventListener("click", async (e) => {
const id = e.target.dataset.id;
if (!confirm("Удалить кнопку?")) return;
await api("DELETE", `/api/buttons/${encodeURIComponent(id)}`);
location.reload();
});
});
form?.addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const id = $("#b-id").value;
const payload = {
label: $("#b-label").value,
iconPath: $("#b-icon").value,
actionType: $("#b-type").value,
url: $("#b-url").value,
successText: $("#b-success").value,
errorText: $("#b-error").value,
color: $("#b-color").value,
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
dlg.close();
location.reload();
});
// ---------- Network ----------
function toggleStatic() {
const wrap = $("#net-mode")?.closest(".grid-form");
if (!wrap) return;
wrap.classList.toggle("show-static", $("#net-mode").value === "static");
}
$("#net-mode")?.addEventListener("change", toggleStatic);
async function loadNetwork() {
const statusEl = $("#net-status");
const ifaceSel = $("#net-iface");
if (!statusEl) return;
statusEl.textContent = "Загрузка состояния…";
try {
const data = await api("GET", "/api/network");
const devs = data.devices || [];
if (!devs.length) {
statusEl.innerHTML = "<p class='muted'>Ethernet-интерфейсы не найдены (или nmcli недоступен).</p>";
} else {
statusEl.innerHTML = devs.map(d => {
const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis");
const badge = up ? "<span class='badge up'>up</span>" : "<span class='badge down'>down</span>";
const addr = (d.addresses || []).join(", ") || "—";
return `<div class="net-device">
<div class="name">${d.device} ${badge}</div>
<div class="detail">Соединение: ${d.connection || "—"}</div>
<div class="detail">IP: ${addr}</div>
<div class="detail">Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}</div>
</div>`;
}).join("");
}
if (ifaceSel) {
const prev = ifaceSel.value;
ifaceSel.innerHTML = devs.map(d => `<option value="${d.device}">${d.device}</option>`).join("");
if (prev) ifaceSel.value = prev;
}
} catch (e) {
statusEl.innerHTML = `<p class='warn'>${e.message}</p>`;
}
}
$("#net-refresh")?.addEventListener("click", loadNetwork);
$("#net-apply")?.addEventListener("click", async () => {
const mode = $("#net-mode").value;
const payload = { iface: $("#net-iface").value, mode };
if (mode === "static") {
payload.address = $("#net-address").value.trim();
payload.prefix = parseInt($("#net-prefix").value, 10);
payload.gateway = $("#net-gateway").value.trim();
payload.dns = $("#net-dns").value.trim();
}
if (!payload.iface) { flash("Выберите интерфейс", true); return; }
if (!confirm("Применить сетевые настройки? Соединение может прерваться.")) return;
try {
await api("PUT", "/api/network", payload);
flash("Сетевые настройки применены");
setTimeout(loadNetwork, 1500);
} catch (e) { flash(e.message, true); }
});
// ---------- Update / OTA ----------
let updatePollTimer = null;
function renderUpdateStatus(s) {
const el = $("#update-status");
if (!el) return;
const st = (s.status && s.status.state) || "idle";
const map = {
idle: ["Статус: готово к обновлению", ""],
staging: ["Распаковка пакета…", "busy"],
switching: ["Переключение версии…", "busy"],
health_check: ["Проверка работоспособности…", "busy"],
success: ["Обновление успешно применено", "ok"],
rolledback: ["Запуск новой версии не удался — выполнен откат", "fail"],
rollback_done: ["Откат выполнен", "ok"],
error: ["Ошибка обновления", "fail"],
};
const [text, cls] = map[st] || [`Статус: ${st}`, ""];
el.className = "update-status " + cls;
el.textContent = (s.status && s.status.message) ? `${text}${s.status.message}` : text;
if (s.current) {
$("#update-version").textContent = s.current.version || "—";
$("#update-path").textContent = s.current.path || "—";
}
const busy = ["staging", "switching", "health_check"].includes(st);
if (busy && !updatePollTimer) {
updatePollTimer = setInterval(loadUpdateStatus, 2000);
} else if (!busy && updatePollTimer) {
clearInterval(updatePollTimer); updatePollTimer = null;
}
}
async function loadUpdateStatus() {
try {
const s = await api("GET", "/api/update/status");
renderUpdateStatus(s);
} catch (e) {
const el = $("#update-status");
if (el) { el.className = "update-status fail"; el.textContent = e.message; }
}
}
$("#update-upload")?.addEventListener("click", async () => {
const file = $("#update-file").files[0];
if (!file) { flash("Выберите пакет .tar.gz", true); return; }
if (!confirm("Загрузить и применить обновление? Сервис будет перезапущен.")) return;
const fd = new FormData();
fd.append("file", file);
try {
const r = await fetch("/api/update", { method: "POST", body: fd });
if (!r.ok) {
let msg = "Ошибка загрузки";
try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {}
throw new Error(msg);
}
flash("Обновление запущено");
setTimeout(loadUpdateStatus, 2000);
} catch (e) { flash(e.message, true); }
});
$("#update-rollback")?.addEventListener("click", async () => {
if (!confirm("Откатить на предыдущую версию?")) return;
try {
await api("POST", "/api/update/rollback");
flash("Откат запущен");
setTimeout(loadUpdateStatus, 2000);
} catch (e) { flash(e.message, true); }
});
toggleStatic();
+281
View File
@@ -0,0 +1,281 @@
:root {
--bg: #14171d;
--bg-elev: #1c2029;
--bg-card: #232730;
--bg-input: #14171d;
--border: #2c313b;
--border-strong: #3a3f4a;
--text: #e8ebf0;
--muted: #8b93a1;
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #b91c1c;
--danger-hover: #dc2626;
--ok: #16a34a;
--warn: #f59e0b;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
/* ---------- Header ---------- */
header {
padding: 14px 24px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand .logo {
font-size: 1.6em;
color: var(--accent);
line-height: 1;
}
header h1 { margin: 0; font-size: 1.25em; }
header .meta { color: var(--muted); font-size: 0.82em; }
/* ---------- Tabs ---------- */
.tabs {
display: flex;
gap: 4px;
padding: 0 16px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
padding: 12px 16px;
cursor: pointer;
font: inherit;
white-space: nowrap;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
/* ---------- Layout ---------- */
main {
max-width: 960px;
margin: 20px auto;
padding: 0 16px 48px;
}
.panel { display: none; flex-direction: column; gap: 16px; }
.panel.active { display: flex; }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
}
.card h2 { margin: 0 0 14px; font-size: 1.05em; }
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.card-head h2 { margin: 0; }
.card-actions { margin-top: 16px; display: flex; gap: 10px; }
/* ---------- Forms ---------- */
.grid-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
label { display: flex; flex-direction: column; gap: 5px; font-size: 0.88em; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: 8px; color: var(--text); }
input[type="text"], input[type="number"], input[type="password"], select {
background: var(--bg-input);
border: 1px solid var(--border-strong);
color: var(--text);
padding: 8px 10px;
border-radius: 6px;
font: inherit;
width: 100%;
}
input:focus, select:focus { border-color: var(--accent); outline: none; }
input[type="color"] {
width: 48px; height: 34px; padding: 2px;
background: var(--bg-input); border: 1px solid var(--border-strong);
border-radius: 6px; cursor: pointer;
}
input[type="range"] { width: 100%; accent-color: var(--accent); }
input[type="file"] { font-size: 0.85em; color: var(--muted); }
/* ---------- Buttons ---------- */
button {
background: var(--bg-elev);
color: var(--text);
border: 1px solid var(--border-strong);
padding: 8px 14px;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-hover); }
button.del { background: var(--danger); border-color: var(--danger); color: #fff; }
button.del:hover { background: var(--danger-hover); }
button.ghost { background: transparent; }
button.icon-only { padding: 6px 9px; line-height: 1; }
/* ---------- Hints ---------- */
.hint { color: var(--muted); font-size: 0.85em; margin: 0 0 12px; }
.warn { color: var(--warn); font-size: 0.85em; margin: 0 0 12px; }
.empty { color: var(--muted); text-align: center; padding: 24px 0; }
/* ---------- Icons grid ---------- */
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
margin-top: 14px;
}
.icon-tile {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
text-align: center;
position: relative;
}
.icon-tile img { width: 100%; height: 64px; object-fit: contain; }
.icon-tile span { display: block; font-size: 0.72em; color: var(--muted); word-break: break-all; }
.icon-tile .del { position: absolute; top: 4px; right: 4px; }
/* ---------- Buttons list ---------- */
.buttons-list { list-style: none; margin: 0; padding: 0; }
.buttons-list li {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.buttons-list .handle { cursor: grab; font-size: 1.4em; color: var(--muted); user-select: none; }
.buttons-list .btn-icon { width: 38px; height: 38px; object-fit: contain; }
.buttons-list .btn-info { flex: 1; display: flex; flex-direction: column; }
.buttons-list .btn-info small { color: var(--muted); font-size: 0.8em; }
.buttons-list .swatch { width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border-strong); }
.buttons-list li.sortable-ghost { opacity: 0.4; }
.buttons-list li.sortable-chosen { border-color: var(--accent); }
/* ---------- Feedback preview ---------- */
.preview-row { display: flex; gap: 24px; margin-top: 16px; }
.preview-dot {
width: 72px; height: 72px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: #7b007b; color: #fff; font-weight: 600; font-size: 0.85em;
}
/* ---------- Network ---------- */
.net-status { font-size: 0.9em; }
.net-device {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
}
.net-device .name { font-weight: 600; }
.net-device .detail { color: var(--muted); font-size: 0.85em; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 0.75em; }
.badge.up { background: rgba(22,163,74,0.2); color: #4ade80; }
.badge.down { background: rgba(185,28,28,0.2); color: #f87171; }
.static-only { display: none; }
.grid-form.show-static .static-only { display: flex; }
/* ---------- Update ---------- */
.kv { display: flex; flex-direction: column; gap: 8px; }
.kv > div { display: flex; gap: 12px; }
.kv span { color: var(--muted); min-width: 80px; }
.kv code { word-break: break-all; }
.update-status {
margin-top: 14px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-input);
border: 1px solid var(--border);
font-size: 0.88em;
}
.update-status.ok { border-color: var(--ok); }
.update-status.fail { border-color: var(--danger); }
.update-status.busy { border-color: var(--warn); }
/* ---------- Dialog ---------- */
dialog {
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text);
padding: 22px;
min-width: 360px;
}
dialog::backdrop { background: rgba(0,0,0,0.6); }
dialog form { display: flex; flex-direction: column; gap: 10px; }
dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; }
/* ---------- Toast ---------- */
.toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--accent); color: #fff;
padding: 12px 18px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 9999;
}
.toast.error { background: var(--danger); }
/* ---------- Login ---------- */
.login-body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f1115 0%, #1f2330 100%);
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 30px 34px;
width: 330px;
box-shadow: 0 12px 36px rgba(0,0,0,0.45);
}
.login-card h1 { margin: 0 0 4px; font-size: 1.5em; }
.login-card .muted { margin: 0 0 22px; color: var(--muted); font-size: 0.9em; }
.login-card form { display: flex; flex-direction: column; gap: 16px; }
.login-card .error {
background: rgba(185,28,28,0.18);
border: 1px solid var(--danger);
color: #fca5a5;
padding: 9px 12px;
border-radius: 6px;
font-size: 0.9em;
}
.login-card button.primary { width: 100%; padding: 11px; font-size: 1em; }
+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ButtonTask — конфигуратор</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js" defer></script>
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
</head>
<body>
<header>
<div class="brand">
<span class="logo"></span>
<div>
<h1>ButtonTask</h1>
<span class="meta">Веб-конфигуратор · версия {{ version.version }}</span>
</div>
</div>
<a href="{{ url_for('auth.logout') }}" class="logout"><button type="button" class="ghost">Выйти</button></a>
</header>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="buttons">Кнопки</button>
<button class="tab" data-tab="appearance">Внешний вид</button>
<button class="tab" data-tab="network">Сеть</button>
<button class="tab" data-tab="update">Обновление</button>
<button class="tab" data-tab="security">Безопасность</button>
</nav>
<main>
<!-- ============ Кнопки ============ -->
<section class="panel active" id="tab-buttons">
<div class="card">
<div class="card-head">
<h2>Кнопки</h2>
<button id="add-btn" class="primary">+ Добавить</button>
</div>
<p class="hint">Перетаскивайте за «≡» для изменения порядка.</p>
<ul id="buttons-list" class="buttons-list">
{% for b in config.buttons %}
<li data-id="{{ b.id }}">
<span class="handle"></span>
<img class="btn-icon" src="{{ url_for('api.serve_icon', fname=b.iconPath) if b.iconPath else '' }}" alt="">
<div class="btn-info">
<strong>{{ b.label }}</strong>
<small>{{ b.action.type }}: {{ b.action.url }}</small>
</div>
<span class="swatch" style="background: {{ b.color or '#7b007b' }}"></span>
<button class="edit icon-only" data-id="{{ b.id }}" title="Редактировать"></button>
<button class="del icon-only" data-id="{{ b.id }}" title="Удалить"></button>
</li>
{% endfor %}
</ul>
{% if not config.buttons %}
<p class="empty">Кнопок пока нет. Нажмите «Добавить».</p>
{% endif %}
</div>
</section>
<!-- ============ Внешний вид ============ -->
<section class="panel" id="tab-appearance">
<div class="card">
<h2>Расположение</h2>
<div class="grid-form">
<label>Режим
<select id="layout-mode">
<option value="grid" {% if config.layout.mode == 'grid' %}selected{% endif %}>Сетка</option>
<option value="list" {% if config.layout.mode == 'list' %}selected{% endif %}>Список</option>
</select>
</label>
<label>Колонок
<input type="number" id="layout-columns" min="1" max="8" value="{{ config.layout.columns }}">
</label>
<label>Отступ
<input type="number" id="layout-spacing" min="0" max="40" value="{{ config.layout.spacing }}">
</label>
<label class="check">
<input type="checkbox" id="layout-show-labels" {% if config.layout.showLabels %}checked{% endif %}>
Показывать подписи
</label>
</div>
<div class="card-actions">
<button id="save-layout" class="primary">Сохранить расположение</button>
</div>
</div>
<div class="card">
<h2>Фон</h2>
<div class="grid-form">
<label>Тип
<select id="bg-type">
<option value="gradient" {% if config.background.type == 'gradient' %}selected{% endif %}>Градиент</option>
<option value="solid" {% if config.background.type == 'solid' %}selected{% endif %}>Цвет</option>
<option value="image" {% if config.background.type == 'image' %}selected{% endif %}>Картинка</option>
</select>
</label>
<label>Цвет 1 <input type="color" id="bg-c1" value="{{ config.background.color1 }}"></label>
<label>Цвет 2 <input type="color" id="bg-c2" value="{{ config.background.color2 }}"></label>
<label class="check">
<input type="checkbox" id="bg-animated" {% if config.background.animated %}checked{% endif %}>
Анимация
</label>
<label>Картинка
<select id="bg-image">
<option value="">— нет —</option>
{% for ic in icons %}
<option value="{{ ic }}" {% if config.background.imagePath == ic %}selected{% endif %}>{{ ic }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="card-actions">
<button id="save-bg" class="primary">Сохранить фон</button>
</div>
</div>
<div class="card">
<h2>Обводка отклика</h2>
<p class="hint">Цвет и ширина свечения вокруг кнопки при успехе и ошибке.</p>
<div class="grid-form">
<label>Цвет «ОК» <input type="color" id="fb-ok-color" value="{{ config.feedback.okColor }}"></label>
<label>Ширина «ОК»: <span id="fb-ok-width-val">{{ config.feedback.okWidth }}</span>
<input type="range" id="fb-ok-width" min="0" max="30" value="{{ config.feedback.okWidth }}">
</label>
<label>Цвет «Ошибка» <input type="color" id="fb-error-color" value="{{ config.feedback.errorColor }}"></label>
<label>Ширина «Ошибка»: <span id="fb-error-width-val">{{ config.feedback.errorWidth }}</span>
<input type="range" id="fb-error-width" min="0" max="30" value="{{ config.feedback.errorWidth }}">
</label>
<label>Радиус свечения: <span id="fb-glow-val">{{ config.feedback.glowRadius }}</span>
<input type="range" id="fb-glow" min="0" max="60" value="{{ config.feedback.glowRadius }}">
</label>
</div>
<div class="preview-row">
<div class="preview-dot" id="fb-preview-ok">OK</div>
<div class="preview-dot" id="fb-preview-error">ERR</div>
</div>
<div class="card-actions">
<button id="save-feedback" class="primary">Сохранить обводку</button>
</div>
</div>
<div class="card">
<h2>Иконки</h2>
<div class="row">
<input type="file" id="icon-file" accept=".png,.jpg,.jpeg,.bmp,.svg,.gif">
<button id="upload-icon">Загрузить</button>
</div>
<div id="icon-grid" class="icon-grid">
{% for ic in icons %}
<div class="icon-tile" data-name="{{ ic }}">
<img src="{{ url_for('api.serve_icon', fname=ic) }}" alt="{{ ic }}">
<span>{{ ic }}</span>
<button class="del icon-only" data-name="{{ ic }}"></button>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- ============ Сеть ============ -->
<section class="panel" id="tab-network">
<div class="card">
<div class="card-head">
<h2>Ethernet</h2>
<button id="net-refresh" class="ghost">↻ Обновить</button>
</div>
<div id="net-status" class="net-status">Загрузка состояния…</div>
</div>
<div class="card">
<h2>Настройка интерфейса</h2>
<p class="warn">Изменение настроек может разорвать текущее соединение и сменить IP-адрес устройства.</p>
<div class="grid-form">
<label>Интерфейс
<select id="net-iface"></select>
</label>
<label>Режим
<select id="net-mode">
<option value="dhcp">DHCP (автоматически)</option>
<option value="static">Статический IP</option>
</select>
</label>
<label class="static-only">IP-адрес <input type="text" id="net-address" placeholder="192.168.1.50"></label>
<label class="static-only">Маска (префикс) <input type="number" id="net-prefix" min="1" max="32" value="24"></label>
<label class="static-only">Шлюз <input type="text" id="net-gateway" placeholder="192.168.1.1"></label>
<label class="static-only">DNS <input type="text" id="net-dns" placeholder="8.8.8.8, 1.1.1.1"></label>
</div>
<div class="card-actions">
<button id="net-apply" class="primary">Применить</button>
</div>
</div>
</section>
<!-- ============ Обновление ============ -->
<section class="panel" id="tab-update">
<div class="card">
<h2>Текущая версия</h2>
<div id="update-current" class="kv">
<div><span>Версия</span><strong id="update-version">{{ version.version }}</strong></div>
<div><span>Путь</span><code id="update-path">{{ version.path }}</code></div>
</div>
</div>
<div class="card">
<h2>Установить обновление</h2>
<p class="hint">Загрузите пакет <code>.tar.gz</code> (бинарь ButtonTask + webconfig + manifest.json). После применения выполняется проверка работоспособности; при ошибке запуска произойдёт автоматический откат.</p>
<div class="row">
<input type="file" id="update-file" accept=".tar.gz,.tgz">
<button id="update-upload" class="primary">Загрузить и применить</button>
</div>
<div id="update-status" class="update-status">Статус: проверка…</div>
<div class="card-actions">
<button id="update-rollback" class="del">Откатить на предыдущую версию</button>
</div>
</div>
</section>
<!-- ============ Безопасность ============ -->
<section class="panel" id="tab-security">
<div class="card">
<h2>Пароль</h2>
<p class="hint">Используется и для входа в веб-конфигуратор, и для настроек в приложении.</p>
<div class="row">
<label>Новый пароль
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
</label>
<button id="save-password" class="primary">Сохранить пароль</button>
</div>
</div>
</section>
</main>
<!-- Edit dialog -->
<dialog id="btn-dialog">
<form method="dialog" id="btn-form">
<h3 id="btn-dialog-title">Кнопка</h3>
<input type="hidden" id="b-id">
<label>Подпись <input type="text" id="b-label" required></label>
<label>Иконка
<select id="b-icon">
<option value="">— нет —</option>
{% for ic in icons %}
<option value="{{ ic }}">{{ ic }}</option>
{% endfor %}
</select>
</label>
<label>Метод
<select id="b-type">
<option value="http_get">http_get</option>
<option value="http_post">http_post</option>
</select>
</label>
<label>URL <input type="text" id="b-url" required></label>
<label>Текст успеха <input type="text" id="b-success"></label>
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
<label>Цвет кнопки <input type="color" id="b-color" value="#7b007b"></label>
<menu>
<button value="cancel">Отмена</button>
<button id="b-save" value="ok" type="submit" class="primary">Сохранить</button>
</menu>
</form>
</dialog>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ButtonTask — вход</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="login-body">
<main class="login-card">
<h1>ButtonTask</h1>
<p class="muted">Веб-конфигуратор</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('auth.login') }}{% if next %}?next={{ next }}{% endif %}">
<label>
<span>Пароль</span>
<input type="password" name="password" autofocus required>
</label>
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<button type="submit" class="primary">Войти</button>
</form>
</main>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""OTA software update for ButtonTask.
The web side receives an uploaded package (tar.gz containing the ButtonTask
binary + webconfig/ + manifest.json), validates it, then hands the actual
swap/health-check/rollback to the privileged, detached helper `bt-update`.
Because the web service itself is part of the package being replaced, the
helper is launched detached (setsid) so it survives the service restart.
Status is reported via run/update-status.json.
"""
import hashlib
import json
import subprocess
import time
from pathlib import Path
from flask import Blueprint, abort, jsonify, request
from auth import login_required
from paths import (UPDATE_STATUS, current_link, install_root, run_dir,
scripts_dir)
updater_bp = Blueprint("updater", __name__)
STAGING_NAME = "staging"
def _read_manifest(directory):
mf = Path(directory) / "manifest.json"
if not mf.exists():
return {}
try:
with mf.open("r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
def current_version():
link = current_link()
target = link.resolve() if link.exists() else None
manifest = _read_manifest(target) if target else {}
return {
"version": manifest.get("version", "unknown"),
"path": str(target) if target else "",
}
def read_status():
p = run_dir() / UPDATE_STATUS
if not p.exists():
return {"state": "idle"}
try:
with p.open("r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {"state": "unknown"}
def _staging_dir():
p = install_root() / STAGING_NAME
p.mkdir(parents=True, exist_ok=True)
return p
@updater_bp.route("/api/update/status", methods=["GET"])
@login_required
def api_update_status():
return jsonify({"current": current_version(), "status": read_status()})
@updater_bp.route("/api/update", methods=["POST"])
@login_required
def api_update():
if "file" not in request.files:
abort(400, "package file required")
fobj = request.files["file"]
fname = Path(fobj.filename or "").name
if not (fname.endswith(".tar.gz") or fname.endswith(".tgz")):
abort(400, "package must be a .tar.gz")
staging = _staging_dir()
pkg_path = staging / f"upload-{int(time.time())}.tar.gz"
fobj.save(str(pkg_path))
# Optional integrity check against a client-provided sha256.
expected = (request.form.get("sha256") or "").strip().lower()
if expected:
h = hashlib.sha256()
with pkg_path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
if h.hexdigest() != expected:
pkg_path.unlink(missing_ok=True)
abort(400, "sha256 mismatch")
script = str(scripts_dir() / "bt-update")
# start_new_session detaches the child (setsid) so it survives the web
# service restart performed mid-update.
try:
subprocess.Popen(
["sudo", "-n", script, "stage", str(pkg_path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
except FileNotFoundError as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "state": "started"})
@updater_bp.route("/api/update/rollback", methods=["POST"])
@login_required
def api_update_rollback():
script = str(scripts_dir() / "bt-update")
try:
subprocess.Popen(
["sudo", "-n", script, "rollback"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
except FileNotFoundError as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "state": "rollback-started"})