Show live camera refresh status
This commit is contained in:
@@ -1500,10 +1500,66 @@ class CameraControlBridge:
|
|||||||
self.inherit_action_runtime_state(old_actions, new_actions)
|
self.inherit_action_runtime_state(old_actions, new_actions)
|
||||||
self.actions = new_actions
|
self.actions = new_actions
|
||||||
self.refresh_snapshots()
|
self.refresh_snapshots()
|
||||||
|
old_ids = set(old_actions)
|
||||||
|
new_ids = set(new_actions)
|
||||||
|
old_cameras = self.camera_keys_from_actions(old_actions.values())
|
||||||
|
new_cameras = self.camera_keys_from_actions(new_actions.values())
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"added_actions": len(new_ids - old_ids),
|
||||||
|
"removed_actions": len(old_ids - new_ids),
|
||||||
|
"added_cameras": len(new_cameras - old_cameras),
|
||||||
|
"removed_cameras": len(old_cameras - new_cameras),
|
||||||
|
"total_actions": len(new_ids),
|
||||||
|
"total_cameras": len(new_cameras),
|
||||||
|
"mqtt_published": True,
|
||||||
|
}
|
||||||
|
result["changed"] = any(
|
||||||
|
result[key] > 0
|
||||||
|
for key in ("added_actions", "removed_actions", "added_cameras", "removed_cameras")
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self.publish_discovery()
|
self.publish_discovery()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
result["mqtt_published"] = False
|
||||||
LOG.warning("Frigate refresh could not publish MQTT discovery: %s", exc)
|
LOG.warning("Frigate refresh could not publish MQTT discovery: %s", exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def camera_keys_from_actions(actions):
|
||||||
|
keys = set()
|
||||||
|
for action in actions:
|
||||||
|
if action.kind == "all_off":
|
||||||
|
continue
|
||||||
|
snapshot = action.cfg.get("snapshot") or {}
|
||||||
|
camera = snapshot.get("camera") or action.cfg.get("camera_name")
|
||||||
|
if camera:
|
||||||
|
keys.add((snapshot.get("frigate_url"), camera))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def refresh_message(lang, result):
|
||||||
|
if result.get("added_cameras") or result.get("removed_cameras"):
|
||||||
|
return text_for(
|
||||||
|
lang,
|
||||||
|
"refresh.cameras_changed",
|
||||||
|
added=result.get("added_cameras", 0),
|
||||||
|
removed=result.get("removed_cameras", 0),
|
||||||
|
)
|
||||||
|
if result.get("added_actions") or result.get("removed_actions"):
|
||||||
|
return text_for(
|
||||||
|
lang,
|
||||||
|
"refresh.controls_changed",
|
||||||
|
added=result.get("added_actions", 0),
|
||||||
|
removed=result.get("removed_actions", 0),
|
||||||
|
)
|
||||||
|
return text_for(lang, "refresh.no_changes")
|
||||||
|
|
||||||
|
def refresh_payload(self, lang, result, redirect_url):
|
||||||
|
payload = dict(result)
|
||||||
|
payload["message"] = self.refresh_message(lang, result)
|
||||||
|
payload["redirect_url"] = redirect_url
|
||||||
|
return payload
|
||||||
|
|
||||||
def frigate_refresh_loop(self):
|
def frigate_refresh_loop(self):
|
||||||
interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300))
|
interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300))
|
||||||
@@ -1661,17 +1717,33 @@ class CameraControlBridge:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
if parsed.path == "/refresh":
|
if parsed.path == "/refresh":
|
||||||
|
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
|
||||||
|
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
||||||
|
redirect_url = f"/?lang={urllib.parse.quote(lang)}{token_query}"
|
||||||
|
wants_json = "application/json" in self.headers.get("Accept", "").lower()
|
||||||
try:
|
try:
|
||||||
bridge.refresh_frigate_inventory()
|
result = bridge.refresh_frigate_inventory()
|
||||||
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
|
if wants_json:
|
||||||
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
self.send_json(bridge.refresh_payload(lang, result, redirect_url))
|
||||||
self.send_response(303)
|
else:
|
||||||
self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}")
|
self.send_response(303)
|
||||||
self.end_headers()
|
self.send_header("Location", redirect_url)
|
||||||
|
self.end_headers()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_response(500)
|
if wants_json:
|
||||||
self.end_headers()
|
self.send_json(
|
||||||
self.wfile.write(str(exc).encode("utf-8"))
|
{
|
||||||
|
"ok": False,
|
||||||
|
"changed": False,
|
||||||
|
"message": text_for(lang, "refresh.failed", error=str(exc)),
|
||||||
|
"redirect_url": redirect_url,
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(str(exc).encode("utf-8"))
|
||||||
return
|
return
|
||||||
parts = parsed.path.strip("/").split("/")
|
parts = parsed.path.strip("/").split("/")
|
||||||
if len(parts) == 3 and parts[0] == "action":
|
if len(parts) == 3 and parts[0] == "action":
|
||||||
@@ -1692,9 +1764,9 @@ class CameraControlBridge:
|
|||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def send_json(self, payload):
|
def send_json(self, payload, status=200):
|
||||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||||
self.send_response(200)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(data)))
|
self.send_header("Content-Length", str(len(data)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -113,6 +113,36 @@ h1 {
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refresh-button.is-refreshing svg {
|
||||||
|
animation: refresh-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-status {
|
||||||
|
display: none;
|
||||||
|
max-width: 260px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-status.is-visible {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-status.is-success {
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-status.is-error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes refresh-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.language-form {
|
.language-form {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -558,6 +588,10 @@ h3 {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refresh-status {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-single,
|
.grid-single,
|
||||||
.control-grid {
|
.control-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -22,9 +22,94 @@ function updateTimers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshStatusElement() {
|
||||||
|
return document.querySelector("[data-refresh-status]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRefreshMessage(message, type = "success") {
|
||||||
|
const status = refreshStatusElement();
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = message || "";
|
||||||
|
status.classList.toggle("is-visible", Boolean(message));
|
||||||
|
status.classList.toggle("is-success", type === "success");
|
||||||
|
status.classList.toggle("is-error", type === "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRefreshMessage(message) {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem("frigateRefreshMessage", message);
|
||||||
|
} catch (_error) {
|
||||||
|
// Some browsers may block sessionStorage; the refresh still succeeds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreRefreshMessage() {
|
||||||
|
try {
|
||||||
|
const message = window.sessionStorage.getItem("frigateRefreshMessage");
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.sessionStorage.removeItem("frigateRefreshMessage");
|
||||||
|
showRefreshMessage(message, "success");
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage errors and keep the GUI usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshSubmit(form, event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = form.querySelector(".refresh-button");
|
||||||
|
if (button && button.classList.contains("is-refreshing")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = refreshStatusElement();
|
||||||
|
const workingLabel = status?.dataset.workingLabel || "Refreshing cameras...";
|
||||||
|
showRefreshMessage(workingLabel, "success");
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add("is-refreshing");
|
||||||
|
button.setAttribute("aria-busy", "true");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || payload.ok === false) {
|
||||||
|
throw new Error(payload.message || "Refresh failed");
|
||||||
|
}
|
||||||
|
showRefreshMessage(payload.message, "success");
|
||||||
|
if (payload.changed && payload.redirect_url) {
|
||||||
|
rememberRefreshMessage(payload.message);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.assign(payload.redirect_url);
|
||||||
|
}, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showRefreshMessage(error.message || "Refresh failed", "error");
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.classList.remove("is-refreshing");
|
||||||
|
button.removeAttribute("aria-busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("submit", (event) => {
|
document.addEventListener("submit", (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
if (!(form instanceof HTMLFormElement) || form.dataset.requiresAck !== "true") {
|
if (!(form instanceof HTMLFormElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.classList.contains("refresh-form")) {
|
||||||
|
handleRefreshSubmit(form, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.dataset.requiresAck !== "true") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = form.dataset.ackMessage || "Confirm this action";
|
const message = form.dataset.ackMessage || "Confirm this action";
|
||||||
@@ -34,4 +119,5 @@ document.addEventListener("submit", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateTimers();
|
updateTimers();
|
||||||
|
restoreRefreshMessage();
|
||||||
setInterval(updateTimers, 1000);
|
setInterval(updateTimers, 1000);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<span class="refresh-status" data-refresh-status data-working-label="{refresh_working_label}" role="status" aria-live="polite"></span>
|
||||||
<form method="get" action="/" class="language-form">
|
<form method="get" action="/" class="language-form">
|
||||||
<label for="lang">{language_label}</label>
|
<label for="lang">{language_label}</label>
|
||||||
{token_input}
|
{token_input}
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ def render_page(actions, lang, token_param=None):
|
|||||||
actions_label=html.escape(text_for(lang, "cameras")),
|
actions_label=html.escape(text_for(lang, "cameras")),
|
||||||
language_label=html.escape(text_for(lang, "language")),
|
language_label=html.escape(text_for(lang, "language")),
|
||||||
refresh_label=html.escape(text_for(lang, "button.refresh_cameras")),
|
refresh_label=html.escape(text_for(lang, "button.refresh_cameras")),
|
||||||
|
refresh_working_label=html.escape(text_for(lang, "refresh.working")),
|
||||||
language_options=render_language_options(lang),
|
language_options=render_language_options(lang),
|
||||||
token_input=token_input,
|
token_input=token_input,
|
||||||
token_query=html.escape(token_query),
|
token_query=html.escape(token_query),
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ msgstr "PRESS"
|
|||||||
msgid "button.refresh_cameras"
|
msgid "button.refresh_cameras"
|
||||||
msgstr "Refresh cameras"
|
msgstr "Refresh cameras"
|
||||||
|
|
||||||
|
msgid "refresh.working"
|
||||||
|
msgstr "Refreshing cameras..."
|
||||||
|
|
||||||
|
msgid "refresh.no_changes"
|
||||||
|
msgstr "No camera changes found."
|
||||||
|
|
||||||
|
msgid "refresh.cameras_changed"
|
||||||
|
msgstr "Cameras updated: +{added}, -{removed}."
|
||||||
|
|
||||||
|
msgid "refresh.controls_changed"
|
||||||
|
msgstr "Camera controls updated: +{added}, -{removed}."
|
||||||
|
|
||||||
|
msgid "refresh.failed"
|
||||||
|
msgstr "Camera refresh failed: {error}"
|
||||||
|
|
||||||
msgid "action.all_off"
|
msgid "action.all_off"
|
||||||
msgstr "{target} all off"
|
msgstr "{target} all off"
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ msgstr "URUCHOM"
|
|||||||
msgid "button.refresh_cameras"
|
msgid "button.refresh_cameras"
|
||||||
msgstr "Odswiez kamery"
|
msgstr "Odswiez kamery"
|
||||||
|
|
||||||
|
msgid "refresh.working"
|
||||||
|
msgstr "Odswiezanie kamer..."
|
||||||
|
|
||||||
|
msgid "refresh.no_changes"
|
||||||
|
msgstr "Nie wykryto zmian w kamerach."
|
||||||
|
|
||||||
|
msgid "refresh.cameras_changed"
|
||||||
|
msgstr "Kamery zaktualizowane: +{added}, -{removed}."
|
||||||
|
|
||||||
|
msgid "refresh.controls_changed"
|
||||||
|
msgstr "Kontrolki kamer zaktualizowane: +{added}, -{removed}."
|
||||||
|
|
||||||
|
msgid "refresh.failed"
|
||||||
|
msgstr "Odswiezanie kamer nie powiodlo sie: {error}"
|
||||||
|
|
||||||
msgid "action.all_off"
|
msgid "action.all_off"
|
||||||
msgstr "{target} wylacz wszystko"
|
msgstr "{target} wylacz wszystko"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate_camera_control_bridge.app import CameraControlBridge
|
||||||
|
|
||||||
|
|
||||||
|
class DummyAction:
|
||||||
|
def __init__(self, action_id, camera=None, kind="hikvision_colorvu"):
|
||||||
|
self.id = action_id
|
||||||
|
self.kind = kind
|
||||||
|
self.cfg = {}
|
||||||
|
if camera:
|
||||||
|
self.cfg["snapshot"] = {
|
||||||
|
"frigate_url": "http://frigate.example.test:5000",
|
||||||
|
"camera": camera,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshSummaryTest(unittest.TestCase):
|
||||||
|
def test_camera_keys_ignore_global_actions(self):
|
||||||
|
keys = CameraControlBridge.camera_keys_from_actions(
|
||||||
|
[
|
||||||
|
DummyAction("one_light", "one"),
|
||||||
|
DummyAction("all_off", kind="all_off"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(keys, {("http://frigate.example.test:5000", "one")})
|
||||||
|
|
||||||
|
def test_refresh_message_reports_camera_changes_first(self):
|
||||||
|
message = CameraControlBridge.refresh_message(
|
||||||
|
"en",
|
||||||
|
{
|
||||||
|
"added_cameras": 1,
|
||||||
|
"removed_cameras": 0,
|
||||||
|
"added_actions": 2,
|
||||||
|
"removed_actions": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message, "Cameras updated: +1, -0.")
|
||||||
|
|
||||||
|
def test_refresh_message_reports_no_changes(self):
|
||||||
|
message = CameraControlBridge.refresh_message(
|
||||||
|
"en",
|
||||||
|
{
|
||||||
|
"added_cameras": 0,
|
||||||
|
"removed_cameras": 0,
|
||||||
|
"added_actions": 0,
|
||||||
|
"removed_actions": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message, "No camera changes found.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -69,6 +69,8 @@ class UiRenderTest(unittest.TestCase):
|
|||||||
self.assertIn('action="/refresh?lang=en&token=secret"', html)
|
self.assertIn('action="/refresh?lang=en&token=secret"', html)
|
||||||
self.assertIn('title="Refresh cameras"', html)
|
self.assertIn('title="Refresh cameras"', html)
|
||||||
self.assertIn('aria-label="Refresh cameras"', html)
|
self.assertIn('aria-label="Refresh cameras"', html)
|
||||||
|
self.assertIn('data-refresh-status', html)
|
||||||
|
self.assertIn('data-working-label="Refreshing cameras..."', html)
|
||||||
self.assertIn("<svg", html)
|
self.assertIn("<svg", html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user