This commit is contained in:
2026-08-24 16:40:48 +03:00
parent f7e99516bf
commit 32abb8bf0a
73 changed files with 12364 additions and 9292 deletions
+4 -1
View File
@@ -91,12 +91,15 @@ PNG/JPG/SVG/GIF из `.\software\` и `.\software\icons\` попадут в
1. `dpkg -i debs/*.deb` офлайн (два прохода), `ldconfig`.
2. Создаёт пользователя `buttontask`, группы `video render input`.
3. Раскладывает `/opt/buttontask/{versions/<ver>,config,icons,run,scripts}`,
симлинк `current`. Существующий `config.json` не трогает.
симлинк `current`. Существующий `config.json` **по умолчанию не трогает**
(upgrade-safe). Для полного сброса: `BUTTONTASK_RESET_CONFIG=1` или
`deploy-adb.ps1 -ResetConfig`, либо галочка в sshDeploy GUI.
4. Хелперы `bt-*` + `sudoers` (+`visudo -c`).
5. `/tmp/runtime-buttontask` через `tmpfiles.d` (переживает ребут).
6. Авто-детект тачскрина → `override.conf` с нужным `eventN`.
7. Выключает `display-manager` (eglfs забирает KMS/DRM).
8. `systemctl enable buttontask buttontask-web buttontask-network`; старт — после ребута.
UI (`buttontask`) не ждёт поднятия IP — сеть настраивается параллельно с коротким таймаутом.
9. Если в бандле есть `config/.master` — кладёт аварийный мастер-пароль (web + Qt).
Идемпотентен: повторный прогон безопасен.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -2
View File
@@ -6,7 +6,8 @@
# Сценарий: подключил по USB -> нашлось adb-устройство -> отработал -> ребут -> работает.
param(
[string]$Bundle,
[string]$Adb
[string]$Adb,
[switch]$ResetConfig
)
# native-команды adb пишут в stderr (adb root, прогресс push); Stop делает это фатальным
$ErrorActionPreference = 'Continue'
@@ -67,7 +68,13 @@ Log 'заливаю бандл...'
# 5. установка, ловим INSTALL-OK
Log 'разворачиваю на устройстве...'
$cmd = ("$sudo bash /tmp/btdeploy/device-install.sh").Trim()
if ($ResetConfig) {
Log 'полный сброс config.json и .master из бандла'
if ($sudo) { $cmd = "$sudo env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh" }
else { $cmd = 'env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh' }
} else {
$cmd = ("$sudo bash /tmp/btdeploy/device-install.sh").Trim()
}
$output = & $Adb shell $cmd 2>&1 | Out-String
Write-Host ($output -replace "`r", '')
if ($output -notmatch 'INSTALL-OK') { Die 'установка завершилась без INSTALL-OK (см. вывод выше)' }
+20 -4
View File
@@ -53,8 +53,20 @@ install -m0755 "$BUNDLE_DIR/app/ButtonTask" "$ROOT/versions/$VER/ButtonTask"
cp -a "$BUNDLE_DIR/webconfig" "$ROOT/versions/$VER/"
ln -sfn "$ROOT/versions/$VER" "$ROOT/current"
# config — не перезатираем существующий (apgrade-safe)
if [ -f "$ROOT/config/config.json" ]; then
# config — по умолчанию не перезатираем (upgrade-safe); BUTTONTASK_RESET_CONFIG=1 — из бандла
reset_cfg="${BUTTONTASK_RESET_CONFIG:-0}"
case "$reset_cfg" in
1|yes|true|TRUE|Yes|YES) reset_cfg=1 ;;
*) reset_cfg=0 ;;
esac
if [ "$reset_cfg" = "1" ]; then
if [ -f "$BUNDLE_DIR/config/config.json" ]; then
cp -a "$BUNDLE_DIR/config/config.json" "$ROOT/config/"
log "config.json перезаписан из бандла (полный сброс конфига)"
else
warn "BUTTONTASK_RESET_CONFIG=1, но config.json в бандле нет"
fi
elif [ -f "$ROOT/config/config.json" ]; then
log "config.json уже есть — оставляю как есть"
elif [ -f "$BUNDLE_DIR/config/config.json" ]; then
cp -a "$BUNDLE_DIR/config/config.json" "$ROOT/config/"
@@ -111,7 +123,11 @@ chown -R "$USER_NAME:$USER_NAME" "$ROOT"
chown root:root "$ROOT"/scripts/bt-*
# мастер-пароль (аварийный вход web + Qt) — один раз при первой установке
if [ -f "$ROOT/config/.master" ]; then
if [ "$reset_cfg" = "1" ] && [ -f "$BUNDLE_DIR/config/.master" ]; then
install -m0600 -o "$USER_NAME" -g "$USER_NAME" \
"$BUNDLE_DIR/config/.master" "$ROOT/config/.master"
log "мастер-пароль перезаписан из бандла (полный сброс конфига)"
elif [ -f "$ROOT/config/.master" ]; then
log "config/.master уже есть — оставляю как есть"
elif [ -f "$BUNDLE_DIR/config/.master" ]; then
install -m0600 -o "$USER_NAME" -g "$USER_NAME" \
@@ -168,7 +184,7 @@ systemctl enable buttontask buttontask-web buttontask-network >/dev/null 2>&1 ||
# применить сеть сразу (не ждать ребута), если есть сохранённый профиль
if [ -f "$ROOT/config/network.json" ] && [ -x "$ROOT/scripts/bt-netconfig-boot" ]; then
if "$ROOT/scripts/bt-netconfig-boot"; then
if BUTTONTASK_NET_ACTIVATE_TIMEOUT=30 "$ROOT/scripts/bt-netconfig-boot"; then
log "сеть применена из network.json"
else
warn "не удалось применить network.json — проверьте nmcli и кабель"
+3 -2
View File
@@ -93,13 +93,14 @@ else {
"layout": { "mode": "grid", "columns": 2, "spacing": 10, "showLabels": true },
"background": { "type": "gradient", "color1": "#00007b", "color2": "#7b007b", "animated": true, "imagePath": "" },
"feedback": { "okColor": "#00cc44", "errorColor": "#cc2200", "okWidth": 6, "errorWidth": 6, "glowRadius": 20 },
"settings": { "darkMode": true, "password": "admin", "kioskMode": false, "iconsDir": "/opt/buttontask/icons", "webPort": 8080 },
"settings": { "darkMode": true, "password": "admin_26", "kioskMode": false, "iconsDir": "/opt/buttontask/icons", "webPort": 8080, "brightness": 60 },
"buttons": [
{
"id": "cleaning-call",
"label": "Вызов клининга",
"iconPath": "cleaner1Crop.png",
"action": { "type": "http_get", "url": "http://192.168.1.55", "headers": {}, "body": "" },
"action": { "type": "http_get", "url": "http://192.168.30.147:80/api/click/btn?bid=01*04*01", "headers": {}, "body": "" },
"latchReset": { "enabled": true, "resetUrl": "http://192.168.30.147:80/api/click/btn?bid=01*04*00", "successMatch": "OK" },
"feedback": { "successText": "Вызов отправлен", "errorText": "Ошибка", "pendingText": "...", "fadeMs": 5000 },
"trigger": { "mode": "hold", "holdMs": 800 },
"color": "#7b007b"
Binary file not shown.
@@ -0,0 +1,66 @@
{
"version": 2,
"layout": {
"mode": "list",
"columns": 2,
"spacing": 10,
"showLabels": true,
"labelColor": "#ffffff"
},
"background": {
"type": "solid",
"color1": "#000000",
"color2": "#7b007b",
"animated": true,
"imagePath": ""
},
"feedback": {
"okColor": "#00cc44",
"errorColor": "#cc2200",
"okWidth": 6,
"errorWidth": 6,
"glowRadius": 20,
"holdColor": "#ffffff",
"pendingColor": "#ffffff",
"pendingWidth": 6
},
"settings": {
"darkMode": true,
"password": "admin_26",
"kioskMode": false,
"iconsDir": "/opt/buttontask/icons",
"webPort": 8080,
"brightness": 60
},
"buttons": [
{
"id": "cleaning-call",
"label": "Вызов клининга",
"iconPath": "cleaner1Crop.png",
"action": {
"type": "http_get",
"url": "http://192.168.30.147:80/api/click/btn?bid=01*04*01",
"headers": {},
"body": "",
"timeoutMs": 15000
},
"latchReset": {
"enabled": true,
"resetUrl": "http://192.168.30.147:80/api/click/btn?bid=01*04*00",
"successMatch": "OK",
"fireAndForget": true
},
"feedback": {
"successText": "Вызов отправлен",
"errorText": "Ошибка",
"pendingText": "...",
"fadeMs": 5000
},
"trigger": {
"mode": "hold",
"holdMs": 800
},
"color": "#000000"
}
]
}
@@ -81,6 +81,41 @@ def _validate_response_check(data):
}
def _validate_latch_reset(data):
if data is None:
return None
if not isinstance(data, dict):
abort(400, "latchReset must be an object")
enabled = bool(data.get("enabled", False))
if not enabled:
return {"enabled": False}
reset_url = (data.get("resetUrl") or "").strip()
if not reset_url:
abort(400, "latchReset.resetUrl is required when enabled")
success_match = (data.get("successMatch") or "OK").strip() or "OK"
result = {
"enabled": True,
"resetUrl": reset_url,
"successMatch": success_match,
}
if data.get("fireAndForget"):
result["fireAndForget"] = True
return result
def _apply_button_latch_reset(btn, data):
if "latchReset" not in data:
return btn
lr = _validate_latch_reset(data.get("latchReset"))
if lr is None:
btn.pop("latchReset", None)
else:
btn["latchReset"] = lr
return btn
def _apply_button_action(action, data):
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
@@ -88,6 +123,17 @@ def _apply_button_action(action, data):
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
if "timeoutMs" in data:
try:
timeout_ms = int(data["timeoutMs"])
except (TypeError, ValueError):
abort(400, "timeoutMs must be an integer")
if timeout_ms < 1000 or timeout_ms > 600000:
abort(400, "timeoutMs must be between 1000 and 600000")
if timeout_ms == 7000:
action.pop("timeoutMs", None)
else:
action["timeoutMs"] = timeout_ms
if "responseCheck" in data:
rc = _validate_response_check(data.get("responseCheck"))
if rc is None:
@@ -148,6 +194,7 @@ def api_add_button():
},
"color": data.get("color", "#7b007b"),
}
_apply_button_latch_reset(btn, data)
cfg.setdefault("buttons", []).append(btn)
save_config(cfg)
return jsonify(btn)
@@ -180,6 +227,7 @@ def api_update_button(bid):
trigger.setdefault("holdMs", 800)
if "color" in data:
b["color"] = data["color"]
_apply_button_latch_reset(b, data)
save_config(cfg)
return jsonify(b)
abort(404)
@@ -2,7 +2,6 @@
Description=ButtonTask network bringup
After=NetworkManager.service
Wants=NetworkManager.service
Before=buttontask-web.service buttontask.service
[Service]
Type=oneshot
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Web Configurator
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
@@ -1,7 +1,7 @@
[Unit]
Description=ButtonTask Qt5 UI
After=buttontask-network.service
Wants=buttontask-network.service
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=simple
@@ -50,10 +50,11 @@ def _defaults():
},
"settings": {
"darkMode": True,
"password": "admin",
"password": "admin_26",
"kioskMode": False,
"iconsDir": "./icons",
"webPort": 8080,
"brightness": 60,
},
"buttons": [],
}
@@ -8,6 +8,10 @@
#
# Intended to be invoked through sudo by the (unprivileged) web configurator.
# Do NOT use ip/ifconfig directly — NetworkManager will undo manual changes.
#
# Environment (optional):
# BUTTONTASK_NET_BOOT=1 boot path: succeed even if activation times out
# BUTTONTASK_NET_ACTIVATE_TIMEOUT=N seconds to wait for nmcli connection up (default 30)
set -euo pipefail
err() { echo "bt-netconfig: $*" >&2; exit 1; }
@@ -17,6 +21,8 @@ err() { echo "bt-netconfig: $*" >&2; exit 1; }
IFACE="$1"
MODE="$2"
PROFILE="buttontask-$IFACE"
BOOT="${BUTTONTASK_NET_BOOT:-0}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-30}"
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
@@ -99,8 +105,25 @@ esac
nmcli device disconnect "$IFACE" 2>/dev/null || true
nmcli connection down "$CON" 2>/dev/null || true
if ! nmcli connection up "$CON" ifname "$IFACE" 2>/dev/null; then
nmcli connection up "$CON" >/dev/null
activate_connection() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
&& return 0
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" >/dev/null 2>&1 \
&& return 0
return 1
fi
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" >/dev/null
}
if ! activate_connection "$CON" "$IFACE"; then
if [ "$BOOT" = "1" ]; then
echo "bt-netconfig: activation timed out (boot); profile saved, NM will retry" >&2
exit 0
fi
err "connection activation failed or timed out after ${ACTIVATE_TIMEOUT}s"
fi
# Brief settle, then report what NM actually applied.
@@ -2,17 +2,21 @@
#
# bt-netconfig-boot - re-apply saved network settings after reboot.
# Runs as root from buttontask-network.service once NetworkManager is up.
# Does not block UI startup: short waits, bounded activation, best-effort apply.
set -uo pipefail
ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}"
CFG="$ROOT/config/network.json"
SCRIPT="$ROOT/scripts/bt-netconfig"
NM_WAIT_SEC="${BUTTONTASK_NET_NM_WAIT:-15}"
IFACE_WAIT_SEC="${BUTTONTASK_NET_IFACE_WAIT:-10}"
ACTIVATE_TIMEOUT="${BUTTONTASK_NET_ACTIVATE_TIMEOUT:-10}"
log() { echo "bt-netconfig-boot: $*"; }
wait_for_iface() {
local iface="$1" i
for i in $(seq 1 45); do
for i in $(seq 1 "$IFACE_WAIT_SEC"); do
ip link show "$iface" &>/dev/null && return 0
sleep 1
done
@@ -21,19 +25,33 @@ wait_for_iface() {
wait_for_nm() {
local i
for i in $(seq 1 30); do
for i in $(seq 1 "$NM_WAIT_SEC"); do
nmcli general status &>/dev/null && return 0
sleep 1
done
return 1
}
activate_profile() {
local con="$1" iface="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| timeout "$ACTIVATE_TIMEOUT" nmcli connection up "$con" 2>/dev/null \
|| true
else
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
fi
}
apply_saved() {
[ -f "$CFG" ] || { log "no saved config at $CFG"; return 0; }
[ -x "$SCRIPT" ] || { log "bt-netconfig missing: $SCRIPT"; return 1; }
BUTTONTASK_NET_BOOT=1 \
BUTTONTASK_NET_ACTIVATE_TIMEOUT="$ACTIVATE_TIMEOUT" \
python3 - "$CFG" "$SCRIPT" <<'PY'
import json, subprocess, sys
import json, os, subprocess, sys
cfg_path, script = sys.argv[1], sys.argv[2]
with open(cfg_path, encoding="utf-8") as f:
@@ -50,7 +68,9 @@ if mode == "static":
cfg.get("gateway", ""),
cfg.get("dns", ""),
]
raise SystemExit(subprocess.run(args).returncode)
env = os.environ.copy()
env.setdefault("BUTTONTASK_NET_BOOT", "1")
raise SystemExit(subprocess.run(args, env=env).returncode)
PY
}
@@ -63,13 +83,12 @@ wait_for_nm || log "NetworkManager not ready, continuing anyway"
wait_for_iface "$IFACE" || log "interface $IFACE not found, continuing anyway"
if apply_saved; then
log "network ready on $IFACE"
log "network profile applied on $IFACE (activation may continue in background)"
else
log "saved config apply failed, trying buttontask profiles"
while IFS= read -r con; do
[ -z "$con" ] && continue
iface="${con#buttontask-}"
nmcli connection up "$con" ifname "$iface" 2>/dev/null \
|| nmcli connection up "$con" 2>/dev/null || true
activate_profile "$con" "$iface"
done < <(nmcli -t -f NAME connection show 2>/dev/null | grep '^buttontask-' || true)
fi
@@ -266,6 +266,45 @@ function syncResponseCheckUi() {
if (adv) adv.classList.toggle("show", !!on);
}
function fillLatchResetForm(lr) {
const latchEl = $("#b-latch-enabled");
if (!latchEl) return;
const enabled = lr ? !!lr.enabled : true;
latchEl.checked = enabled;
$("#b-reset-url").value = lr?.resetUrl || "";
$("#b-success-match").value = lr?.successMatch || "OK";
const ffEl = $("#b-fire-and-forget");
if (ffEl) ffEl.checked = !!lr?.fireAndForget;
syncLatchUi();
}
function readLatchResetFromForm() {
if (!$("#b-latch-enabled")?.checked) {
return { enabled: false };
}
return {
enabled: true,
resetUrl: $("#b-reset-url").value.trim(),
successMatch: ($("#b-success-match").value.trim() || "OK"),
fireAndForget: !!$("#b-fire-and-forget")?.checked,
};
}
function syncLatchUi() {
const latchOn = $("#b-latch-enabled")?.checked;
const fields = $("#b-latch-fields");
const rcBlock = $("#b-response-check-block");
const ffHint = document.querySelector(".latch-fire-hint");
if (fields) fields.classList.toggle("show", !!latchOn);
if (rcBlock) rcBlock.style.display = latchOn ? "none" : "";
if (ffHint) {
ffHint.style.display = latchOn && $("#b-fire-and-forget")?.checked ? "" : "none";
}
}
$("#b-latch-enabled")?.addEventListener("change", syncLatchUi);
$("#b-fire-and-forget")?.addEventListener("change", syncLatchUi);
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
@@ -284,14 +323,17 @@ function openDialogFor(btn) {
$("#b-icon").value = btn?.iconPath || "";
$("#b-type").value = btn?.action?.type || "http_get";
$("#b-url").value = btn?.action?.url || "";
$("#b-action-timeout").value = btn?.action?.timeoutMs ?? 7000;
$("#b-success").value = btn?.feedback?.successText || "";
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
$("#b-pending").value = btn?.feedback?.pendingText ?? "...";
$("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800;
fillLatchResetForm(btn?.latchReset);
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow();
syncLatchUi();
dlg.showModal();
}
@@ -322,18 +364,21 @@ form?.addEventListener("submit", async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const id = $("#b-id").value;
const latchReset = readLatchResetFromForm();
const payload = {
label: $("#b-label").value,
iconPath: $("#b-icon").value,
actionType: $("#b-type").value,
url: $("#b-url").value,
timeoutMs: parseInt($("#b-action-timeout").value, 10) || 7000,
successText: $("#b-success").value,
errorText: $("#b-error").value,
pendingText: $("#b-pending").value || "...",
color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
latchReset,
responseCheck: latchReset.enabled ? { enabled: false } : readResponseCheckFromForm(),
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
@@ -397,6 +397,9 @@ dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; }
.response-check-block { margin-top: 6px; }
.latch-reset-block { margin-top: 8px; }
.latch-reset-fields { display: none; margin-top: 8px; gap: 8px; flex-direction: column; }
.latch-reset-fields.show { display: flex; }
.response-check-advanced { display: none; margin-top: 10px; gap: 10px; flex-direction: column; }
.response-check-advanced.show { display: flex; }
.response-rules-head {
@@ -411,7 +411,7 @@
<p class="hint">Используется и для входа в веб-конфигуратор, и для настроек в приложении.</p>
<div class="row">
<label>Новый пароль
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
<input type="text" id="new-password" placeholder="Оставьте пустым чтобы не менять" autocomplete="off">
</label>
<button type="button" id="save-password" class="primary">Сохранить пароль</button>
</div>
@@ -440,6 +440,28 @@
</select>
</label>
<label>URL <input type="text" id="b-url" required></label>
<label>Ожидание ответа, мс
<input type="number" id="b-action-timeout" min="1000" max="600000" step="500" value="7000">
</label>
<p class="hint b-action-timeout-hint">Таймаут одного HTTP-запроса. По умолчанию 7000 мс (7 с). При «Мгновенном отклике» влияет только на фоновый запрос и лог.</p>
<div class="latch-reset-block" id="b-latch-block">
<label class="check">
<input type="checkbox" id="b-latch-enabled" checked>
Сброс повторным нажатием
</label>
<p class="hint">Первое нажатие — вызов по URL выше. Успех, если в ответе есть указанный текст или HTTP &lt; 400. Зелёный статус держится до повторного нажатия (URL сброса).</p>
<div id="b-latch-fields" class="latch-reset-fields">
<label>URL сброса <input type="text" id="b-reset-url" placeholder="http://host/api/click/btn?bid=01*04*00"></label>
<label>Успех если в ответе <input type="text" id="b-success-match" value="OK"></label>
<label class="check">
<input type="checkbox" id="b-fire-and-forget">
Мгновенный отклик
</label>
<p class="hint latch-fire-hint">Статус обновляется сразу после нажатия; команда на сервер уходит параллельно.</p>
</div>
</div>
<label>Текст успеха <input type="text" id="b-success"></label>
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
<label>Текст ожидания <input type="text" id="b-pending" value="..."></label>