This commit is contained in:
2026-08-24 16:40:48 +03:00
parent f7e99516bf
commit 32abb8bf0a
73 changed files with 12364 additions and 9292 deletions
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
{
"background": {
"animated": true,
"color1": "#000000",
"color2": "#7b007b",
"imagePath": "",
"type": "solid"
},
"buttons": [
{
"action": {
"body": "",
"headers": {},
"type": "http_get",
"url": "http://192.168.1.55",
"responseCheck": {
"enabled": false
}
},
"color": "#000000",
"feedback": {
"errorText": "Ошибка",
"fadeMs": 5000,
"pendingText": "...",
"successText": "Вызов отправлен"
},
"iconPath": "cleaner1Crop.png",
"id": "cleaning-call",
"label": "Вызов клининга",
"trigger": {
"holdMs": 800,
"mode": "hold"
}
}
],
"feedback": {
"errorColor": "#cc2200",
"errorWidth": 6,
"glowRadius": 20,
"holdColor": "#ffffff",
"okColor": "#00cc44",
"okWidth": 6,
"pendingColor": "#ffffff",
"pendingWidth": 6
},
"layout": {
"columns": 2,
"labelColor": "#ffffff",
"mode": "list",
"showLabels": true,
"spacing": 10
},
"settings": {
"darkMode": true,
"iconsDir": "/opt/buttontask/icons",
"kioskMode": false,
"password": "admin",
"webPort": 8080
},
"version": 2
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -10,7 +10,9 @@ Item {
// Per-button trigger config (from the model). Default: hold-to-activate.
readonly property string triggerModeResolved: (typeof triggerMode !== "undefined" && triggerMode) ? triggerMode : "hold"
readonly property int holdMsResolved: (typeof holdMs !== "undefined" && holdMs > 0) ? holdMs : 800
readonly property bool inputBlocked: status !== 0
readonly property bool latchOn: typeof latchResetEnabled !== "undefined" && latchResetEnabled
readonly property bool inputBlocked: status === 3 || status === 2
|| (status !== 0 && !latchOn)
// Resolved feedback colour/width for the current status (ok / error).
readonly property color okColor: settingsContainer.feedbackOkColor
-1
View File
@@ -99,7 +99,6 @@ Dialog {
Layout.fillWidth: true
Layout.preferredHeight: 40
placeholderText: "Новый пароль"
echoMode: TextInput.Password
onAccepted: changePwdDialog.accept()
}
Text {
+65 -4
View File
@@ -10,7 +10,7 @@ Dialog {
title: editId === "" ? "Новая кнопка" : "Редактирование"
anchors.centerIn: parent
width: Math.min(520, parent ? parent.width * 0.95 : 520)
height: Math.min(560, parent ? parent.height * 0.9 : 560)
height: Math.min(680, parent ? parent.height * 0.9 : 680)
standardButtons: Dialog.Ok | Dialog.Cancel
readonly property color panelBg: "#161628"
@@ -48,6 +48,10 @@ Dialog {
typeCombo.currentIndex = 0
holdSwitch.checked = true
holdSlider.value = 800
latchSwitch.checked = true
resetUrlF.text = ""
successMatchF.text = "OK"
fireAndForgetSwitch.checked = false
responseCheckSwitch.checked = false
responseCheckWasEnabled = false
open()
@@ -72,6 +76,10 @@ Dialog {
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
holdSwitch.checked = (o.triggerMode !== "click")
holdSlider.value = (o.holdMs && o.holdMs > 0) ? o.holdMs : 800
latchSwitch.checked = !!o.latchResetEnabled
resetUrlF.text = o.latchResetUrl || ""
successMatchF.text = o.latchSuccessMatch || "OK"
fireAndForgetSwitch.checked = !!o.latchFireAndForget
responseCheckSwitch.checked = !!o.responseCheckEnabled
responseCheckWasEnabled = responseCheckSwitch.checked
open()
@@ -116,6 +124,52 @@ Dialog {
Layout.fillWidth: true
placeholderText: "URL"
}
RowLayout {
Layout.fillWidth: true
Text { text: "Сброс повторным нажатием:"; color: editorDialog.panelFg }
Switch { id: latchSwitch; checked: true }
}
Text {
Layout.fillWidth: true
visible: latchSwitch.checked
text: "Первое нажатие — URL выше. Успех, если в ответе есть указанный текст или HTTP < 400. Зелёный статус держится до повторного нажатия."
color: "#aaaaaa"
font.pixelSize: 10
wrapMode: Text.WordWrap
}
TextField {
id: resetUrlF
Layout.fillWidth: true
visible: latchSwitch.checked
placeholderText: "URL сброса"
}
TextField {
id: successMatchF
Layout.fillWidth: true
visible: latchSwitch.checked
placeholderText: "Успех если в ответе (OK)"
}
RowLayout {
Layout.fillWidth: true
visible: latchSwitch.checked
Text {
text: "Мгновенный отклик:"
color: editorDialog.panelFg
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Switch { id: fireAndForgetSwitch }
}
Text {
Layout.fillWidth: true
visible: latchSwitch.checked && fireAndForgetSwitch.checked
text: "Статус обновляется сразу после нажатия; команда на сервер уходит параллельно."
color: "#aaaaaa"
font.pixelSize: 10
wrapMode: Text.WordWrap
}
TextField {
id: successF
Layout.fillWidth: true
@@ -177,6 +231,7 @@ Dialog {
RowLayout {
Layout.fillWidth: true
visible: !latchSwitch.checked
Text { text: "Расширенная проверка:"; color: editorDialog.panelFg }
Switch { id: responseCheckSwitch }
Text {
@@ -193,17 +248,23 @@ Dialog {
onAccepted: {
var mode = holdSwitch.checked ? "hold" : "click"
var hold = Math.round(holdSlider.value)
var latchOn = latchSwitch.checked
var resetUrl = resetUrlF.text.trim()
var successMatch = successMatchF.text.trim() || "OK"
var fireAndForget = latchOn && fireAndForgetSwitch.checked
if (editId === "") {
btnController.addButton(labelF.text, iconCombo.currentText,
typeCombo.currentText, urlF.text,
successF.text, errorF.text, pendingF.text,
colorF.text, mode, hold)
colorF.text, mode, hold,
latchOn, resetUrl, successMatch, fireAndForget)
} else {
btnController.updateButton(editId, labelF.text, iconCombo.currentText,
typeCombo.currentText, urlF.text,
successF.text, errorF.text, pendingF.text,
colorF.text, mode, hold)
if (responseCheckSwitch.checked !== responseCheckWasEnabled)
colorF.text, mode, hold,
latchOn, resetUrl, successMatch, fireAndForget)
if (!latchOn && responseCheckSwitch.checked !== responseCheckWasEnabled)
btnController.setResponseCheckEnabled(editId, responseCheckSwitch.checked)
}
}
+144 -13
View File
@@ -93,6 +93,22 @@ QString outcomeName(ResponseOutcome outcome)
return QString();
}
QJsonObject makeLatchReset(bool enabled, const QString &resetUrl, const QString &successMatch,
bool fireAndForget = false)
{
QJsonObject latchReset;
latchReset.insert(QStringLiteral("enabled"), enabled);
if (enabled) {
latchReset.insert(QStringLiteral("resetUrl"), resetUrl);
const QString match = successMatch.trimmed();
latchReset.insert(QStringLiteral("successMatch"),
match.isEmpty() ? QStringLiteral("OK") : match);
if (fireAndForget)
latchReset.insert(QStringLiteral("fireAndForget"), true);
}
return latchReset;
}
} // namespace
ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent)
@@ -141,19 +157,42 @@ void ButtonController::finishRequest(const QString &id, int status, bool success
const qint64 elapsedMs = qMax<qint64>(
0, QDateTime::currentMSecsSinceEpoch() - state.startedMs);
const QString method = httpMethodForAction(state.action);
const QString url = state.action.value(QStringLiteral("url")).toString();
const QString url = state.requestUrl.isEmpty()
? state.action.value(QStringLiteral("url")).toString()
: state.requestUrl;
logButtonResult(state.label, method, url, success, httpStatus, elapsedMs, message,
state.attempt, outcome, matchedRule);
if (state.fireAndForget) {
emit buttonInvoked(id, success, message);
cancelActiveRequest(id);
return;
}
m_model->setStatus(id, status, message);
emit buttonInvoked(id, success, message);
const int fadeMs = state.feedback.value(QStringLiteral("fadeMs")).toInt(5000);
QString idCopy = id;
QPointer<ButtonsModel> modelPtr = m_model;
QTimer::singleShot(fadeMs, this, [modelPtr, idCopy]() {
if (state.latchResetEnabled && success && !state.isResetRequest) {
cancelActiveRequest(id);
return;
}
if (state.latchResetEnabled && success && state.isResetRequest) {
m_model->setStatus(id, 0, QString());
cancelActiveRequest(id);
return;
}
const int revertStatus = state.revertStatusOnError;
const QString revertText = (revertStatus == 1) ? state.latchedSuccessText : QString();
QTimer::singleShot(fadeMs, this, [modelPtr, idCopy, revertStatus, revertText]() {
if (modelPtr)
modelPtr->setStatus(idCopy, 0, QString());
modelPtr->setStatus(idCopy, revertStatus, revertText);
});
cancelActiveRequest(id);
@@ -193,7 +232,7 @@ void ButtonController::schedulePoll(const QString &id, const QString &message)
btn.insert(QStringLiteral("label"), activeIt->label);
btn.insert(QStringLiteral("action"), activeIt->action);
btn.insert(QStringLiteral("feedback"), activeIt->feedback);
startRequest(id, btn, true);
startRequest(id, btn, true, activeIt->isResetRequest);
});
}
@@ -202,22 +241,31 @@ void ButtonController::schedulePoll(const QString &id, const QString &message)
Q_UNUSED(generation);
}
void ButtonController::startRequest(const QString &id, const QJsonObject &btn, bool isPoll)
void ButtonController::startRequest(const QString &id, const QJsonObject &btn, bool isPoll, bool isReset)
{
const QJsonObject action = btn.value(QStringLiteral("action")).toObject();
const QJsonObject feedback = btn.value(QStringLiteral("feedback")).toObject();
const QJsonObject latchReset = btn.value(QStringLiteral("latchReset")).toObject();
const bool latchEnabled = latchReset.value(QStringLiteral("enabled")).toBool(false);
const bool fireAndForget = latchEnabled
&& latchReset.value(QStringLiteral("fireAndForget")).toBool(false);
const QString type = action.value(QStringLiteral("type")).toString(QStringLiteral("http_get"));
const QString url = action.value(QStringLiteral("url")).toString();
const QString url = isReset
? latchReset.value(QStringLiteral("resetUrl")).toString()
: action.value(QStringLiteral("url")).toString();
const int timeoutMs = action.value(QStringLiteral("timeoutMs")).toInt(7000);
const QString pendingText = feedback.value(QStringLiteral("pendingText")).toString(QStringLiteral("..."));
const QString errorText = feedback.value(QStringLiteral("errorText")).toString(QStringLiteral("Ошибка"));
const QString successText = feedback.value(QStringLiteral("successText")).toString();
const QString label = btn.value(QStringLiteral("label")).toString();
if (url.isEmpty()) {
qWarning() << "startRequest: empty url for" << id;
m_model->setStatus(id, 2, errorText);
logButtonResult(label, httpMethodForAction(action), url, false, 0, 0, errorText);
emit buttonInvoked(id, false, errorText);
if (!fireAndForget) {
m_model->setStatus(id, 2, errorText);
emit buttonInvoked(id, false, errorText);
}
cancelActiveRequest(id);
return;
}
@@ -228,12 +276,20 @@ void ButtonController::startRequest(const QString &id, const QJsonObject &btn, b
state.label = label;
state.action = action;
state.feedback = feedback;
state.latchReset = latchReset;
state.requestUrl = url;
state.latchedSuccessText = successText;
state.startedMs = QDateTime::currentMSecsSinceEpoch();
state.attempt = 1;
state.generation = 1;
state.statusMessage = pendingText;
state.latchResetEnabled = latchEnabled;
state.fireAndForget = fireAndForget;
state.isResetRequest = isReset;
state.revertStatusOnError = isReset ? 1 : 0;
m_active.insert(id, state);
m_model->setStatus(id, 3, pendingText);
if (!fireAndForget)
m_model->setStatus(id, 3, pendingText);
} else {
auto it = m_active.find(id);
if (it == m_active.end())
@@ -301,6 +357,20 @@ void ButtonController::handleReplyFinished(QNetworkReply *reply, const QString &
return;
}
if (state.latchResetEnabled) {
const QString successMatch = state.latchReset.value(QStringLiteral("successMatch"))
.toString(QStringLiteral("OK"));
ResponseEvaluation evaluation = evaluateBodyMatch(body, httpStatus, successMatch, feedback);
const QString outcome = outcomeName(evaluation.outcome);
if (evaluation.outcome == ResponseOutcome::Ok) {
finishRequest(id, state.isResetRequest ? 0 : 1, true, evaluation.message,
httpStatus, outcome, evaluation.matchedRule);
} else {
finishRequest(id, 2, false, evaluation.message, httpStatus, outcome, evaluation.matchedRule);
}
return;
}
ResponseEvaluation evaluation = evaluateResponse(body, httpStatus, responseCheck, feedback);
if (evaluation.outcome == ResponseOutcome::UseHttpFallback) {
bool ok = false;
@@ -358,13 +428,52 @@ void ButtonController::invokeButton(const QString &id)
return;
}
if (m_model->buttonStatus(id) != 0) {
const int status = m_model->buttonStatus(id);
const QJsonObject latchReset = btn.value(QStringLiteral("latchReset")).toObject();
const bool latchEnabled = latchReset.value(QStringLiteral("enabled")).toBool(false);
const bool fireAndForget = latchEnabled
&& latchReset.value(QStringLiteral("fireAndForget")).toBool(false);
const QJsonObject feedback = btn.value(QStringLiteral("feedback")).toObject();
const QString successText = feedback.value(QStringLiteral("successText"))
.toString(btn.value(QStringLiteral("label")).toString());
if (fireAndForget) {
cancelActiveRequest(id);
if (status == 1) {
m_model->setStatus(id, 0, QString());
startRequest(id, btn, false, true);
return;
}
if (status == 0 || status == 2 || status == 3) {
m_model->setStatus(id, 1, successText);
startRequest(id, btn, false, false);
return;
}
}
if (status == 3) {
qDebug() << "invokeButton: busy" << id;
return;
}
if (status == 2) {
qDebug() << "invokeButton: error state" << id;
return;
}
if (status == 1 && latchEnabled) {
cancelActiveRequest(id);
startRequest(id, btn, false, true);
return;
}
if (status != 0) {
qDebug() << "invokeButton: busy" << id;
return;
}
cancelActiveRequest(id);
startRequest(id, btn, false);
startRequest(id, btn, false, false);
}
bool ButtonController::checkPassword(const QString &pwd) const
@@ -395,13 +504,22 @@ void ButtonController::addButton(const QString &label,
const QString &pendingText,
const QString &color,
const QString &triggerMode,
int holdMs)
int holdMs,
bool latchEnabled,
const QString &resetUrl,
const QString &successMatch,
bool fireAndForget)
{
QJsonObject action;
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
action.insert("url", actionUrl);
action.insert("headers", QJsonObject());
action.insert("body", "");
if (latchEnabled) {
QJsonObject responseCheck;
responseCheck.insert(QStringLiteral("enabled"), false);
action.insert(QStringLiteral("responseCheck"), responseCheck);
}
QJsonObject feedback;
feedback.insert("successText", successText);
@@ -420,6 +538,8 @@ void ButtonController::addButton(const QString &label,
btn.insert("action", action);
btn.insert("feedback", feedback);
btn.insert("trigger", trigger);
btn.insert(QStringLiteral("latchReset"),
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
m_config->addButton(btn);
}
@@ -433,7 +553,11 @@ void ButtonController::updateButton(const QString &id,
const QString &pendingText,
const QString &color,
const QString &triggerMode,
int holdMs)
int holdMs,
bool latchEnabled,
const QString &resetUrl,
const QString &successMatch,
bool fireAndForget)
{
QJsonArray arr = m_config->buttons();
for (const auto &v : arr) {
@@ -442,6 +566,11 @@ void ButtonController::updateButton(const QString &id,
QJsonObject action = b.value("action").toObject();
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
action.insert("url", actionUrl);
if (latchEnabled) {
QJsonObject responseCheck = action.value(QStringLiteral("responseCheck")).toObject();
responseCheck.insert(QStringLiteral("enabled"), false);
action.insert(QStringLiteral("responseCheck"), responseCheck);
}
QJsonObject feedback = b.value("feedback").toObject();
feedback.insert("successText", successText);
feedback.insert("errorText", errorText);
@@ -456,6 +585,8 @@ void ButtonController::updateButton(const QString &id,
b.insert("action", action);
b.insert("feedback", feedback);
b.insert("trigger", trigger);
b.insert(QStringLiteral("latchReset"),
makeLatchReset(latchEnabled, resetUrl, successMatch, fireAndForget));
m_config->updateButton(id, b);
return;
}
+18 -3
View File
@@ -33,7 +33,11 @@ public:
const QString &pendingText = QStringLiteral("..."),
const QString &color = QString(),
const QString &triggerMode = QString(),
int holdMs = 0);
int holdMs = 0,
bool latchEnabled = true,
const QString &resetUrl = QString(),
const QString &successMatch = QStringLiteral("OK"),
bool fireAndForget = false);
Q_INVOKABLE void updateButton(const QString &id,
const QString &label,
const QString &iconPath,
@@ -44,7 +48,11 @@ public:
const QString &pendingText = QStringLiteral("..."),
const QString &color = QString(),
const QString &triggerMode = QString(),
int holdMs = 0);
int holdMs = 0,
bool latchEnabled = false,
const QString &resetUrl = QString(),
const QString &successMatch = QStringLiteral("OK"),
bool fireAndForget = false);
Q_INVOKABLE void setResponseCheckEnabled(const QString &id, bool enabled);
Q_INVOKABLE void removeButton(const QString &id);
Q_INVOKABLE void moveButton(int from, int to);
@@ -80,15 +88,22 @@ private:
QString label;
QJsonObject action;
QJsonObject feedback;
QJsonObject latchReset;
QString requestUrl;
QString statusMessage;
QString latchedSuccessText;
qint64 startedMs = 0;
int attempt = 0;
int generation = 0;
int revertStatusOnError = 0;
bool latchResetEnabled = false;
bool fireAndForget = false;
bool isResetRequest = false;
QTimer *pollTimer = nullptr;
};
void cancelActiveRequest(const QString &id);
void startRequest(const QString &id, const QJsonObject &btn, bool isPoll);
void startRequest(const QString &id, const QJsonObject &btn, bool isPoll, bool isReset = false);
void schedulePoll(const QString &id, const QString &message);
void finishRequest(const QString &id, int status, bool success, const QString &message,
int httpStatus, const QString &outcome,
+13
View File
@@ -44,6 +44,7 @@ QVariant ButtonsModel::data(const QModelIndex &index, int role) const
QJsonObject action = b.value("action").toObject();
QJsonObject feedback = b.value("feedback").toObject();
QJsonObject trigger = b.value("trigger").toObject();
QJsonObject latchReset = b.value("latchReset").toObject();
RuntimeState rt = m_runtime.value(id);
switch (role) {
@@ -68,6 +69,14 @@ QVariant ButtonsModel::data(const QModelIndex &index, int role) const
case ResponseCheckEnabledRole:
return action.value(QStringLiteral("responseCheck")).toObject()
.value(QStringLiteral("enabled")).toBool(false);
case LatchResetEnabledRole:
return latchReset.value(QStringLiteral("enabled")).toBool(false);
case LatchResetUrlRole:
return latchReset.value(QStringLiteral("resetUrl")).toString();
case LatchSuccessMatchRole:
return latchReset.value(QStringLiteral("successMatch")).toString(QStringLiteral("OK"));
case LatchFireAndForgetRole:
return latchReset.value(QStringLiteral("fireAndForget")).toBool(false);
case FadeMsRole: return feedback.value("fadeMs").toInt(5000);
case StatusRole: return rt.status;
case StatusTextRole: return rt.text;
@@ -92,6 +101,10 @@ QHash<int, QByteArray> ButtonsModel::roleNames() const
{ErrorTextRole, "errorText"},
{PendingTextRole, "pendingText"},
{ResponseCheckEnabledRole, "responseCheckEnabled"},
{LatchResetEnabledRole, "latchResetEnabled"},
{LatchResetUrlRole, "latchResetUrl"},
{LatchSuccessMatchRole, "latchSuccessMatch"},
{LatchFireAndForgetRole, "latchFireAndForget"},
{FadeMsRole, "fadeMs"},
{StatusRole, "status"},
{StatusTextRole, "statusText"},
+4
View File
@@ -25,6 +25,10 @@ public:
ErrorTextRole,
PendingTextRole,
ResponseCheckEnabledRole,
LatchResetEnabledRole,
LatchResetUrlRole,
LatchSuccessMatchRole,
LatchFireAndForgetRole,
FadeMsRole,
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
StatusTextRole,
+1
View File
@@ -155,6 +155,7 @@ bool ConfigManager::ensureDefaults()
st.insert("iconsDir", defDir);
}
if (!st.contains("webPort")) st.insert("webPort", 8080);
if (!st.contains("brightness")) st.insert("brightness", 60);
st.insert("darkMode", true);
m_root.insert("settings", st);
+19
View File
@@ -149,3 +149,22 @@ ResponseEvaluation evaluateResponse(const QByteArray &body,
responseCheck.value(QStringLiteral("defaultResult")).toString(QStringLiteral("error")));
return makeEvaluation(fallback, feedbackDefaults);
}
ResponseEvaluation evaluateBodyMatch(const QByteArray &body,
int httpStatus,
const QString &successMatch,
const QJsonObject &feedbackDefaults)
{
const QString bodyText = QString::fromUtf8(body);
const QString needle = successMatch.trimmed();
bool ok = false;
if (httpStatus > 0 && httpStatus < 400)
ok = true;
if (!needle.isEmpty() && bodyText.contains(needle, Qt::CaseInsensitive))
ok = true;
if (ok)
return makeEvaluation(ResponseOutcome::Ok, feedbackDefaults, QString(), QStringLiteral("bodyMatch"));
return makeEvaluation(ResponseOutcome::Error, feedbackDefaults);
}
+5
View File
@@ -22,4 +22,9 @@ ResponseEvaluation evaluateResponse(const QByteArray &body,
const QJsonObject &responseCheck,
const QJsonObject &feedbackDefaults);
ResponseEvaluation evaluateBodyMatch(const QByteArray &body,
int httpStatus,
const QString &successMatch,
const QJsonObject &feedbackDefaults);
#endif // RESPONSECHECK_H
+20 -6
View File
@@ -48,6 +48,8 @@
- `final_ip` — static IP, который останется в `network.json` после установки
(по умолчанию `192.168.1.60`);
- `after_install``halt` (рекомендуется) или `reboot`;
- `reset_config``false` (сохранить config на устройстве) или `true`
(перезаписать `config.json` и `.master` из бандла — для переустановки);
- при необходимости — диапазон `pool_start`/`pool_end` (адрес `final_ip`
из пула исключается автоматически).
@@ -63,7 +65,8 @@
2. Включить GUI.
3. Выбрать сетевой интерфейс.
4. Выбрать `.tgz` бандл.
5. Нажать `Start`.
5. При переустановке на уже настроенное устройство — включить «Полный сброс конфига».
6. Нажать `Start`.
6. Вставить устройство в изолированную сеть.
7. Дождаться статуса `ГОТОВО`.
8. Вынуть устройство и повторить для следующего.
@@ -99,6 +102,11 @@ discover -> acked -> ждём ssh -> заливаю -> устанавливаю
Если где-то `ОШИБКА`, детали видны в таблице; полный вывод install пишется в
`logs/<mac>.log`.
Частая ошибка на шаге `сеть`: `Connection timed out` — SSH оборвался до записи
`network.json`. Устройство после этого остаётся на DHCP; в таблице будет
`ОШИБКА`, не `ГОТОВО`. С новых версий deploy-скрипта запись повторяется до 3
раз и проверяется чтением файла обратно.
Когда устройство показывает `ГОТОВО` — оно выключено. Можно отключать кабель
и включать уже на рабочей сети: поднимется на `final_ip` с сервисами
ButtonTask.
@@ -113,12 +121,18 @@ IP-адреса привязываются к MAC-адресу и запомин
Если нужно "сбросить" привязки/ключи — удалите `leases.json` и/или
`host_keys.json` (файлы создаются заново автоматически).
## device-install.sh менять не нужно
## device-install.sh
Сам скрипт установки на устройстве (`device-install.sh`) не менялся. На время
install `network.json` из бандла временно прячется, чтобы SSH не оборвался
из‑за смены IP; целевой static `final_ip` записывается уже после `INSTALL-OK`.
Изменился только способ доставки (SSH вместо `adb push`/`adb shell`).
На время install `network.json` из бандла временно прячется, а старый
`/opt/buttontask/config/network.json` на устройстве удаляется — иначе при
переустановке install мог бы применить static во время SSH-сессии и оборвать
связь. Целевой static `final_ip` записывается уже после `INSTALL-OK` и
проверяется чтением файла.
`config.json` и `.master` **по умолчанию сохраняются**, если уже есть на
устройстве (upgrade-safe). Для переустановки «как с завода» включите
`"reset_config": true` в `config.json`, галочку в GUI или переменную
`BUTTONTASK_RESET_CONFIG=1` при запуске install.
## Сборка в exe
+31 -13
View File
@@ -6,15 +6,40 @@ $ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $here
function Test-PyInstaller($pyExe, [string[]]$pyArgs) {
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $pyExe @pyArgs -m PyInstaller --version 2>&1 | Out-Null
return $LASTEXITCODE -eq 0
} finally {
$ErrorActionPreference = $prev
}
}
$pyCmd = $null
if (Get-Command python -ErrorAction SilentlyContinue) {
$pyCmd = @('python')
} elseif (Get-Command py -ErrorAction SilentlyContinue) {
$pyCmd = @('py', '-3')
$candidates = @()
if (Get-Command python -ErrorAction SilentlyContinue) { $candidates += ,@('python') }
if (Get-Command py -ErrorAction SilentlyContinue) { $candidates += ,@('py', '-3') }
foreach ($candidate in $candidates) {
$pyExe = $candidate[0]
$pyArgs = @()
if ($candidate.Length -gt 1) {
$pyArgs = $candidate[1..($candidate.Length - 1)]
}
if (Test-PyInstaller $pyExe $pyArgs) {
$pyCmd = $candidate
break
}
}
if (-not $pyCmd) {
Write-Host "[error] Python 3 not found." -ForegroundColor Red
Write-Host "[error] Python 3 with PyInstaller not found." -ForegroundColor Red
Write-Host " Install: py -3 -m pip install pyinstaller" -ForegroundColor Yellow
if ($candidates.Count -gt 0) {
Write-Host " PyInstaller is missing in the Python that runs first (often 'python' != 'py -3')." -ForegroundColor Yellow
}
exit 1
}
@@ -23,14 +48,7 @@ $pyArgs = @()
if ($pyCmd.Length -gt 1) {
$pyArgs = $pyCmd[1..($pyCmd.Length - 1)]
}
$checkArgs = $pyArgs + @('-m', 'PyInstaller', '--version')
try {
& $pyExe @checkArgs *> $null
} catch {
Write-Host "[error] PyInstaller not found. Install with: py -3 -m pip install pyinstaller" -ForegroundColor Red
exit 1
}
Write-Host "[build] using: $pyExe $($pyArgs -join ' ')" -ForegroundColor Cyan
$releaseDir = Join-Path $here 'dist\sshDeploy-gui'
$pyiDist = Join-Path $here 'dist-pyinstaller'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+376 -125
View File
@@ -1,185 +1,436 @@
('T:\\sources\\button\\sshDeploy\\build\\sshDeploy-gui\\PYZ-00.pyz',
[('__future__', 'C:\\Python313\\Lib\\__future__.py', 'PYMODULE'),
('_colorize', 'C:\\Python313\\Lib\\_colorize.py', 'PYMODULE'),
('_compat_pickle', 'C:\\Python313\\Lib\\_compat_pickle.py', 'PYMODULE'),
('_compression', 'C:\\Python313\\Lib\\_compression.py', 'PYMODULE'),
('_opcode_metadata', 'C:\\Python313\\Lib\\_opcode_metadata.py', 'PYMODULE'),
('_py_abc', 'C:\\Python313\\Lib\\_py_abc.py', 'PYMODULE'),
('_pydatetime', 'C:\\Python313\\Lib\\_pydatetime.py', 'PYMODULE'),
('_pydecimal', 'C:\\Python313\\Lib\\_pydecimal.py', 'PYMODULE'),
('_strptime', 'C:\\Python313\\Lib\\_strptime.py', 'PYMODULE'),
('_threading_local', 'C:\\Python313\\Lib\\_threading_local.py', 'PYMODULE'),
('argparse', 'C:\\Python313\\Lib\\argparse.py', 'PYMODULE'),
('ast', 'C:\\Python313\\Lib\\ast.py', 'PYMODULE'),
('base64', 'C:\\Python313\\Lib\\base64.py', 'PYMODULE'),
('bisect', 'C:\\Python313\\Lib\\bisect.py', 'PYMODULE'),
('bz2', 'C:\\Python313\\Lib\\bz2.py', 'PYMODULE'),
('calendar', 'C:\\Python313\\Lib\\calendar.py', 'PYMODULE'),
('contextlib', 'C:\\Python313\\Lib\\contextlib.py', 'PYMODULE'),
('contextvars', 'C:\\Python313\\Lib\\contextvars.py', 'PYMODULE'),
('copy', 'C:\\Python313\\Lib\\copy.py', 'PYMODULE'),
('csv', 'C:\\Python313\\Lib\\csv.py', 'PYMODULE'),
('ctypes', 'C:\\Python313\\Lib\\ctypes\\__init__.py', 'PYMODULE'),
('ctypes._endian', 'C:\\Python313\\Lib\\ctypes\\_endian.py', 'PYMODULE'),
('dataclasses', 'C:\\Python313\\Lib\\dataclasses.py', 'PYMODULE'),
('datetime', 'C:\\Python313\\Lib\\datetime.py', 'PYMODULE'),
('decimal', 'C:\\Python313\\Lib\\decimal.py', 'PYMODULE'),
('dis', 'C:\\Python313\\Lib\\dis.py', 'PYMODULE'),
('email', 'C:\\Python313\\Lib\\email\\__init__.py', 'PYMODULE'),
('G:\\sourse\\ButtonTask\\sshDeploy\\build\\sshDeploy-gui\\PYZ-00.pyz',
[('__future__',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\__future__.py',
'PYMODULE'),
('_aix_support',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_aix_support.py',
'PYMODULE'),
('_ast_unparse',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_ast_unparse.py',
'PYMODULE'),
('_colorize',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_colorize.py',
'PYMODULE'),
('_compat_pickle',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_compat_pickle.py',
'PYMODULE'),
('_opcode_metadata',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_opcode_metadata.py',
'PYMODULE'),
('_py_abc',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_py_abc.py',
'PYMODULE'),
('_py_warnings',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_py_warnings.py',
'PYMODULE'),
('_pydatetime',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_pydatetime.py',
'PYMODULE'),
('_pydecimal',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_pydecimal.py',
'PYMODULE'),
('_strptime',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_strptime.py',
'PYMODULE'),
('_threading_local',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\_threading_local.py',
'PYMODULE'),
('annotationlib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\annotationlib.py',
'PYMODULE'),
('argparse',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\argparse.py',
'PYMODULE'),
('ast',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ast.py',
'PYMODULE'),
('base64',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\base64.py',
'PYMODULE'),
('bisect',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\bisect.py',
'PYMODULE'),
('bz2',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\bz2.py',
'PYMODULE'),
('calendar',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\calendar.py',
'PYMODULE'),
('codeop',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\codeop.py',
'PYMODULE'),
('compression',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\compression\\__init__.py',
'PYMODULE'),
('compression._common',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\compression\\_common\\__init__.py',
'PYMODULE'),
('compression._common._streams',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\compression\\_common\\_streams.py',
'PYMODULE'),
('compression.zstd',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\compression\\zstd\\__init__.py',
'PYMODULE'),
('compression.zstd._zstdfile',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\compression\\zstd\\_zstdfile.py',
'PYMODULE'),
('contextlib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\contextlib.py',
'PYMODULE'),
('contextvars',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\contextvars.py',
'PYMODULE'),
('copy',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\copy.py',
'PYMODULE'),
('csv',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\csv.py',
'PYMODULE'),
('ctypes',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ctypes\\__init__.py',
'PYMODULE'),
('ctypes._endian',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ctypes\\_endian.py',
'PYMODULE'),
('ctypes._layout',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ctypes\\_layout.py',
'PYMODULE'),
('dataclasses',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\dataclasses.py',
'PYMODULE'),
('datetime',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\datetime.py',
'PYMODULE'),
('decimal',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\decimal.py',
'PYMODULE'),
('difflib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\difflib.py',
'PYMODULE'),
('dis',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\dis.py',
'PYMODULE'),
('email',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\__init__.py',
'PYMODULE'),
('email._encoded_words',
'C:\\Python313\\Lib\\email\\_encoded_words.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\_encoded_words.py',
'PYMODULE'),
('email._header_value_parser',
'C:\\Python313\\Lib\\email\\_header_value_parser.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\_header_value_parser.py',
'PYMODULE'),
('email._parseaddr',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\_parseaddr.py',
'PYMODULE'),
('email._parseaddr', 'C:\\Python313\\Lib\\email\\_parseaddr.py', 'PYMODULE'),
('email._policybase',
'C:\\Python313\\Lib\\email\\_policybase.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\_policybase.py',
'PYMODULE'),
('email.base64mime',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\base64mime.py',
'PYMODULE'),
('email.charset',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\charset.py',
'PYMODULE'),
('email.base64mime', 'C:\\Python313\\Lib\\email\\base64mime.py', 'PYMODULE'),
('email.charset', 'C:\\Python313\\Lib\\email\\charset.py', 'PYMODULE'),
('email.contentmanager',
'C:\\Python313\\Lib\\email\\contentmanager.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\contentmanager.py',
'PYMODULE'),
('email.encoders',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\encoders.py',
'PYMODULE'),
('email.errors',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\errors.py',
'PYMODULE'),
('email.feedparser',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\feedparser.py',
'PYMODULE'),
('email.generator',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\generator.py',
'PYMODULE'),
('email.header',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\header.py',
'PYMODULE'),
('email.encoders', 'C:\\Python313\\Lib\\email\\encoders.py', 'PYMODULE'),
('email.errors', 'C:\\Python313\\Lib\\email\\errors.py', 'PYMODULE'),
('email.feedparser', 'C:\\Python313\\Lib\\email\\feedparser.py', 'PYMODULE'),
('email.generator', 'C:\\Python313\\Lib\\email\\generator.py', 'PYMODULE'),
('email.header', 'C:\\Python313\\Lib\\email\\header.py', 'PYMODULE'),
('email.headerregistry',
'C:\\Python313\\Lib\\email\\headerregistry.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\headerregistry.py',
'PYMODULE'),
('email.iterators',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\iterators.py',
'PYMODULE'),
('email.message',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\message.py',
'PYMODULE'),
('email.parser',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\parser.py',
'PYMODULE'),
('email.policy',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\policy.py',
'PYMODULE'),
('email.quoprimime',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\quoprimime.py',
'PYMODULE'),
('email.utils',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\email\\utils.py',
'PYMODULE'),
('fnmatch',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\fnmatch.py',
'PYMODULE'),
('fractions',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\fractions.py',
'PYMODULE'),
('ftplib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ftplib.py',
'PYMODULE'),
('getopt',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\getopt.py',
'PYMODULE'),
('gettext',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\gettext.py',
'PYMODULE'),
('glob',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\glob.py',
'PYMODULE'),
('gzip',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\gzip.py',
'PYMODULE'),
('hashlib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\hashlib.py',
'PYMODULE'),
('http',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\http\\__init__.py',
'PYMODULE'),
('http.client',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\http\\client.py',
'PYMODULE'),
('http.cookiejar',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\http\\cookiejar.py',
'PYMODULE'),
('importlib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\__init__.py',
'PYMODULE'),
('importlib._abc',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\_abc.py',
'PYMODULE'),
('email.iterators', 'C:\\Python313\\Lib\\email\\iterators.py', 'PYMODULE'),
('email.message', 'C:\\Python313\\Lib\\email\\message.py', 'PYMODULE'),
('email.parser', 'C:\\Python313\\Lib\\email\\parser.py', 'PYMODULE'),
('email.policy', 'C:\\Python313\\Lib\\email\\policy.py', 'PYMODULE'),
('email.quoprimime', 'C:\\Python313\\Lib\\email\\quoprimime.py', 'PYMODULE'),
('email.utils', 'C:\\Python313\\Lib\\email\\utils.py', 'PYMODULE'),
('fnmatch', 'C:\\Python313\\Lib\\fnmatch.py', 'PYMODULE'),
('fractions', 'C:\\Python313\\Lib\\fractions.py', 'PYMODULE'),
('getopt', 'C:\\Python313\\Lib\\getopt.py', 'PYMODULE'),
('gettext', 'C:\\Python313\\Lib\\gettext.py', 'PYMODULE'),
('glob', 'C:\\Python313\\Lib\\glob.py', 'PYMODULE'),
('gzip', 'C:\\Python313\\Lib\\gzip.py', 'PYMODULE'),
('hashlib', 'C:\\Python313\\Lib\\hashlib.py', 'PYMODULE'),
('importlib', 'C:\\Python313\\Lib\\importlib\\__init__.py', 'PYMODULE'),
('importlib._abc', 'C:\\Python313\\Lib\\importlib\\_abc.py', 'PYMODULE'),
('importlib._bootstrap',
'C:\\Python313\\Lib\\importlib\\_bootstrap.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\_bootstrap.py',
'PYMODULE'),
('importlib._bootstrap_external',
'C:\\Python313\\Lib\\importlib\\_bootstrap_external.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\_bootstrap_external.py',
'PYMODULE'),
('importlib.abc',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\abc.py',
'PYMODULE'),
('importlib.abc', 'C:\\Python313\\Lib\\importlib\\abc.py', 'PYMODULE'),
('importlib.machinery',
'C:\\Python313\\Lib\\importlib\\machinery.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\machinery.py',
'PYMODULE'),
('importlib.metadata',
'C:\\Python313\\Lib\\importlib\\metadata\\__init__.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\__init__.py',
'PYMODULE'),
('importlib.metadata._adapters',
'C:\\Python313\\Lib\\importlib\\metadata\\_adapters.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_adapters.py',
'PYMODULE'),
('importlib.metadata._collections',
'C:\\Python313\\Lib\\importlib\\metadata\\_collections.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_collections.py',
'PYMODULE'),
('importlib.metadata._functools',
'C:\\Python313\\Lib\\importlib\\metadata\\_functools.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_functools.py',
'PYMODULE'),
('importlib.metadata._itertools',
'C:\\Python313\\Lib\\importlib\\metadata\\_itertools.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_itertools.py',
'PYMODULE'),
('importlib.metadata._meta',
'C:\\Python313\\Lib\\importlib\\metadata\\_meta.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_meta.py',
'PYMODULE'),
('importlib.metadata._text',
'C:\\Python313\\Lib\\importlib\\metadata\\_text.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\metadata\\_text.py',
'PYMODULE'),
('importlib.readers',
'C:\\Python313\\Lib\\importlib\\readers.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\readers.py',
'PYMODULE'),
('importlib.resources',
'C:\\Python313\\Lib\\importlib\\resources\\__init__.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\__init__.py',
'PYMODULE'),
('importlib.resources._adapters',
'C:\\Python313\\Lib\\importlib\\resources\\_adapters.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\_adapters.py',
'PYMODULE'),
('importlib.resources._common',
'C:\\Python313\\Lib\\importlib\\resources\\_common.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\_common.py',
'PYMODULE'),
('importlib.resources._functional',
'C:\\Python313\\Lib\\importlib\\resources\\_functional.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\_functional.py',
'PYMODULE'),
('importlib.resources._itertools',
'C:\\Python313\\Lib\\importlib\\resources\\_itertools.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\_itertools.py',
'PYMODULE'),
('importlib.resources.abc',
'C:\\Python313\\Lib\\importlib\\resources\\abc.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\abc.py',
'PYMODULE'),
('importlib.resources.readers',
'C:\\Python313\\Lib\\importlib\\resources\\readers.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\resources\\readers.py',
'PYMODULE'),
('importlib.util',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\importlib\\util.py',
'PYMODULE'),
('inspect',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\inspect.py',
'PYMODULE'),
('ipaddress',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ipaddress.py',
'PYMODULE'),
('json',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\json\\__init__.py',
'PYMODULE'),
('json.decoder',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\json\\decoder.py',
'PYMODULE'),
('json.encoder',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\json\\encoder.py',
'PYMODULE'),
('json.scanner',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\json\\scanner.py',
'PYMODULE'),
('logging',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\logging\\__init__.py',
'PYMODULE'),
('lzma',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\lzma.py',
'PYMODULE'),
('mass_deploy',
'G:\\sourse\\ButtonTask\\sshDeploy\\mass_deploy.py',
'PYMODULE'),
('mimetypes',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\mimetypes.py',
'PYMODULE'),
('netrc',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\netrc.py',
'PYMODULE'),
('numbers',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\numbers.py',
'PYMODULE'),
('opcode',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\opcode.py',
'PYMODULE'),
('pathlib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\pathlib\\__init__.py',
'PYMODULE'),
('pathlib._os',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\pathlib\\_os.py',
'PYMODULE'),
('pickle',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\pickle.py',
'PYMODULE'),
('pprint',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\pprint.py',
'PYMODULE'),
('py_compile',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\py_compile.py',
'PYMODULE'),
('quopri',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\quopri.py',
'PYMODULE'),
('random',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\random.py',
'PYMODULE'),
('selectors',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\selectors.py',
'PYMODULE'),
('shlex',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\shlex.py',
'PYMODULE'),
('shutil',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\shutil.py',
'PYMODULE'),
('signal',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\signal.py',
'PYMODULE'),
('socket',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\socket.py',
'PYMODULE'),
('ssl',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\ssl.py',
'PYMODULE'),
('statistics',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\statistics.py',
'PYMODULE'),
('string',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\string\\__init__.py',
'PYMODULE'),
('stringprep',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\stringprep.py',
'PYMODULE'),
('subprocess',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\subprocess.py',
'PYMODULE'),
('sysconfig',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\sysconfig\\__init__.py',
'PYMODULE'),
('tarfile',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tarfile.py',
'PYMODULE'),
('tempfile',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tempfile.py',
'PYMODULE'),
('textwrap',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\textwrap.py',
'PYMODULE'),
('threading',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\threading.py',
'PYMODULE'),
('tkinter',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\__init__.py',
'PYMODULE'),
('importlib.util', 'C:\\Python313\\Lib\\importlib\\util.py', 'PYMODULE'),
('inspect', 'C:\\Python313\\Lib\\inspect.py', 'PYMODULE'),
('ipaddress', 'C:\\Python313\\Lib\\ipaddress.py', 'PYMODULE'),
('json', 'C:\\Python313\\Lib\\json\\__init__.py', 'PYMODULE'),
('json.decoder', 'C:\\Python313\\Lib\\json\\decoder.py', 'PYMODULE'),
('json.encoder', 'C:\\Python313\\Lib\\json\\encoder.py', 'PYMODULE'),
('json.scanner', 'C:\\Python313\\Lib\\json\\scanner.py', 'PYMODULE'),
('logging', 'C:\\Python313\\Lib\\logging\\__init__.py', 'PYMODULE'),
('lzma', 'C:\\Python313\\Lib\\lzma.py', 'PYMODULE'),
('mass_deploy', 'T:\\sources\\button\\sshDeploy\\mass_deploy.py', 'PYMODULE'),
('numbers', 'C:\\Python313\\Lib\\numbers.py', 'PYMODULE'),
('opcode', 'C:\\Python313\\Lib\\opcode.py', 'PYMODULE'),
('pathlib', 'C:\\Python313\\Lib\\pathlib\\__init__.py', 'PYMODULE'),
('pathlib._abc', 'C:\\Python313\\Lib\\pathlib\\_abc.py', 'PYMODULE'),
('pathlib._local', 'C:\\Python313\\Lib\\pathlib\\_local.py', 'PYMODULE'),
('pickle', 'C:\\Python313\\Lib\\pickle.py', 'PYMODULE'),
('pprint', 'C:\\Python313\\Lib\\pprint.py', 'PYMODULE'),
('py_compile', 'C:\\Python313\\Lib\\py_compile.py', 'PYMODULE'),
('quopri', 'C:\\Python313\\Lib\\quopri.py', 'PYMODULE'),
('random', 'C:\\Python313\\Lib\\random.py', 'PYMODULE'),
('selectors', 'C:\\Python313\\Lib\\selectors.py', 'PYMODULE'),
('shlex', 'C:\\Python313\\Lib\\shlex.py', 'PYMODULE'),
('shutil', 'C:\\Python313\\Lib\\shutil.py', 'PYMODULE'),
('signal', 'C:\\Python313\\Lib\\signal.py', 'PYMODULE'),
('socket', 'C:\\Python313\\Lib\\socket.py', 'PYMODULE'),
('statistics', 'C:\\Python313\\Lib\\statistics.py', 'PYMODULE'),
('string', 'C:\\Python313\\Lib\\string.py', 'PYMODULE'),
('stringprep', 'C:\\Python313\\Lib\\stringprep.py', 'PYMODULE'),
('subprocess', 'C:\\Python313\\Lib\\subprocess.py', 'PYMODULE'),
('tarfile', 'C:\\Python313\\Lib\\tarfile.py', 'PYMODULE'),
('tempfile', 'C:\\Python313\\Lib\\tempfile.py', 'PYMODULE'),
('textwrap', 'C:\\Python313\\Lib\\textwrap.py', 'PYMODULE'),
('threading', 'C:\\Python313\\Lib\\threading.py', 'PYMODULE'),
('tkinter', 'C:\\Python313\\Lib\\tkinter\\__init__.py', 'PYMODULE'),
('tkinter.commondialog',
'C:\\Python313\\Lib\\tkinter\\commondialog.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\commondialog.py',
'PYMODULE'),
('tkinter.constants',
'C:\\Python313\\Lib\\tkinter\\constants.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\constants.py',
'PYMODULE'),
('tkinter.dialog',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\dialog.py',
'PYMODULE'),
('tkinter.dialog', 'C:\\Python313\\Lib\\tkinter\\dialog.py', 'PYMODULE'),
('tkinter.filedialog',
'C:\\Python313\\Lib\\tkinter\\filedialog.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\filedialog.py',
'PYMODULE'),
('tkinter.messagebox',
'C:\\Python313\\Lib\\tkinter\\messagebox.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\messagebox.py',
'PYMODULE'),
('tkinter.simpledialog',
'C:\\Python313\\Lib\\tkinter\\simpledialog.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\simpledialog.py',
'PYMODULE'),
('tkinter.ttk',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tkinter\\ttk.py',
'PYMODULE'),
('token',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\token.py',
'PYMODULE'),
('tokenize',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tokenize.py',
'PYMODULE'),
('tracemalloc',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\tracemalloc.py',
'PYMODULE'),
('typing',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\typing.py',
'PYMODULE'),
('urllib',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\urllib\\__init__.py',
'PYMODULE'),
('urllib.error',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\urllib\\error.py',
'PYMODULE'),
('urllib.parse',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\urllib\\parse.py',
'PYMODULE'),
('urllib.request',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\urllib\\request.py',
'PYMODULE'),
('urllib.response',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\urllib\\response.py',
'PYMODULE'),
('zipfile',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\zipfile\\__init__.py',
'PYMODULE'),
('tkinter.ttk', 'C:\\Python313\\Lib\\tkinter\\ttk.py', 'PYMODULE'),
('token', 'C:\\Python313\\Lib\\token.py', 'PYMODULE'),
('tokenize', 'C:\\Python313\\Lib\\tokenize.py', 'PYMODULE'),
('tracemalloc', 'C:\\Python313\\Lib\\tracemalloc.py', 'PYMODULE'),
('typing', 'C:\\Python313\\Lib\\typing.py', 'PYMODULE'),
('urllib', 'C:\\Python313\\Lib\\urllib\\__init__.py', 'PYMODULE'),
('urllib.parse', 'C:\\Python313\\Lib\\urllib\\parse.py', 'PYMODULE'),
('zipfile', 'C:\\Python313\\Lib\\zipfile\\__init__.py', 'PYMODULE'),
('zipfile._path',
'C:\\Python313\\Lib\\zipfile\\_path\\__init__.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\zipfile\\_path\\__init__.py',
'PYMODULE'),
('zipfile._path.glob',
'C:\\Python313\\Lib\\zipfile\\_path\\glob.py',
'C:\\Users\\black\\AppData\\Local\\Python\\pythoncore-3.14-64\\Lib\\zipfile\\_path\\glob.py',
'PYMODULE')])
Binary file not shown.
Binary file not shown.
@@ -14,12 +14,13 @@ Types of import:
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional)
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional)
missing module named 'collections.abc' - imported by _colorize (top-level), typing (top-level), traceback (top-level), logging (top-level), selectors (top-level), http.client (top-level), importlib.resources.readers (top-level), tracemalloc (top-level), inspect (top-level)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional), pathlib._os (optional)
missing module named resource - imported by posix (top-level)
missing module named fcntl - imported by pathlib._os (optional), subprocess (optional)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (optional), subprocess (delayed, conditional, optional)
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (optional), netrc (delayed, optional), subprocess (delayed, conditional, optional)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional)
missing module named 'collections.abc' - imported by traceback (top-level), typing (top-level), inspect (top-level), logging (top-level), importlib.resources.readers (top-level), selectors (top-level), tracemalloc (top-level)
missing module named posix - imported by posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional), os (conditional, optional)
missing module named resource - imported by posix (top-level)
missing module named _posixsubprocess - imported by subprocess (conditional)
missing module named fcntl - imported by subprocess (optional)
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -23,5 +23,11 @@
"pscp_path": "pscp.exe",
"ssh_timeout_seconds": 180,
"install_timeout_seconds": 600
"install_timeout_seconds": 600,
"network_write_retries": 3,
"network_write_timeout_seconds": 90,
"network_write_retry_pause_seconds": 4,
"reset_config": false
}
+153
View File
@@ -0,0 +1,153 @@
# Массовый деплой ButtonTask по SSH (без adb)
Заменяет `deploy-adb.ps1` там, где adb на устройстве отключён. Вместо USB
использует Ethernet: инструмент сам выдаёт устройствам IP (мини-DHCP-сервер)
и заливает бандл по SSH. Можно подключать сразу несколько устройств через
свитч — каждое обрабатывается параллельно.
После установки в конфиг устройства пишется static `final_ip` (по умолчанию
`192.168.1.60`), затем устройство **выключается (`halt`)**, чтобы на свитче
не поднималось несколько девайсов с одним и тем же IP.
## Важно про сеть
Инструмент работает только в **изолированной сети**.
Используйте отдельный свитч, к которому подключены:
- один сетевой адаптер компьютера;
- пустые устройства ButtonTask.
На этом сегменте не должно быть других устройств и внешних аплинков.
## Что нужно один раз настроить
1. **Python 3** — установить с [python.org](https://www.python.org/downloads/)
(обычная установка, ничего дополнительно ставить не нужно — скрипт не
использует сторонние библиотеки).
2. Если в `config.json` стоит `"auth": "password"` — скачайте
`plink.exe` и `pscp.exe` с [putty.org](https://www.putty.org/) и положите
их в эту же папку, рядом с `mass_deploy.py`.
Если `"auth": "key"` — ничего докачивать не нужно, используется
встроенный в Windows 10/11 `ssh.exe`/`scp.exe`.
3. На сетевом адаптере, подключённом к выделенному свитчу, задать
**статический IP** (Панель управления -> Сеть -> Свойства адаптера ->
IPv4): например `192.168.1.201`, маска `255.255.255.0`, без шлюза.
Этот адрес должен совпадать с `server_ip` / `bind_ip` в `config.json`.
4. Если на этом адаптере когда-либо включали "Общий доступ к подключению к
интернету" (Internet Connection Sharing) — отключить. Он тоже слушает
DHCP-порт и будет конфликтовать с нашим сервером.
5. Скопировать `config.example.json` в `config.json` и заполнить:
- `bundle_path` — путь к `.tgz` бандлу (можно относительно этой папки,
например `../winDeployScripts/buttontask-deploy-2.33.tgz`);
- `device_user` / `device_password` (на голых платах обычно `linaro`/`linaro`);
- `use_sudo``true` (root не настроен, install идёт через `sudo -S`);
- `final_ip` — static IP, который останется в `network.json` после установки
(по умолчанию `192.168.1.60`);
- `after_install``halt` (рекомендуется) или `reboot`;
- `reset_config``false` (сохранить config на устройстве) или `true`
(перезаписать `config.json` и `.master` из бандла — для переустановки);
- при необходимости — диапазон `pool_start`/`pool_end` (адрес `final_ip`
из пула исключается автоматически).
## Запуск
### Вариант 1 — GUI для оператора
Дважды кликнуть `run-gui.bat`.
Порядок работы:
1. Настроить сетевой адаптер компьютера.
2. Включить GUI.
3. Выбрать сетевой интерфейс.
4. Выбрать `.tgz` бандл.
5. При переустановке на уже настроенное устройство — включить «Полный сброс конфига».
6. Нажать `Start`.
6. Вставить устройство в изолированную сеть.
7. Дождаться статуса `ГОТОВО`.
8. Вынуть устройство и повторить для следующего.
Windows спросит права администратора — нужно согласиться.
### Вариант 2 — старый CLI
Дважды кликнуть `run.bat`.
- Windows спросит права администратора — нужно согласиться (иначе DHCP-порт
может быть занят системными службами).
- Windows Firewall может спросить "разрешить программе доступ к сети" —
разрешить.
- Появится таблица статусов, обновляющаяся раз в секунду.
Подключайте устройства к свитчу — по одному или сразу все. Каждое появится
в таблице (по MAC-адресу) и пройдёт стадии:
```
discover -> acked -> ждём ssh -> заливаю -> устанавливаю -> сеть -> halt -> ГОТОВО
```
Общий алгоритм деплоя:
1. Устройство получает IP по DHCP.
2. Бандл копируется по SSH.
3. Устанавливаются пакеты и файлы из бандла.
4. Применяются `sudoers` и служебные скрипты.
5. Записывается итоговая сеть устройства.
6. Устройство выключается, чтобы избежать конфликтов после установки.
Если где-то `ОШИБКА`, детали видны в таблице; полный вывод install пишется в
`logs/<mac>.log`.
Частая ошибка на шаге `сеть`: `Connection timed out` — SSH оборвался до записи
`network.json`. Устройство после этого остаётся на DHCP; в таблице будет
`ОШИБКА`, не `ГОТОВО`. С новых версий deploy-скрипта запись повторяется до 3
раз и проверяется чтением файла обратно.
Когда устройство показывает `ГОТОВО` — оно выключено. Можно отключать кабель
и включать уже на рабочей сети: поднимется на `final_ip` с сервисами
ButtonTask.
`Ctrl+C` в окне консоли — остановить инструмент.
## Повторный прогон / переустановка того же устройства
IP-адреса привязываются к MAC-адресу и запоминаются в файле `leases.json`
рядом с конфигом — при повторной установке то же устройство снова получит
тот же DHCP-IP. SSH fingerprint'ы аналогично хранятся в `host_keys.json`.
Если нужно "сбросить" привязки/ключи — удалите `leases.json` и/или
`host_keys.json` (файлы создаются заново автоматически).
## device-install.sh
На время install `network.json` из бандла временно прячется, а старый
`/opt/buttontask/config/network.json` на устройстве удаляется — иначе при
переустановке install мог бы применить static во время SSH-сессии и оборвать
связь. Целевой static `final_ip` записывается уже после `INSTALL-OK` и
проверяется чтением файла.
`config.json` и `.master` **по умолчанию сохраняются**, если уже есть на
устройстве (upgrade-safe). Для переустановки «как с завода» включите
`"reset_config": true` в `config.json`, галочку в GUI или переменную
`BUTTONTASK_RESET_CONFIG=1` при запуске install.
## Сборка в exe
Для упаковки GUI в один exe:
```powershell
powershell -ExecutionPolicy Bypass -File .\build-gui.ps1
```
В результате появится папка `dist\sshDeploy-gui\` с:
- `sshDeploy-gui.exe`
- `config.json`
- `config.example.json`
- `README.md`
- `plink.exe`
- `pscp.exe`
Оператору можно отдавать именно эту папку.
Binary file not shown.
+7 -1
View File
@@ -23,5 +23,11 @@
"pscp_path": "pscp.exe",
"ssh_timeout_seconds": 180,
"install_timeout_seconds": 600
"install_timeout_seconds": 600,
"network_write_retries": 3,
"network_write_timeout_seconds": 90,
"network_write_retry_pause_seconds": 4,
"reset_config": false
}
+1 -1
View File
@@ -11,7 +11,7 @@
"final_ip": "192.168.1.60",
"after_install": "halt",
"bundle_path": "buttontask-deploy-2.56.tgz",
"bundle_path": "buttontask-deploy-2.63.tgz",
"device_user": "linaro",
"auth": "password",
-1
View File
@@ -1,4 +1,3 @@
{
"ca:b1:00:7c:80:f1": "ssh-ed25519 255 SHA256:2cDol8gz3BgF9gg7fQvJf8hDHLUheVyyYH+cQMILj7s",
"96:a4:ad:6d:fb:89": "ssh-ed25519 255 SHA256:D1nYX1w/xTGAVC2sMNPWIxbF/5xRWrM3lyxN0E6kTI8"
}
+1 -6
View File
@@ -1,8 +1,3 @@
{
"ca:b1:00:7c:80:f1": "192.168.1.50",
"34:86:da:50:05:40": "192.168.1.51",
"9e:7e:e5:d0:11:34": "192.168.1.52",
"96:a4:ad:6d:fb:89": "192.168.1.53",
"38:d5:7a:f8:f3:fd": "192.168.1.54",
"54:a0:50:7d:e7:9f": "192.168.1.55"
"96:a4:ad:6d:fb:89": "192.168.1.50"
}
Binary file not shown.
+16 -2
View File
@@ -44,6 +44,7 @@ class DeployGui(tk.Tk):
self.nic_var = tk.StringVar()
self.bundle_var = tk.StringVar(value=self.base_cfg.get("bundle_path", ""))
self.reset_config_var = tk.BooleanVar(value=bool(self.base_cfg.get("reset_config", False)))
self.summary_var = tk.StringVar(value="Готово к запуску.")
self.stats_var = tk.StringVar(value="DHCP: rx=0 offer=0 ack=0")
@@ -65,8 +66,16 @@ class DeployGui(tk.Tk):
ttk.Entry(top, textvariable=self.bundle_var).grid(row=1, column=1, sticky="ew", pady=4)
ttk.Button(top, text="Выбрать", command=self.pick_bundle).grid(row=1, column=2, padx=6, pady=4)
reset_row = ttk.Frame(top)
reset_row.grid(row=2, column=0, columnspan=3, sticky="w", pady=(0, 4))
ttk.Checkbutton(
reset_row,
text="Полный сброс конфига (config.json + .master из бандла)",
variable=self.reset_config_var,
).pack(anchor="w")
actions = ttk.Frame(top)
actions.grid(row=2, column=0, columnspan=3, sticky="w", pady=(10, 0))
actions.grid(row=3, column=0, columnspan=3, sticky="w", pady=(10, 0))
self.start_btn = ttk.Button(actions, text="Start", command=self.start_backend)
self.start_btn.pack(side="left")
self.stop_btn = ttk.Button(actions, text="Stop", command=self.stop_backend, state="disabled")
@@ -185,7 +194,12 @@ class DeployGui(tk.Tk):
if not bundle:
messagebox.showerror(APP_TITLE, "Выберите .tgz бандл.")
return
cfg = build_runtime_config(self.base_cfg, interface_ip=iface["ip"], bundle_path=bundle)
cfg = build_runtime_config(
self.base_cfg,
interface_ip=iface["ip"],
bundle_path=bundle,
reset_config=self.reset_config_var.get(),
)
try:
ensure_admin()
self.backend = DeployBackend(CONFIG_PATH, cfg=cfg)
+79 -13
View File
@@ -269,6 +269,19 @@ def build_final_network_json(cfg):
}
def network_json_matches(cfg, text):
"""Best-effort check that remote network.json has the expected static profile."""
try:
data = json.loads(text)
except json.JSONDecodeError:
return False
expected = build_final_network_json(cfg)
for key in ("iface", "mode", "address", "prefix", "gateway", "dns"):
if str(data.get(key, "")).strip() != str(expected.get(key, "")).strip():
return False
return True
def load_config(config_path):
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
@@ -502,6 +515,54 @@ class DeployBackend:
"-i", self.cfg["device_key_path"], f"{self.cfg['device_user']}@{ip}", remote_cmd,
]
def ssh_run(self, ip, remote_cmd, hostkey=None, timeout=60):
return run(self.build_ssh_cmd(ip, remote_cmd, hostkey=hostkey), timeout=timeout)
def write_network_json(self, mac, ip, hostkey):
"""Write final static network.json and verify on disk (retries on SSH flake)."""
net_json = json.dumps(build_final_network_json(self.cfg), indent=2)
b64 = base64.b64encode(net_json.encode("utf-8")).decode("ascii")
final_ip = self.cfg.get("final_ip", "192.168.1.60")
sp = sudo_prefix(self.cfg)
write_cmd = (
f"echo {b64} | base64 -d > /tmp/network.json && "
f"{sp}install -m0644 -o buttontask -g buttontask /tmp/network.json "
f"/opt/buttontask/config/network.json && rm -f /tmp/network.json"
)
verify_cmd = "cat /opt/buttontask/config/network.json"
attempts = max(1, int(self.cfg.get("network_write_retries", 3)))
timeout = int(self.cfg.get("network_write_timeout_seconds", 90))
pause = float(self.cfg.get("network_write_retry_pause_seconds", 4))
last_out = ""
for attempt in range(1, attempts + 1):
detail = f"пишу static {final_ip}..."
if attempt > 1:
detail = f"повтор {attempt}/{attempts}: пишу static {final_ip}..."
self.update_status(mac, state="сеть", detail=detail)
try:
rc, out = self.ssh_run(ip, write_cmd, hostkey=hostkey, timeout=timeout)
except subprocess.TimeoutExpired as e:
rc, out = 124, e.stdout or "timeout"
last_out = out or ""
if rc != 0:
if attempt < attempts:
time.sleep(pause)
continue
return False, last_out
try:
vrc, vout = self.ssh_run(ip, verify_cmd, hostkey=hostkey, timeout=30)
except subprocess.TimeoutExpired as e:
vrc, vout = 124, e.stdout or "timeout"
if vrc == 0 and network_json_matches(self.cfg, vout):
return True, vout.strip()
last_out = vout or last_out
if attempt < attempts:
time.sleep(pause)
return False, last_out or "network.json verify failed"
def deploy_device(self, mac, ip):
sp = sudo_prefix(self.cfg)
self.update_status(mac, ip=ip, state="ждём ssh", detail="ожидаю открытия порта 22...")
@@ -524,12 +585,17 @@ class DeployBackend:
return
self.update_status(mac, state="устанавливаю", detail="выполняю device-install.sh...")
if self.cfg.get("reset_config"):
install_cmd = f"{sp}env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh"
else:
install_cmd = f"{sp}bash /tmp/btdeploy/device-install.sh"
remote_cmd = (
f"rm -rf /tmp/btdeploy && mkdir -p /tmp/btdeploy && "
f"tar xzf {remote_tgz} -C /tmp/btdeploy && "
f"if [ -f /tmp/btdeploy/config/network.json ]; then "
f"mv /tmp/btdeploy/config/network.json /tmp/btdeploy/config/network.json.sshdeploy; fi && "
f"{sp}bash /tmp/btdeploy/device-install.sh"
f"{sp}rm -f /opt/buttontask/config/network.json && "
f"{install_cmd}"
)
rc, out = run(
self.build_ssh_cmd(ip, remote_cmd, hostkey=hostkey),
@@ -542,17 +608,13 @@ class DeployBackend:
return
self.update_status(mac, state="сеть", detail=f"пишу static {self.cfg.get('final_ip', '192.168.1.60')}...")
net_json = json.dumps(build_final_network_json(self.cfg), indent=2)
b64 = base64.b64encode(net_json.encode("utf-8")).decode("ascii")
write_net = (
f"echo {b64} | base64 -d > /tmp/network.json && "
f"{sp}cp /tmp/network.json /opt/buttontask/config/network.json && "
f"{sp}chown buttontask:buttontask /opt/buttontask/config/network.json || true && rm -f /tmp/network.json"
)
rc, out = run(self.build_ssh_cmd(ip, write_net, hostkey=hostkey), timeout=60)
if rc != 0:
write_log(mac, out)
self.update_status(mac, state="ОШИБКА", detail=f"network.json: {out[-200:]}")
ok, out = self.write_network_json(mac, ip, hostkey)
if not ok:
log_path = write_log(mac, out)
self.update_status(
mac, state="ОШИБКА",
detail=f"network.json: {(out or 'verify failed')[-180:]} (лог: {log_path})",
)
return
try:
@@ -681,13 +743,15 @@ class DeployBackend:
self.notify()
def build_runtime_config(base_cfg, interface_ip=None, bundle_path=None):
def build_runtime_config(base_cfg, interface_ip=None, bundle_path=None, reset_config=None):
cfg = copy.deepcopy(base_cfg)
if interface_ip:
cfg["server_ip"] = interface_ip
cfg["bind_ip"] = "0.0.0.0"
if bundle_path:
cfg["bundle_path"] = bundle_path
if reset_config is not None:
cfg["reset_config"] = bool(reset_config)
return cfg
@@ -719,6 +783,8 @@ def main():
print(f"[deploy] бандл: {backend.cfg['_bundle_path']}")
print(f"[deploy] DHCP listen: {backend.cfg.get('bind_ip')}:{DHCP_SERVER_PORT} (server_ip={backend.cfg['server_ip']})")
print(f"[deploy] final_ip: {backend.cfg['final_ip']}, after_install: {backend.cfg['after_install']}")
if backend.cfg.get("reset_config"):
print("[deploy] reset_config: config.json и .master будут перезаписаны из бандла")
time.sleep(1.5)
backend.start()
printer = threading.Thread(target=printer_loop, args=(backend,), daemon=True)
+52
View File
@@ -15,6 +15,10 @@
http://<ip>:8765/cleaning?result=pending
http://<ip>:8765/nested?status=ready
http://<ip>:8765/reset сброс счётчиков poll
Latch/reset (plain-text OK, как у заказчика):
http://<ip>:8765/api/click/btn?bid=01*04*01 вызов (trigger)
http://<ip>:8765/api/click/btn?bid=01*04*00 сброс (reset)
"""
from __future__ import annotations
@@ -34,6 +38,25 @@ def _now() -> str:
_poll_lock = threading.Lock()
_poll_counts: dict[str, int] = {}
_latch_lock = threading.Lock()
_latch_active: dict[str, bool] = {}
def _latch_key(handler: BaseHTTPRequestHandler, bid: str) -> str:
"""Group trigger/reset pairs by bid prefix (01*04*01 / 01*04*00 -> 01*04)."""
parts = bid.split("*")
if len(parts) >= 2:
return "*".join(parts[:-1])
return handler.client_address[0]
def _bid_action(bid: str) -> str:
parts = bid.split("*")
if parts and parts[-1] == "00":
return "reset"
if parts and parts[-1] == "01":
return "trigger"
return "unknown"
def _poll_key(handler: BaseHTTPRequestHandler, qs: dict) -> str:
@@ -104,6 +127,30 @@ class ButtonTestHandler(BaseHTTPRequestHandler):
self._send_json(200, {"status": "ok", "bid": bid, "ts": time.time()})
return True
if path == "/api/click/btn":
bid = (qs.get("bid") or ["?"])[0]
action = _bid_action(bid)
key = _latch_key(self, bid)
note = ""
with _latch_lock:
if action == "trigger":
if _latch_active.get(key):
note = " (already active)"
else:
_latch_active[key] = True
elif action == "reset":
if _latch_active.get(key):
_latch_active[key] = False
note = " (cleared)"
else:
note = " (was idle)"
print(
f"[{_now()}] /api/click/btn bid={bid} action={action} key={key}{note}",
flush=True,
)
self._send(200, "OK")
return True
if path == "/slow":
sec = float((qs.get("sec") or ["3"])[0])
sec = max(0.0, min(sec, 60.0))
@@ -235,6 +282,11 @@ GET /poll?pending=3&key=btn1 -> отдельный счётчик на кно
GET /reset -> сброс всех счётчиков
GET /reset?key=btn1 -> сброс одного
=== Latch/reset (bodyMatch: plain OK) ===
GET /api/click/btn?bid=01*04*01 -> trigger, тело "OK"
GET /api/click/btn?bid=01*04*00 -> reset, тело "OK"
(последний сегмент bid: 01=вызов, 00=сброс; в консоли видно состояние)
=== Прочее ===
GET /cleaning?result=ok|pending|error
GET /nested?status=ready -> {"data":{"status":"ready"}}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
import json
import sys
import tarfile
path = sys.argv[1]
with tarfile.open(path) as t:
install = t.extractfile("./device-install.sh").read().decode()
cfg = json.loads(t.extractfile("./config/config.json").read().decode())
print("reset_support:", "BUTTONTASK_RESET_CONFIG" in install)
print("password:", cfg.get("settings", {}).get("password"))
print("buttons:", len(cfg.get("buttons", [])))
for b in cfg.get("buttons", []):
print(" -", b.get("id"), b.get("label"), (b.get("action") or {}).get("url", "")[:50])
+48
View File
@@ -81,6 +81,41 @@ def _validate_response_check(data):
}
def _validate_latch_reset(data):
if data is None:
return None
if not isinstance(data, dict):
abort(400, "latchReset must be an object")
enabled = bool(data.get("enabled", False))
if not enabled:
return {"enabled": False}
reset_url = (data.get("resetUrl") or "").strip()
if not reset_url:
abort(400, "latchReset.resetUrl is required when enabled")
success_match = (data.get("successMatch") or "OK").strip() or "OK"
result = {
"enabled": True,
"resetUrl": reset_url,
"successMatch": success_match,
}
if data.get("fireAndForget"):
result["fireAndForget"] = True
return result
def _apply_button_latch_reset(btn, data):
if "latchReset" not in data:
return btn
lr = _validate_latch_reset(data.get("latchReset"))
if lr is None:
btn.pop("latchReset", None)
else:
btn["latchReset"] = lr
return btn
def _apply_button_action(action, data):
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
@@ -88,6 +123,17 @@ def _apply_button_action(action, data):
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
if "timeoutMs" in data:
try:
timeout_ms = int(data["timeoutMs"])
except (TypeError, ValueError):
abort(400, "timeoutMs must be an integer")
if timeout_ms < 1000 or timeout_ms > 600000:
abort(400, "timeoutMs must be between 1000 and 600000")
if timeout_ms == 7000:
action.pop("timeoutMs", None)
else:
action["timeoutMs"] = timeout_ms
if "responseCheck" in data:
rc = _validate_response_check(data.get("responseCheck"))
if rc is None:
@@ -148,6 +194,7 @@ def api_add_button():
},
"color": data.get("color", "#7b007b"),
}
_apply_button_latch_reset(btn, data)
cfg.setdefault("buttons", []).append(btn)
save_config(cfg)
return jsonify(btn)
@@ -180,6 +227,7 @@ def api_update_button(bid):
trigger.setdefault("holdMs", 800)
if "color" in data:
b["color"] = data["color"]
_apply_button_latch_reset(b, data)
save_config(cfg)
return jsonify(b)
abort(404)
-1
View File
@@ -2,7 +2,6 @@
Description=ButtonTask network bringup
After=NetworkManager.service
Wants=NetworkManager.service
Before=buttontask-web.service buttontask.service
[Service]
Type=oneshot
+2 -2
View File
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Web Configurator
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
+2 -2
View File
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Qt5 UI
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
+2 -1
View File
@@ -50,10 +50,11 @@ def _defaults():
},
"settings": {
"darkMode": True,
"password": "admin",
"password": "admin_26",
"kioskMode": False,
"iconsDir": "./icons",
"webPort": 8080,
"brightness": 60,
},
"buttons": [],
}
+25 -2
View File
@@ -8,6 +8,10 @@
#
# Intended to be invoked through sudo by the (unprivileged) web configurator.
# Do NOT use ip/ifconfig directly — NetworkManager will undo manual changes.
#
# Environment (optional):
# BUTTONTASK_NET_BOOT=1 boot path: succeed even if activation times out
# BUTTONTASK_NET_ACTIVATE_TIMEOUT=N seconds to wait for nmcli connection up (default 30)
set -euo pipefail
err() { echo "bt-netconfig: $*" >&2; exit 1; }
@@ -17,6 +21,8 @@ err() { echo "bt-netconfig: $*" >&2; exit 1; }
IFACE="$1"
MODE="$2"
PROFILE="buttontask-$IFACE"
BOOT="${BUTTONTASK_NET_BOOT:-0}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-30}"
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
@@ -99,8 +105,25 @@ esac
nmcli device disconnect "$IFACE" 2>/dev/null || true
nmcli connection down "$CON" 2>/dev/null || true
if ! nmcli connection up "$CON" ifname "$IFACE" 2>/dev/null; then
nmcli connection up "$CON" >/dev/null
activate_connection() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
&& return 0
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" >/dev/null 2>&1 \
&& return 0
return 1
fi
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" >/dev/null
}
if ! activate_connection "$CON" "$IFACE"; then
if [ "$BOOT" = "1" ]; then
echo "bt-netconfig: activation timed out (boot); profile saved, NM will retry" >&2
exit 0
fi
err "connection activation failed or timed out after ${ACTIVATE_TIMEOUT}s"
fi
# Brief settle, then report what NM actually applied.
+26 -7
View File
@@ -2,17 +2,21 @@
#
# bt-netconfig-boot - re-apply saved network settings after reboot.
# Runs as root from buttontask-network.service once NetworkManager is up.
# Does not block UI startup: short waits, bounded activation, best-effort apply.
set -uo pipefail
ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}"
CFG="$ROOT/config/network.json"
SCRIPT="$ROOT/scripts/bt-netconfig"
NM_WAIT_SEC="${BUTTONTASK_NET_NM_WAIT:-15}"
IFACE_WAIT_SEC="${BUTTONTASK_NET_IFACE_WAIT:-10}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-10}"
log() { echo "bt-netconfig-boot: $*"; }
wait_for_iface() {
local iface="$1" i
for i in $(seq 1 45); do
for i in $(seq 1 "$IFACE_WAIT_SEC"); do
ip link show "$iface" &>/dev/null && return 0
sleep 1
done
@@ -21,19 +25,33 @@ wait_for_iface() {
wait_for_nm() {
local i
for i in $(seq 1 30); do
for i in $(seq 1 "$NM_WAIT_SEC"); do
nmcli general status &>/dev/null && return 0
sleep 1
done
return 1
}
activate_profile() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" 2>/dev/null \
|| true
else
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
fi
}
apply_saved() {
[ -f "$CFG" ] || { log "no saved config at $CFG"; return 0; }
[ -x "$SCRIPT" ] || { log "bt-netconfig missing: $SCRIPT"; return 1; }
BUTTONTASK_NET_BOOT=1 \
BUTTONTASK_NET_ACTIVATE_TIMEOUT="$ACTIVATE_TIMEOUT" \
python3 - "$CFG" "$SCRIPT" <<'PY'
import json, subprocess, sys
import json, os, subprocess, sys
cfg_path, script = sys.argv[1], sys.argv[2]
with open(cfg_path, encoding="utf-8") as f:
@@ -50,7 +68,9 @@ if mode == "static":
cfg.get("gateway", ""),
cfg.get("dns", ""),
]
raise SystemExit(subprocess.run(args).returncode)
env = os.environ.copy()
env.setdefault("BUTTONTASK_NET_BOOT", "1")
raise SystemExit(subprocess.run(args, env=env).returncode)
PY
}
@@ -63,13 +83,12 @@ wait_for_nm || log "NetworkManager not ready, continuing anyway"
wait_for_iface "$IFACE" || log "interface $IFACE not found, continuing anyway"
if apply_saved; then
log "network ready on $IFACE"
log "network profile applied on $IFACE (activation may continue in background)"
else
log "saved config apply failed, trying buttontask profiles"
while IFS= read -r con; do
[ -z "$con" ] && continue
iface="${con#buttontask-}"
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
activate_profile "$con" "$iface"
done < <(nmcli -t -f NAME connection show 2>/dev/null | grep '^buttontask-' || true)
fi
+46 -1
View File
@@ -266,6 +266,45 @@ function syncResponseCheckUi() {
if (adv) adv.classList.toggle("show", !!on);
}
function fillLatchResetForm(lr) {
const latchEl = $("#b-latch-enabled");
if (!latchEl) return;
const enabled = lr ? !!lr.enabled : true;
latchEl.checked = enabled;
$("#b-reset-url").value = lr?.resetUrl || "";
$("#b-success-match").value = lr?.successMatch || "OK";
const ffEl = $("#b-fire-and-forget");
if (ffEl) ffEl.checked = !!lr?.fireAndForget;
syncLatchUi();
}
function readLatchResetFromForm() {
if (!$("#b-latch-enabled")?.checked) {
return { enabled: false };
}
return {
enabled: true,
resetUrl: $("#b-reset-url").value.trim(),
successMatch: ($("#b-success-match").value.trim() || "OK"),
fireAndForget: !!$("#b-fire-and-forget")?.checked,
};
}
function syncLatchUi() {
const latchOn = $("#b-latch-enabled")?.checked;
const fields = $("#b-latch-fields");
const rcBlock = $("#b-response-check-block");
const ffHint = document.querySelector(".latch-fire-hint");
if (fields) fields.classList.toggle("show", !!latchOn);
if (rcBlock) rcBlock.style.display = latchOn ? "none" : "";
if (ffHint) {
ffHint.style.display = latchOn && $("#b-fire-and-forget")?.checked ? "" : "none";
}
}
$("#b-latch-enabled")?.addEventListener("change", syncLatchUi);
$("#b-fire-and-forget")?.addEventListener("change", syncLatchUi);
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
@@ -284,14 +323,17 @@ function openDialogFor(btn) {
$("#b-icon").value = btn?.iconPath || "";
$("#b-type").value = btn?.action?.type || "http_get";
$("#b-url").value = btn?.action?.url || "";
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? 7000;
$("#b-success").value = btn?.feedback?.successText || "";
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
$("#b-pending").value = btn?.feedback?.pendingText ?? "...";
$("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800;
fillLatchResetForm(btn?.latchReset);
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow();
syncLatchUi();
dlg.showModal();
}
@@ -322,18 +364,21 @@ form?.addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const id = $("#b-id").value;
const latchReset = readLatchResetFromForm();
const payload = {
label: $("#b-label").value,
iconPath: $("#b-icon").value,
actionType: $("#b-type").value,
url: $("#b-url").value,
timeoutMs: parseInt($("#b-action-timeout").value, 10) || 7000,
successText: $("#b-success").value,
errorText: $("#b-error").value,
pendingText: $("#b-pending").value || "...",
color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
latchReset,
responseCheck: latchReset.enabled ? { enabled: false } : readResponseCheckFromForm(),
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
+3
View File
@@ -397,6 +397,9 @@ dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; }
.response-check-block { margin-top: 6px; }
.latch-reset-block { margin-top: 8px; }
.latch-reset-fields { display: none; margin-top: 8px; gap: 8px; flex-direction: column; }
.latch-reset-fields.show { display: flex; }
.response-check-advanced { display: none; margin-top: 10px; gap: 10px; flex-direction: column; }
.response-check-advanced.show { display: flex; }
.response-rules-head {
+23 -1
View File
@@ -411,7 +411,7 @@
<p class="hint">Используется и для входа в веб-конфигуратор, и для настроек в приложении.</p>
<div class="row">
<label>Новый пароль
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
<input type="text" id="new-password" placeholder="Оставьте пустым чтобы не менять" autocomplete="off">
</label>
<button type="button" id="save-password" class="primary">Сохранить пароль</button>
</div>
@@ -440,6 +440,28 @@
</select>
</label>
<label>URL <input type="text" id="b-url" required></label>
<label>Ожидание ответа, мс
<input type="number" id="b-action-timeout" min="1000" max="600000" step="500" value="7000">
</label>
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. По умолчанию 7000 мс (7 с). При «Мгновенном отклике» влияет только на фоновый запрос и лог.</p>
<div class="latch-reset-block" id="b-latch-block">
<label class="check">
<input type="checkbox" id="b-latch-enabled" checked>
Сброс повторным нажатием
</label>
<p class="hint">Первое нажатие — вызов по URL выше. Успех, если в ответе есть указанный текст или HTTP &lt; 400. Зелёный статус держится до повторного нажатия (URL сброса).</p>
<div id="b-latch-fields" class="latch-reset-fields">
<label>URL сброса <input type="text" id="b-reset-url" placeholder="http://host/api/click/btn?bid=01*04*00"></label>
<label>Успех если в ответе <input type="text" id="b-success-match" value="OK"></label>
<label class="check">
<input type="checkbox" id="b-fire-and-forget">
Мгновенный отклик
</label>
<p class="hint latch-fire-hint">Статус обновляется сразу после нажатия; команда на сервер уходит параллельно.</p>
</div>
</div>
<label>Текст успеха <input type="text" id="b-success"></label>
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
<label>Текст ожидания <input type="text" id="b-pending" value="..."></label>
+4 -1
View File
@@ -91,12 +91,15 @@ PNG/JPG/SVG/GIF из `.\software\` и `.\software\icons\` попадут в
1. `dpkg -i debs/*.deb` офлайн (два прохода), `ldconfig`.
2. Создаёт пользователя `buttontask`, группы `video render input`.
3. Раскладывает `/opt/buttontask/{versions/<ver>,config,icons,run,scripts}`,
симлинк `current`. Существующий `config.json` не трогает.
симлинк `current`. Существующий `config.json` **по умолчанию не трогает**
(upgrade-safe). Для полного сброса: `BUTTONTASK_RESET_CONFIG=1` или
`deploy-adb.ps1 -ResetConfig`, либо галочка в sshDeploy GUI.
4. Хелперы `bt-*` + `sudoers` (+`visudo -c`).
5. `/tmp/runtime-buttontask` через `tmpfiles.d` (переживает ребут).
6. Авто-детект тачскрина → `override.conf` с нужным `eventN`.
7. Выключает `display-manager` (eglfs забирает KMS/DRM).
8. `systemctl enable buttontask buttontask-web buttontask-network`; старт — после ребута.
UI (`buttontask`) не ждёт поднятия IP — сеть настраивается параллельно с коротким таймаутом.
9. Если в бандле есть `config/.master` — кладёт аварийный мастер-пароль (web + Qt).
Идемпотентен: повторный прогон безопасен.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -2
View File
@@ -6,7 +6,8 @@
# Сценарий: подключил по USB -> нашлось adb-устройство -> отработал -> ребут -> работает.
param(
[string]$Bundle,
[string]$Adb
[string]$Adb,
[switch]$ResetConfig
)
# native-команды adb пишут в stderr (adb root, прогресс push); Stop делает это фатальным
$ErrorActionPreference = 'Continue'
@@ -67,7 +68,13 @@ Log 'заливаю бандл...'
# 5. установка, ловим INSTALL-OK
Log 'разворачиваю на устройстве...'
$cmd = ("$sudo bash /tmp/btdeploy/device-install.sh").Trim()
if ($ResetConfig) {
Log 'полный сброс config.json и .master из бандла'
if ($sudo) { $cmd = "$sudo env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh" }
else { $cmd = 'env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh' }
} else {
$cmd = ("$sudo bash /tmp/btdeploy/device-install.sh").Trim()
}
$output = & $Adb shell $cmd 2>&1 | Out-String
Write-Host ($output -replace "`r", '')
if ($output -notmatch 'INSTALL-OK') { Die 'установка завершилась без INSTALL-OK (см. вывод выше)' }
+20 -4
View File
@@ -53,8 +53,20 @@ install -m0755 "$BUNDLE_DIR/app/ButtonTask" "$ROOT/versions/$VER/ButtonTask"
cp -a "$BUNDLE_DIR/webconfig" "$ROOT/versions/$VER/"
ln -sfn "$ROOT/versions/$VER" "$ROOT/current"
# config — не перезатираем существующий (apgrade-safe)
if [ -f "$ROOT/config/config.json" ]; then
# config — по умолчанию не перезатираем (upgrade-safe); BUTTONTASK_RESET_CONFIG=1 — из бандла
reset_cfg="${BUTTONTASK_RESET_CONFIG:-0}"
case "$reset_cfg" in
1|yes|true|TRUE|Yes|YES) reset_cfg=1 ;;
*) reset_cfg=0 ;;
esac
if [ "$reset_cfg" = "1" ]; then
if [ -f "$BUNDLE_DIR/config/config.json" ]; then
cp -a "$BUNDLE_DIR/config/config.json" "$ROOT/config/"
log "config.json перезаписан из бандла (полный сброс конфига)"
else
warn "BUTTONTASK_RESET_CONFIG=1, но config.json в бандле нет"
fi
elif [ -f "$ROOT/config/config.json" ]; then
log "config.json уже есть — оставляю как есть"
elif [ -f "$BUNDLE_DIR/config/config.json" ]; then
cp -a "$BUNDLE_DIR/config/config.json" "$ROOT/config/"
@@ -111,7 +123,11 @@ chown -R "$USER_NAME:$USER_NAME" "$ROOT"
chown root:root "$ROOT"/scripts/bt-*
# мастер-пароль (аварийный вход web + Qt) — один раз при первой установке
if [ -f "$ROOT/config/.master" ]; then
if [ "$reset_cfg" = "1" ] && [ -f "$BUNDLE_DIR/config/.master" ]; then
install -m0600 -o "$USER_NAME" -g "$USER_NAME" \
"$BUNDLE_DIR/config/.master" "$ROOT/config/.master"
log "мастер-пароль перезаписан из бандла (полный сброс конфига)"
elif [ -f "$ROOT/config/.master" ]; then
log "config/.master уже есть — оставляю как есть"
elif [ -f "$BUNDLE_DIR/config/.master" ]; then
install -m0600 -o "$USER_NAME" -g "$USER_NAME" \
@@ -168,7 +184,7 @@ systemctl enable buttontask buttontask-web buttontask-network >/dev/null 2>&1 ||
# применить сеть сразу (не ждать ребута), если есть сохранённый профиль
if [ -f "$ROOT/config/network.json" ] && [ -x "$ROOT/scripts/bt-netconfig-boot" ]; then
if "$ROOT/scripts/bt-netconfig-boot"; then
if BUTTONTASK_NET_ACTIVATE_TIMEOUT=30 "$ROOT/scripts/bt-netconfig-boot"; then
log "сеть применена из network.json"
else
warn "не удалось применить network.json — проверьте nmcli и кабель"
+3 -2
View File
@@ -93,13 +93,14 @@ else {
"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": "/opt/buttontask/icons", "webPort": 8080 },
"settings": { "darkMode": true, "password": "admin_26", "kioskMode": false, "iconsDir": "/opt/buttontask/icons", "webPort": 8080, "brightness": 60 },
"buttons": [
{
"id": "cleaning-call",
"label": "Вызов клининга",
"iconPath": "cleaner1Crop.png",
"action": { "type": "http_get", "url": "http://192.168.1.55", "headers": {}, "body": "" },
"action": { "type": "http_get", "url": "http://192.168.30.147:80/api/click/btn?bid=01*04*01", "headers": {}, "body": "" },
"latchReset": { "enabled": true, "resetUrl": "http://192.168.30.147:80/api/click/btn?bid=01*04*00", "successMatch": "OK" },
"feedback": { "successText": "Вызов отправлен", "errorText": "Ошибка", "pendingText": "...", "fadeMs": 5000 },
"trigger": { "mode": "hold", "holdMs": 800 },
"color": "#7b007b"
Binary file not shown.
@@ -0,0 +1,66 @@
{
"version": 2,
"layout": {
"mode": "list",
"columns": 2,
"spacing": 10,
"showLabels": true,
"labelColor": "#ffffff"
},
"background": {
"type": "solid",
"color1": "#000000",
"color2": "#7b007b",
"animated": true,
"imagePath": ""
},
"feedback": {
"okColor": "#00cc44",
"errorColor": "#cc2200",
"okWidth": 6,
"errorWidth": 6,
"glowRadius": 20,
"holdColor": "#ffffff",
"pendingColor": "#ffffff",
"pendingWidth": 6
},
"settings": {
"darkMode": true,
"password": "admin_26",
"kioskMode": false,
"iconsDir": "/opt/buttontask/icons",
"webPort": 8080,
"brightness": 60
},
"buttons": [
{
"id": "cleaning-call",
"label": "Вызов клининга",
"iconPath": "cleaner1Crop.png",
"action": {
"type": "http_get",
"url": "http://192.168.30.147:80/api/click/btn?bid=01*04*01",
"headers": {},
"body": "",
"timeoutMs": 15000
},
"latchReset": {
"enabled": true,
"resetUrl": "http://192.168.30.147:80/api/click/btn?bid=01*04*00",
"successMatch": "OK",
"fireAndForget": true
},
"feedback": {
"successText": "Вызов отправлен",
"errorText": "Ошибка",
"pendingText": "...",
"fadeMs": 5000
},
"trigger": {
"mode": "hold",
"holdMs": 800
},
"color": "#000000"
}
]
}
@@ -81,6 +81,41 @@ def _validate_response_check(data):
}
def _validate_latch_reset(data):
if data is None:
return None
if not isinstance(data, dict):
abort(400, "latchReset must be an object")
enabled = bool(data.get("enabled", False))
if not enabled:
return {"enabled": False}
reset_url = (data.get("resetUrl") or "").strip()
if not reset_url:
abort(400, "latchReset.resetUrl is required when enabled")
success_match = (data.get("successMatch") or "OK").strip() or "OK"
result = {
"enabled": True,
"resetUrl": reset_url,
"successMatch": success_match,
}
if data.get("fireAndForget"):
result["fireAndForget"] = True
return result
def _apply_button_latch_reset(btn, data):
if "latchReset" not in data:
return btn
lr = _validate_latch_reset(data.get("latchReset"))
if lr is None:
btn.pop("latchReset", None)
else:
btn["latchReset"] = lr
return btn
def _apply_button_action(action, data):
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
@@ -88,6 +123,17 @@ def _apply_button_action(action, data):
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
if "timeoutMs" in data:
try:
timeout_ms = int(data["timeoutMs"])
except (TypeError, ValueError):
abort(400, "timeoutMs must be an integer")
if timeout_ms < 1000 or timeout_ms > 600000:
abort(400, "timeoutMs must be between 1000 and 600000")
if timeout_ms == 7000:
action.pop("timeoutMs", None)
else:
action["timeoutMs"] = timeout_ms
if "responseCheck" in data:
rc = _validate_response_check(data.get("responseCheck"))
if rc is None:
@@ -148,6 +194,7 @@ def api_add_button():
},
"color": data.get("color", "#7b007b"),
}
_apply_button_latch_reset(btn, data)
cfg.setdefault("buttons", []).append(btn)
save_config(cfg)
return jsonify(btn)
@@ -180,6 +227,7 @@ def api_update_button(bid):
trigger.setdefault("holdMs", 800)
if "color" in data:
b["color"] = data["color"]
_apply_button_latch_reset(b, data)
save_config(cfg)
return jsonify(b)
abort(404)
@@ -2,7 +2,6 @@
Description=ButtonTask network bringup
After=NetworkManager.service
Wants=NetworkManager.service
Before=buttontask-web.service buttontask.service
[Service]
Type=oneshot
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Web Configurator
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Qt5 UI
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
@@ -50,10 +50,11 @@ def _defaults():
},
"settings": {
"darkMode": True,
"password": "admin",
"password": "admin_26",
"kioskMode": False,
"iconsDir": "./icons",
"webPort": 8080,
"brightness": 60,
},
"buttons": [],
}
@@ -8,6 +8,10 @@
#
# Intended to be invoked through sudo by the (unprivileged) web configurator.
# Do NOT use ip/ifconfig directly — NetworkManager will undo manual changes.
#
# Environment (optional):
# BUTTONTASK_NET_BOOT=1 boot path: succeed even if activation times out
# BUTTONTASK_NET_ACTIVATE_TIMEOUT=N seconds to wait for nmcli connection up (default 30)
set -euo pipefail
err() { echo "bt-netconfig: $*" >&2; exit 1; }
@@ -17,6 +21,8 @@ err() { echo "bt-netconfig: $*" >&2; exit 1; }
IFACE="$1"
MODE="$2"
PROFILE="buttontask-$IFACE"
BOOT="${BUTTONTASK_NET_BOOT:-0}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-30}"
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
@@ -99,8 +105,25 @@ esac
nmcli device disconnect "$IFACE" 2>/dev/null || true
nmcli connection down "$CON" 2>/dev/null || true
if ! nmcli connection up "$CON" ifname "$IFACE" 2>/dev/null; then
nmcli connection up "$CON" >/dev/null
activate_connection() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
&& return 0
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" >/dev/null 2>&1 \
&& return 0
return 1
fi
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" >/dev/null
}
if ! activate_connection "$CON" "$IFACE"; then
if [ "$BOOT" = "1" ]; then
echo "bt-netconfig: activation timed out (boot); profile saved, NM will retry" >&2
exit 0
fi
err "connection activation failed or timed out after ${ACTIVATE_TIMEOUT}s"
fi
# Brief settle, then report what NM actually applied.
@@ -2,17 +2,21 @@
#
# bt-netconfig-boot - re-apply saved network settings after reboot.
# Runs as root from buttontask-network.service once NetworkManager is up.
# Does not block UI startup: short waits, bounded activation, best-effort apply.
set -uo pipefail
ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}"
CFG="$ROOT/config/network.json"
SCRIPT="$ROOT/scripts/bt-netconfig"
NM_WAIT_SEC="${BUTTONTASK_NET_NM_WAIT:-15}"
IFACE_WAIT_SEC="${BUTTONTASK_NET_IFACE_WAIT:-10}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-10}"
log() { echo "bt-netconfig-boot: $*"; }
wait_for_iface() {
local iface="$1" i
for i in $(seq 1 45); do
for i in $(seq 1 "$IFACE_WAIT_SEC"); do
ip link show "$iface" &>/dev/null && return 0
sleep 1
done
@@ -21,19 +25,33 @@ wait_for_iface() {
wait_for_nm() {
local i
for i in $(seq 1 30); do
for i in $(seq 1 "$NM_WAIT_SEC"); do
nmcli general status &>/dev/null && return 0
sleep 1
done
return 1
}
activate_profile() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" 2>/dev/null \
|| true
else
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
fi
}
apply_saved() {
[ -f "$CFG" ] || { log "no saved config at $CFG"; return 0; }
[ -x "$SCRIPT" ] || { log "bt-netconfig missing: $SCRIPT"; return 1; }
BUTTONTASK_NET_BOOT=1 \
BUTTONTASK_NET_ACTIVATE_TIMEOUT="$ACTIVATE_TIMEOUT" \
python3 - "$CFG" "$SCRIPT" <<'PY'
import json, subprocess, sys
import json, os, subprocess, sys
cfg_path, script = sys.argv[1], sys.argv[2]
with open(cfg_path, encoding="utf-8") as f:
@@ -50,7 +68,9 @@ if mode == "static":
cfg.get("gateway", ""),
cfg.get("dns", ""),
]
raise SystemExit(subprocess.run(args).returncode)
env = os.environ.copy()
env.setdefault("BUTTONTASK_NET_BOOT", "1")
raise SystemExit(subprocess.run(args, env=env).returncode)
PY
}
@@ -63,13 +83,12 @@ wait_for_nm || log "NetworkManager not ready, continuing anyway"
wait_for_iface "$IFACE" || log "interface $IFACE not found, continuing anyway"
if apply_saved; then
log "network ready on $IFACE"
log "network profile applied on $IFACE (activation may continue in background)"
else
log "saved config apply failed, trying buttontask profiles"
while IFS= read -r con; do
[ -z "$con" ] && continue
iface="${con#buttontask-}"
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
activate_profile "$con" "$iface"
done < <(nmcli -t -f NAME connection show 2>/dev/null | grep '^buttontask-' || true)
fi
@@ -266,6 +266,45 @@ function syncResponseCheckUi() {
if (adv) adv.classList.toggle("show", !!on);
}
function fillLatchResetForm(lr) {
const latchEl = $("#b-latch-enabled");
if (!latchEl) return;
const enabled = lr ? !!lr.enabled : true;
latchEl.checked = enabled;
$("#b-reset-url").value = lr?.resetUrl || "";
$("#b-success-match").value = lr?.successMatch || "OK";
const ffEl = $("#b-fire-and-forget");
if (ffEl) ffEl.checked = !!lr?.fireAndForget;
syncLatchUi();
}
function readLatchResetFromForm() {
if (!$("#b-latch-enabled")?.checked) {
return { enabled: false };
}
return {
enabled: true,
resetUrl: $("#b-reset-url").value.trim(),
successMatch: ($("#b-success-match").value.trim() || "OK"),
fireAndForget: !!$("#b-fire-and-forget")?.checked,
};
}
function syncLatchUi() {
const latchOn = $("#b-latch-enabled")?.checked;
const fields = $("#b-latch-fields");
const rcBlock = $("#b-response-check-block");
const ffHint = document.querySelector(".latch-fire-hint");
if (fields) fields.classList.toggle("show", !!latchOn);
if (rcBlock) rcBlock.style.display = latchOn ? "none" : "";
if (ffHint) {
ffHint.style.display = latchOn && $("#b-fire-and-forget")?.checked ? "" : "none";
}
}
$("#b-latch-enabled")?.addEventListener("change", syncLatchUi);
$("#b-fire-and-forget")?.addEventListener("change", syncLatchUi);
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
@@ -284,14 +323,17 @@ function openDialogFor(btn) {
$("#b-icon").value = btn?.iconPath || "";
$("#b-type").value = btn?.action?.type || "http_get";
$("#b-url").value = btn?.action?.url || "";
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? 7000;
$("#b-success").value = btn?.feedback?.successText || "";
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
$("#b-pending").value = btn?.feedback?.pendingText ?? "...";
$("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800;
fillLatchResetForm(btn?.latchReset);
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow();
syncLatchUi();
dlg.showModal();
}
@@ -322,18 +364,21 @@ form?.addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const id = $("#b-id").value;
const latchReset = readLatchResetFromForm();
const payload = {
label: $("#b-label").value,
iconPath: $("#b-icon").value,
actionType: $("#b-type").value,
url: $("#b-url").value,
timeoutMs: parseInt($("#b-action-timeout").value, 10) || 7000,
successText: $("#b-success").value,
errorText: $("#b-error").value,
pendingText: $("#b-pending").value || "...",
color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
latchReset,
responseCheck: latchReset.enabled ? { enabled: false } : readResponseCheckFromForm(),
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
@@ -397,6 +397,9 @@ dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; }
.response-check-block { margin-top: 6px; }
.latch-reset-block { margin-top: 8px; }
.latch-reset-fields { display: none; margin-top: 8px; gap: 8px; flex-direction: column; }
.latch-reset-fields.show { display: flex; }
.response-check-advanced { display: none; margin-top: 10px; gap: 10px; flex-direction: column; }
.response-check-advanced.show { display: flex; }
.response-rules-head {
@@ -411,7 +411,7 @@
<p class="hint">Используется и для входа в веб-конфигуратор, и для настроек в приложении.</p>
<div class="row">
<label>Новый пароль
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
<input type="text" id="new-password" placeholder="Оставьте пустым чтобы не менять" autocomplete="off">
</label>
<button type="button" id="save-password" class="primary">Сохранить пароль</button>
</div>
@@ -440,6 +440,28 @@
</select>
</label>
<label>URL <input type="text" id="b-url" required></label>
<label>Ожидание ответа, мс
<input type="number" id="b-action-timeout" min="1000" max="600000" step="500" value="7000">
</label>
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. По умолчанию 7000 мс (7 с). При «Мгновенном отклике» влияет только на фоновый запрос и лог.</p>
<div class="latch-reset-block" id="b-latch-block">
<label class="check">
<input type="checkbox" id="b-latch-enabled" checked>
Сброс повторным нажатием
</label>
<p class="hint">Первое нажатие — вызов по URL выше. Успех, если в ответе есть указанный текст или HTTP &lt; 400. Зелёный статус держится до повторного нажатия (URL сброса).</p>
<div id="b-latch-fields" class="latch-reset-fields">
<label>URL сброса <input type="text" id="b-reset-url" placeholder="http://host/api/click/btn?bid=01*04*00"></label>
<label>Успех если в ответе <input type="text" id="b-success-match" value="OK"></label>
<label class="check">
<input type="checkbox" id="b-fire-and-forget">
Мгновенный отклик
</label>
<p class="hint latch-fire-hint">Статус обновляется сразу после нажатия; команда на сервер уходит параллельно.</p>
</div>
</div>
<label>Текст успеха <input type="text" id="b-success"></label>
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
<label>Текст ожидания <input type="text" id="b-pending" value="..."></label>