added backlight and fixed outbound

This commit is contained in:
2026-07-21 09:17:48 +03:00
parent ff2b8a250e
commit 5392273794
37 changed files with 1560 additions and 76 deletions
+9 -1
View File
@@ -185,7 +185,15 @@ def api_set_settings():
st = cfg.setdefault("settings", {})
for k in ("darkMode", "password", "kioskMode", "iconsDir", "webPort"):
if k in data:
st[k] = data[k]
val = data[k]
if k == "webPort":
try:
val = int(val)
except (TypeError, ValueError):
abort(400, "invalid web port")
if val < 1024 or val > 65535:
abort(400, "web port must be 102465535")
st[k] = val
save_config(cfg)
return jsonify({"ok": True})
+4 -1
View File
@@ -18,7 +18,7 @@ from api import api_bp
from auth import auth_bp, load_or_create_secret, login_required
from config_store import list_icons, load_config
from network import network_bp
from system_info import record_request, system_bp
from system_info import apply_saved_brightness, record_request, system_bp
from updater import current_version, updater_bp
@@ -38,6 +38,9 @@ def create_app():
app.register_blueprint(updater_bp)
app.register_blueprint(system_bp)
# Restore LCD brightness from config (best-effort; needs bt-brightness + sudoers).
apply_saved_brightness()
@app.before_request
def _request_start():
g._req_start = time.time()
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# bt-brightness - LCD backlight control for ButtonTask.
#
# Usage:
# bt-brightness status
# bt-brightness set <0-100>
#
# Invoked through sudo by the web configurator / Qt UI.
# Prefers /sys/class/backlight/backlight; otherwise the first device with
# max_brightness > 0.
set -euo pipefail
err() { echo "bt-brightness: $*" >&2; exit 1; }
BACKLIGHT_ROOT="/sys/class/backlight"
find_device() {
local d preferred="$BACKLIGHT_ROOT/backlight" max
if [ -d "$preferred" ] && [ -f "$preferred/max_brightness" ]; then
max="$(cat "$preferred/max_brightness" 2>/dev/null || echo 0)"
if [ "$max" -gt 0 ] 2>/dev/null; then
echo "$preferred"
return 0
fi
fi
for d in "$BACKLIGHT_ROOT"/*; do
[ -d "$d" ] || continue
[ -f "$d/max_brightness" ] || continue
max="$(cat "$d/max_brightness" 2>/dev/null || echo 0)"
if [ "$max" -gt 0 ] 2>/dev/null; then
echo "$d"
return 0
fi
done
return 1
}
emit_status() {
local dev="$1" max cur percent
max="$(cat "$dev/max_brightness")"
if [ -f "$dev/actual_brightness" ]; then
cur="$(cat "$dev/actual_brightness")"
else
cur="$(cat "$dev/brightness")"
fi
if [ "$max" -gt 0 ]; then
percent=$(( (cur * 100 + max / 2) / max ))
else
percent=0
fi
[ "$percent" -gt 100 ] && percent=100
[ "$percent" -lt 0 ] && percent=0
printf '{"device":"%s","brightness":%s,"max":%s,"percent":%s}\n' \
"$(basename "$dev")" "$cur" "$max" "$percent"
}
set_percent() {
local percent="$1" dev max value
[[ "$percent" =~ ^[0-9]+$ ]] || err "percent must be an integer 0-100"
[ "$percent" -ge 0 ] && [ "$percent" -le 100 ] || err "percent must be 0-100"
dev="$(find_device)" || err "no backlight device found"
max="$(cat "$dev/max_brightness")"
[ "$max" -gt 0 ] || err "max_brightness is 0"
value=$(( (percent * max + 50) / 100 ))
[ "$value" -lt 0 ] && value=0
[ "$value" -gt "$max" ] && value="$max"
# Avoid completely blank panel unless explicitly requested as 0.
printf '%s\n' "$value" > "$dev/brightness"
emit_status "$dev"
}
cmd_status() {
local dev
dev="$(find_device)" || err "no backlight device found"
emit_status "$dev"
}
case "${1:-}" in
status) cmd_status ;;
set) [ "$#" -eq 2 ] || err "usage: bt-brightness set <0-100>"; set_percent "$2" ;;
*) err "usage: bt-brightness {status|set <0-100>}" ;;
esac
@@ -101,6 +101,37 @@ switch_to() {
ln -sfn "$1" "$CURRENT"
}
# Install privileged helpers + sudoers from the staged package so OTA can
# deliver new bt-* scripts (e.g. bt-brightness) without a full redeploy.
sync_helpers() {
local dest="$1"
local src="$dest/webconfig/scripts"
local s name tmp_sudoers
[ -d "$src" ] || { log "sync_helpers: no $src"; return 0; }
mkdir -p "$ROOT/scripts"
for s in "$src"/bt-*; do
[ -f "$s" ] || continue
name="$(basename "$s")"
install -m 0755 "$s" "$ROOT/scripts/$name"
chown root:root "$ROOT/scripts/$name" 2>/dev/null || true
sed -i 's/\r$//' "$ROOT/scripts/$name" 2>/dev/null || true
log "synced helper $name"
done
if [ -f "$src/buttontask.sudoers" ]; then
tmp_sudoers="$(mktemp)"
cp "$src/buttontask.sudoers" "$tmp_sudoers"
sed -i 's/\r$//' "$tmp_sudoers" 2>/dev/null || true
chmod 0440 "$tmp_sudoers"
if visudo -cf "$tmp_sudoers" >/dev/null 2>&1; then
install -m 0440 "$tmp_sudoers" /etc/sudoers.d/buttontask
log "synced sudoers"
else
log "sudoers validation failed — kept previous /etc/sudoers.d/buttontask"
fi
rm -f "$tmp_sudoers"
fi
}
cmd_stage() {
local pkg="$1"
[ -f "$pkg" ] || { write_status error "package not found"; exit 1; }
@@ -138,6 +169,9 @@ cmd_stage() {
chmod -R u=rwX,go=rX "$dest" 2>/dev/null || true
rm -f "$pkg"
write_status helpers "установка хелперов"
sync_helpers "$dest"
write_status switching "$ver"
switch_to "$dest"
restart_services
@@ -167,6 +201,14 @@ cmd_rollback() {
write_status rollback_done "откат выполнен"
}
cmd_sync_helpers() {
local dest
dest="$(readlink -f "$CURRENT" 2>/dev/null || true)"
[ -n "$dest" ] && [ -d "$dest" ] || { echo "no current version" >&2; exit 1; }
sync_helpers "$dest"
echo "helpers synced from $dest"
}
cmd_remove() {
local name="$1"
[[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid version name" >&2; exit 1; }
@@ -184,8 +226,9 @@ cmd_remove() {
}
case "${1:-}" in
stage) [ "$#" -ge 2 ] || { echo "usage: bt-update stage <pkg>"; exit 2; }; cmd_stage "$2" ;;
rollback) cmd_rollback ;;
remove) [ "$#" -ge 2 ] || { echo "usage: bt-update remove <name>"; exit 2; }; cmd_remove "$2" ;;
*) echo "usage: bt-update {stage <pkg>|rollback|remove <name>}"; exit 2 ;;
stage) [ "$#" -ge 2 ] || { echo "usage: bt-update stage <pkg>"; exit 2; }; cmd_stage "$2" ;;
rollback) cmd_rollback ;;
sync-helpers) cmd_sync_helpers ;;
remove) [ "$#" -ge 2 ] || { echo "usage: bt-update remove <name>"; exit 2; }; cmd_remove "$2" ;;
*) echo "usage: bt-update {stage <pkg>|rollback|sync-helpers|remove <name>}"; exit 2 ;;
esac
@@ -9,6 +9,7 @@
# buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-settime
# buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-reboot
# buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-access
# buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-brightness
# EOF
# sudo chmod 0440 /etc/sudoers.d/buttontask
# sudo visudo -cf /etc/sudoers.d/buttontask # validate
@@ -23,3 +24,4 @@ buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-service
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-settime
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-reboot
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-access
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-brightness
+185 -11
View File
@@ -52,6 +52,7 @@ $("#save-layout")?.addEventListener("click", async () => {
columns: parseInt($("#layout-columns").value, 10),
spacing: parseInt($("#layout-spacing").value, 10),
showLabels: $("#layout-show-labels").checked,
labelColor: $("#layout-label-color").value,
});
flash("Расположение сохранено");
} catch (e) { flash(e.message, true); }
@@ -84,21 +85,35 @@ $("#save-bg")?.addEventListener("click", async () => {
// ---------- Feedback (borders) ----------
function syncFeedbackPreview() {
const ok = $("#fb-preview-ok"), err = $("#fb-preview-error");
const hold = $("#fb-preview-hold"), pending = $("#fb-preview-pending");
if (!ok) return;
const okColor = $("#fb-ok-color").value;
const errColor = $("#fb-error-color").value;
const holdColor = $("#fb-hold-color").value;
const pendingColor = $("#fb-pending-color").value;
const okW = $("#fb-ok-width").value;
const errW = $("#fb-error-width").value;
const pendingW = $("#fb-pending-width").value;
const glow = $("#fb-glow").value;
$("#fb-ok-width-val").textContent = okW;
$("#fb-error-width-val").textContent = errW;
$("#fb-pending-width-val").textContent = pendingW;
$("#fb-glow-val").textContent = glow;
ok.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${okColor}`;
ok.style.border = `${okW}px solid ${okColor}`;
err.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${errColor}`;
err.style.border = `${errW}px solid ${errColor}`;
if (hold) {
hold.style.boxShadow = `0 0 ${Math.max(8, glow * 0.5)}px ${Math.round(glow/3)}px ${holdColor}`;
hold.style.border = `3px solid ${holdColor}`;
}
if (pending) {
pending.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${pendingColor}`;
pending.style.border = `${pendingW}px solid ${pendingColor}`;
}
}
["fb-ok-color", "fb-error-color", "fb-ok-width", "fb-error-width", "fb-glow"]
["fb-ok-color", "fb-error-color", "fb-hold-color", "fb-pending-color",
"fb-ok-width", "fb-error-width", "fb-pending-width", "fb-glow"]
.forEach(id => $("#" + id)?.addEventListener("input", syncFeedbackPreview));
syncFeedbackPreview();
@@ -107,8 +122,11 @@ $("#save-feedback")?.addEventListener("click", async () => {
await api("PUT", "/api/feedback", {
okColor: $("#fb-ok-color").value,
errorColor: $("#fb-error-color").value,
holdColor: $("#fb-hold-color").value,
pendingColor: $("#fb-pending-color").value,
okWidth: parseInt($("#fb-ok-width").value, 10),
errorWidth: parseInt($("#fb-error-width").value, 10),
pendingWidth: parseInt($("#fb-pending-width").value, 10),
glowRadius: parseInt($("#fb-glow").value, 10),
});
flash("Обводка сохранена");
@@ -137,11 +155,15 @@ $$(".icon-tile .del").forEach(btn => {
// ---------- Password ----------
$("#save-password")?.addEventListener("click", async () => {
const pwd = $("#new-password").value;
const pwd = $("#new-password").value.trim();
if (!pwd) { flash("Введите пароль", true); return; }
await api("PUT", "/api/settings", { password: pwd });
flash("Пароль сохранён. Действует со следующего входа.");
$("#new-password").value = "";
try {
await api("PUT", "/api/settings", { password: pwd });
flash("Пароль сохранён. Действует со следующего входа.");
$("#new-password").value = "";
} catch (e) {
flash(e.message || "Не удалось сохранить пароль", true);
}
});
// ---------- Buttons drag-drop ----------
@@ -162,6 +184,93 @@ if (list && window.Sortable) {
const dlg = $("#btn-dialog");
const form = $("#btn-form");
function defaultResponseCheck() {
return {
enabled: false,
requireHttpSuccess: true,
defaultResult: "error",
rules: [],
poll: { intervalMs: 3000, maxAttempts: 20, timeoutMs: 120000 },
};
}
function createRuleRow(rule = {}) {
const row = document.createElement("div");
row.className = "response-rule-row";
row.innerHTML = `
<input type="text" class="rc-field" placeholder="поле (success)" value="${rule.field || ""}">
<select class="rc-op">
<option value="equals">равно</option>
<option value="contains">содержит</option>
</select>
<input type="text" class="rc-value" placeholder="значение" value="${rule.equals ?? rule.contains ?? ""}">
<select class="rc-result">
<option value="error">Ошибка</option>
<option value="ok">Принято</option>
<option value="pending">Ожидание</option>
</select>
<input type="text" class="rc-message" placeholder="сообщение (опц.)" value="${rule.message || ""}">
<button type="button" class="ghost rc-del" title="Удалить">×</button>
`;
row.querySelector(".rc-op").value = rule.contains != null && rule.contains !== "" ? "contains" : "equals";
row.querySelector(".rc-result").value = rule.result || "error";
row.querySelector(".rc-del").addEventListener("click", () => row.remove());
return row;
}
function fillResponseCheckForm(rc) {
const cfg = rc || defaultResponseCheck();
$("#b-rc-enabled").checked = !!cfg.enabled;
$("#b-rc-require-http").checked = cfg.requireHttpSuccess !== false;
$("#b-rc-default").value = cfg.defaultResult || "error";
$("#b-rc-poll-interval").value = cfg.poll?.intervalMs ?? 3000;
$("#b-rc-poll-attempts").value = cfg.poll?.maxAttempts ?? 20;
$("#b-rc-poll-timeout").value = cfg.poll?.timeoutMs ?? 120000;
const wrap = $("#b-rc-rules");
wrap.innerHTML = "";
(cfg.rules || []).forEach(rule => wrap.appendChild(createRuleRow(rule)));
syncResponseCheckUi();
}
function readResponseCheckFromForm() {
if (!$("#b-rc-enabled").checked) {
return { enabled: false };
}
const rules = Array.from($$("#b-rc-rules .response-rule-row")).map(row => {
const field = row.querySelector(".rc-field").value.trim();
const op = row.querySelector(".rc-op").value;
const value = row.querySelector(".rc-value").value;
const result = row.querySelector(".rc-result").value;
const message = row.querySelector(".rc-message").value.trim();
const rule = { field, result };
rule[op] = value;
if (message) rule.message = message;
return rule;
}).filter(r => r.field);
return {
enabled: true,
requireHttpSuccess: $("#b-rc-require-http").checked,
defaultResult: $("#b-rc-default").value,
rules,
poll: {
intervalMs: parseInt($("#b-rc-poll-interval").value, 10) || 3000,
maxAttempts: parseInt($("#b-rc-poll-attempts").value, 10) || 20,
timeoutMs: parseInt($("#b-rc-poll-timeout").value, 10) || 120000,
},
};
}
function syncResponseCheckUi() {
const on = $("#b-rc-enabled")?.checked;
const adv = $("#b-rc-advanced");
if (adv) adv.classList.toggle("show", !!on);
}
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
});
function syncHoldRow() {
const mode = $("#b-trigger-mode").value;
$("#b-hold-row").style.display = mode === "click" ? "none" : "";
@@ -181,6 +290,7 @@ function openDialogFor(btn) {
$("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800;
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow();
dlg.showModal();
}
@@ -223,6 +333,7 @@ form?.addEventListener("submit", async (e) => {
color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
@@ -325,17 +436,22 @@ function renderButtonLog(entries) {
return;
}
body.innerHTML = entries.map(e => {
const ok = e.ok;
const result = ok
? (e.http ? `OK ${e.http}` : "OK") + (e.message ? `${e.message}` : "")
: (e.http ? `ERR ${e.http}` : "ERR") + (e.message ? `${e.message}` : "");
const outcome = e.outcome || (e.ok ? "ok" : "error");
const ok = outcome === "ok";
const pending = outcome === "pending";
const http = e.http ? ` ${e.http}` : "";
const attempt = e.attempt > 1 ? ` [${e.attempt}]` : "";
const result = (ok ? "OK" : pending ? "WAIT" : "ERR") + http
+ attempt
+ (e.message ? `${e.message}` : "");
const cls = ok ? "log-ok" : pending ? "log-wait" : "log-err";
return `
<tr>
<td>${e.ts || "—"}</td>
<td>${e.label || "—"}</td>
<td>${e.method || "—"}</td>
<td class="log-path" title="${e.url || ""}">${e.url || "—"}</td>
<td class="${ok ? "log-ok" : "log-err"}">${result}</td>
<td class="${cls}">${result}</td>
<td>${e.ms != null ? e.ms : "—"}</td>
</tr>`;
}).join("");
@@ -370,6 +486,34 @@ async function loadAccess() {
}
}
function renderBrightness(s) {
const el = $("#brightness-status");
const slider = $("#brightness-slider");
const val = $("#brightness-val");
if (!el) return;
if (!s || s.ok === false) {
el.innerHTML = `<p class="warn">${(s && s.error) || "подсветка недоступна"}</p>`;
return;
}
const pct = s.percent != null ? s.percent : 100;
el.textContent = `Сейчас: ${pct}%` +
(s.device ? ` (${s.device}: ${s.brightness}/${s.max})` : "") +
(s.saved != null ? ` · сохранено: ${s.saved}%` : "");
if (slider && !slider.dataset.touched) {
slider.value = pct;
if (val) val.textContent = pct + "%";
}
}
async function loadBrightness() {
try {
const s = await api("GET", "/api/system/brightness");
renderBrightness(s);
} catch (e) {
renderBrightness({ ok: false, error: e.message });
}
}
async function setAccess(action, value) {
const r = await api("POST", "/api/system/access", { action, value });
if (action !== "webPort") renderAccessStatus(r);
@@ -389,6 +533,7 @@ async function loadSystem() {
dtInput.value = stats.clock.replace(" ", "T").slice(0, 16);
}
loadAccess();
loadBrightness();
} catch (e) {
const el = $("#sys-stats");
if (el) el.textContent = e.message;
@@ -410,6 +555,31 @@ $("#web-port")?.addEventListener("input", (e) => {
e.target.dataset.touched = "1";
});
$("#brightness-slider")?.addEventListener("input", (e) => {
e.target.dataset.touched = "1";
const val = $("#brightness-val");
if (val) val.textContent = e.target.value + "%";
});
$("#brightness-refresh")?.addEventListener("click", () => {
const slider = $("#brightness-slider");
if (slider) delete slider.dataset.touched;
loadBrightness();
});
$("#brightness-save")?.addEventListener("click", async () => {
const percent = parseInt($("#brightness-slider")?.value, 10);
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
flash("Некорректная яркость", true);
return;
}
try {
const s = await api("POST", "/api/system/brightness", { percent });
const slider = $("#brightness-slider");
if (slider) delete slider.dataset.touched;
renderBrightness(s);
flash(`Яркость: ${s.percent}%`);
} catch (e) { flash(e.message, true); }
});
$("#sys-set-time")?.addEventListener("click", async () => {
const raw = $("#sys-datetime")?.value;
if (!raw) { flash("Укажите дату и время", true); return; }
@@ -456,9 +626,13 @@ $("#save-ssh-port")?.addEventListener("click", async () => {
flash("Порт SSH обновлён");
} catch (e) { flash(e.message, true); }
});
const MIN_USER_PORT = 1024;
$("#save-web-port")?.addEventListener("click", async () => {
const port = parseInt($("#web-port").value, 10);
if (!port || port < 1 || port > 65535) { flash("Некорректный веб-порт", true); return; }
if (!port || port < MIN_USER_PORT || port > 65535) {
flash(`Веб-порт должен быть от ${MIN_USER_PORT} до 65535 (1–1023 зарезервированы в Linux)`, true);
return;
}
if (!confirm(`Сменить веб-порт на ${port}? Веб-сервис перезапустится.`)) return;
try {
await setAccess("webPort", port);
@@ -200,13 +200,16 @@ def access_status():
return status
MIN_USER_PORT = 1024
def set_web_port(port):
try:
port = int(port)
except (TypeError, ValueError):
raise ValueError("invalid web port")
if port < 1 or port > 65535:
raise ValueError("invalid web port")
if port < MIN_USER_PORT or port > 65535:
raise ValueError(f"web port must be {MIN_USER_PORT}65535")
cfg = load_config()
cfg.setdefault("settings", {})["webPort"] = port
save_config(cfg)
@@ -219,6 +222,72 @@ def set_web_port(port):
return port
def _run_brightness(*args, timeout=10):
script = str(scripts_dir() / "bt-brightness")
proc = subprocess.run(
["sudo", "-n", script, *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
for line in reversed((proc.stdout or "").splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
return json.loads(line)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "bt-brightness failed").strip()
raise RuntimeError(err)
raise RuntimeError((proc.stdout or "bt-brightness returned no status").strip())
def brightness_status():
live = _run_brightness("status")
cfg = load_config()
saved = cfg.get("settings", {}).get("brightness")
try:
saved = int(saved) if saved is not None else None
except (TypeError, ValueError):
saved = None
live["saved"] = saved
live["ok"] = True
return live
def set_brightness(percent):
try:
percent = int(percent)
except (TypeError, ValueError):
raise ValueError("percent must be an integer 0-100")
if percent < 0 or percent > 100:
raise ValueError("percent must be 0-100")
live = _run_brightness("set", str(percent))
cfg = load_config()
cfg.setdefault("settings", {})["brightness"] = percent
save_config(cfg)
live["saved"] = percent
live["ok"] = True
return live
def apply_saved_brightness():
"""Restore brightness from config at service start. No-op if unset."""
cfg = load_config()
saved = cfg.get("settings", {}).get("brightness")
if saved is None:
return None
try:
percent = int(saved)
except (TypeError, ValueError):
return None
if percent < 0 or percent > 100:
return None
try:
return _run_brightness("set", str(percent))
except Exception:
return None
@system_bp.route("/api/system/stats", methods=["GET"])
@login_required
def api_system_stats():
@@ -261,6 +330,27 @@ def api_access_set():
return jsonify({"ok": False, "error": str(e)}), 500
@system_bp.route("/api/system/brightness", methods=["GET"])
@login_required
def api_brightness_get():
try:
return jsonify(brightness_status())
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@system_bp.route("/api/system/brightness", methods=["POST"])
@login_required
def api_brightness_set():
data = request.get_json(force=True, silent=True) or {}
try:
return jsonify(set_brightness(data.get("percent")))
except ValueError as e:
abort(400, str(e))
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@system_bp.route("/api/system/time", methods=["GET"])
@login_required
def api_system_time_get():
@@ -80,6 +80,9 @@
<input type="checkbox" id="layout-show-labels" {% if config.layout.showLabels %}checked{% endif %}>
Показывать подписи
</label>
<label>Цвет подписей
<input type="color" id="layout-label-color" value="{{ config.layout.labelColor|default('#ffffff', true) }}">
</label>
</div>
<div class="card-actions">
<button id="save-layout" class="primary">Сохранить расположение</button>
@@ -124,32 +127,85 @@
<hr class="section-divider">
<h3 class="subhead">Обводка при нажатии</h3>
<p class="hint">Цвет и ширина свечения вокруг кнопки при успехе и ошибке.</p>
<p class="hint">Цвет и ширина свечения вокруг кнопки при успехе, ошибке, удержании и ожидании ответа.</p>
<div class="feedback-panel">
<div class="grid-form feedback-form">
<label>Цвет «ОК»
<input type="color" id="fb-ok-color" value="{{ config.feedback.okColor }}">
</label>
<label>Ширина «ОК»: <span id="fb-ok-width-val">{{ config.feedback.okWidth }}</span> px
<input type="range" id="fb-ok-width" min="0" max="30" value="{{ config.feedback.okWidth }}">
</label>
<label>Цвет «Ошибка»
<input type="color" id="fb-error-color" value="{{ config.feedback.errorColor }}">
</label>
<label>Ширина «Ошибка»: <span id="fb-error-width-val">{{ config.feedback.errorWidth }}</span> px
<input type="range" id="fb-error-width" min="0" max="30" value="{{ config.feedback.errorWidth }}">
</label>
<label class="span-2">Радиус свечения: <span id="fb-glow-val">{{ config.feedback.glowRadius }}</span> px
<div class="feedback-section">
<h4 class="feedback-section-title">Общее</h4>
<label class="label-range">
<span class="label-caption">Радиус свечения: <span id="fb-glow-val">{{ config.feedback.glowRadius }}</span> px</span>
<input type="range" id="fb-glow" min="0" max="60" value="{{ config.feedback.glowRadius }}">
</label>
</div>
<div class="feedback-preview">
<div class="preview-label">Превью</div>
<div class="preview-row">
<div class="preview-dot" id="fb-preview-ok">OK</div>
<div class="preview-dot" id="fb-preview-error">ERR</div>
<div class="feedback-section">
<h4 class="feedback-section-title">Успех</h4>
<div class="feedback-section-grid">
<div class="grid-form feedback-section-form">
<label>Цвет
<input type="color" id="fb-ok-color" value="{{ config.feedback.okColor }}">
</label>
<label class="label-range">
<span class="label-caption">Ширина: <span id="fb-ok-width-val">{{ config.feedback.okWidth }}</span> px</span>
<input type="range" id="fb-ok-width" min="0" max="30" value="{{ config.feedback.okWidth }}">
</label>
</div>
<div class="feedback-section-preview">
<div class="preview-dot" id="fb-preview-ok">OK</div>
</div>
</div>
</div>
<div class="feedback-section">
<h4 class="feedback-section-title">Ошибка</h4>
<div class="feedback-section-grid">
<div class="grid-form feedback-section-form">
<label>Цвет
<input type="color" id="fb-error-color" value="{{ config.feedback.errorColor }}">
</label>
<label class="label-range">
<span class="label-caption">Ширина: <span id="fb-error-width-val">{{ config.feedback.errorWidth }}</span> px</span>
<input type="range" id="fb-error-width" min="0" max="30" value="{{ config.feedback.errorWidth }}">
</label>
</div>
<div class="feedback-section-preview">
<div class="preview-dot" id="fb-preview-error">ERR</div>
</div>
</div>
</div>
<div class="feedback-section">
<h4 class="feedback-section-title">Удержание</h4>
<div class="feedback-section-grid">
<div class="grid-form feedback-section-form">
<label>Цвет кольца прогресса
<input type="color" id="fb-hold-color" value="{{ config.feedback.holdColor|default('#ffffff', true) }}">
</label>
</div>
<div class="feedback-section-preview">
<div class="preview-dot" id="fb-preview-hold">HOLD</div>
</div>
</div>
</div>
<div class="feedback-section">
<h4 class="feedback-section-title">Ожидание</h4>
<div class="feedback-section-grid">
<div class="grid-form feedback-section-form">
<label>Цвет
<input type="color" id="fb-pending-color" value="{{ config.feedback.pendingColor|default('#ffffff', true) }}">
</label>
<label class="label-range">
<span class="label-caption">Ширина: <span id="fb-pending-width-val">{{ config.feedback.pendingWidth|default(6, true) }}</span> px</span>
<input type="range" id="fb-pending-width" min="0" max="30" value="{{ config.feedback.pendingWidth|default(6, true) }}">
</label>
</div>
<div class="feedback-section-preview">
<div class="preview-dot" id="fb-preview-pending"></div>
</div>
</div>
</div>
</div>
<div class="card-actions compact">
<button id="save-feedback" class="primary">Сохранить обводку</button>
@@ -218,6 +274,22 @@
<div id="sys-stats" class="sys-stats">Загрузка…</div>
</div>
<div class="card">
<div class="card-head">
<h2>Яркость экрана</h2>
<button id="brightness-refresh" class="ghost">↻ Обновить</button>
</div>
<p class="hint">Подсветка LCD. Снижение яркости уменьшает нагрев. Значение сохраняется и применяется при старте.</p>
<div id="brightness-status" class="sys-clock">Загрузка…</div>
<div class="row">
<label style="flex:1">Яркость
<input type="range" id="brightness-slider" min="1" max="100" value="100">
</label>
<span id="brightness-val">100%</span>
<button id="brightness-save" class="primary">Применить</button>
</div>
</div>
<div class="card">
<h2>Дата и время</h2>
<p class="hint">Без сети NTP не работает — задайте время вручную. Сохраняется в системные часы и RTC (если есть).</p>
@@ -262,9 +334,10 @@
</label>
<label>Порт веб-конфигуратора
<div class="row">
<input type="number" id="web-port" min="1" max="65535" value="8080">
<input type="number" id="web-port" min="1024" max="65535" value="8080">
<button id="save-web-port">Сохранить и перезапустить веб</button>
</div>
<span class="hint">Порты 1–1023 зарезервированы в Linux и недоступны без root.</span>
</label>
</div>
</div>
@@ -340,7 +413,7 @@
<label>Новый пароль
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
</label>
<button id="save-password" class="primary">Сохранить пароль</button>
<button type="button" id="save-password" class="primary">Сохранить пароль</button>
</div>
</div>
</section>
@@ -380,6 +453,45 @@
<label id="b-hold-row">Длительность удержания: <span id="b-hold-val">800</span> мс
<input type="range" id="b-hold" min="200" max="3000" step="50" value="800">
</label>
<div class="response-check-block" id="b-response-check-block">
<label class="check">
<input type="checkbox" id="b-rc-enabled">
Расширенная проверка ответа (JSON)
</label>
<div id="b-rc-advanced" class="response-check-advanced">
<p class="hint">Пример ответа: <code>{"success":"ok"}</code>. Без этого блока проверяется только HTTP-код. В колонке результата правила выберите <strong>Ошибка</strong> / <strong>Ожидание</strong> / <strong>Принято</strong> — не оставляйте «Принято» для условий ошибки.</p>
<label class="check">
<input type="checkbox" id="b-rc-require-http" checked>
Требовать HTTP &lt; 400
</label>
<label>Если ни одно правило не подошло
<select id="b-rc-default">
<option value="error">Ошибка</option>
<option value="ok">Принято</option>
<option value="pending">Ожидание</option>
</select>
</label>
<div class="response-rules-head">
<strong>Правила</strong>
<button type="button" id="b-rc-add-rule" class="ghost">+ Правило</button>
</div>
<div id="b-rc-rules"></div>
<h4 class="subhead">Polling при «Ожидание»</h4>
<div class="grid-form response-check-poll">
<label>Интервал, мс
<input type="number" id="b-rc-poll-interval" min="500" max="60000" value="3000">
</label>
<label>Макс. попыток
<input type="number" id="b-rc-poll-attempts" min="1" max="100" value="20">
</label>
<label>Общий таймаут, мс
<input type="number" id="b-rc-poll-timeout" min="1000" max="600000" value="120000">
</label>
</div>
</div>
</div>
<menu>
<button value="cancel">Отмена</button>
<button id="b-save" value="ok" type="submit" class="primary">Сохранить</button>