a
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -99,7 +99,6 @@ Dialog {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
placeholderText: "Новый пароль"
|
||||
echoMode: TextInput.Password
|
||||
onAccepted: changePwdDialog.accept()
|
||||
}
|
||||
Text {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -25,6 +25,10 @@ public:
|
||||
ErrorTextRole,
|
||||
PendingTextRole,
|
||||
ResponseCheckEnabledRole,
|
||||
LatchResetEnabledRole,
|
||||
LatchResetUrlRole,
|
||||
LatchSuccessMatchRole,
|
||||
LatchFireAndForgetRole,
|
||||
FadeMsRole,
|
||||
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
|
||||
StatusTextRole,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+2966
-2773
File diff suppressed because it is too large
Load Diff
+2962
-2769
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
Vendored
BIN
Binary file not shown.
+16
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
+46
-1
@@ -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 < 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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Идемпотентен: повторный прогон безопасен.
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 (см. вывод выше)' }
|
||||
|
||||
@@ -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 и кабель"
|
||||
|
||||
@@ -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 < 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>
|
||||
|
||||
Reference in New Issue
Block a user