added backlight and fixed outbound
This commit is contained in:
@@ -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 1024–65535")
|
||||
st[k] = val
|
||||
save_config(cfg)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 < 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>
|
||||
|
||||
Reference in New Issue
Block a user