Group GUI controls by camera
This commit is contained in:
@@ -103,10 +103,9 @@ select {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card {
|
.camera-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 388px;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -142,10 +141,16 @@ select {
|
|||||||
margin-top: 78px;
|
margin-top: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visual-badge {
|
.visual-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-badge {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 58px;
|
width: 58px;
|
||||||
height: 58px;
|
height: 58px;
|
||||||
@@ -161,14 +166,17 @@ select {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-empty .visual-badge {
|
.media-empty .visual-stack {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
transform: translate(-50%, -58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-empty .visual-badge {
|
||||||
width: 76px;
|
width: 76px;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
transform: translate(-50%, -58%);
|
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(255, 255, 255, 0.82);
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,7 @@ select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 15px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +221,54 @@ h2 {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.camera-count {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-head .visual-badge {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-head .visual-badge svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.action-id {
|
.action-id {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -244,15 +300,15 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip-state-on {
|
.chip-state-on {
|
||||||
border-color: #9dd8bd;
|
border-color: #f2b2aa;
|
||||||
background: #edfdf5;
|
background: var(--red-soft);
|
||||||
color: var(--green-dark);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-state-off {
|
.chip-state-off {
|
||||||
border-color: #f0b8b4;
|
border-color: #9dd8bd;
|
||||||
background: var(--red-soft);
|
background: #edfdf5;
|
||||||
color: var(--red);
|
color: var(--green-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-state-unknown {
|
.chip-state-unknown {
|
||||||
@@ -291,7 +347,6 @@ h2 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-form {
|
.command-form {
|
||||||
@@ -341,14 +396,64 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cmd-off {
|
.cmd-off {
|
||||||
border-color: #d7a5a1;
|
border-color: #9dd8bd;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: var(--red);
|
color: var(--green-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-off:hover {
|
.cmd-off:hover {
|
||||||
border-color: var(--red);
|
border-color: var(--green);
|
||||||
background: var(--red-soft);
|
background: #edfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-strip {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: min(100%, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
width: min(100%, 520px);
|
||||||
|
border: 1px solid #c9d2df;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-main .visual-badge {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-main .visual-badge svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-controls {
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -371,12 +476,21 @@ h2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-strip,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.language-form {
|
.language-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-strip,
|
||||||
|
.toolbar {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-action {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
max-width: 60%;
|
max-width: 60%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ function updateTimers() {
|
|||||||
}
|
}
|
||||||
const remaining = target - now;
|
const remaining = target - now;
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
item.textContent = item.dataset.expiredLabel || "Auto-off pending";
|
item.textContent = item.dataset.expiredLabel || "Automatic turn-off pending";
|
||||||
item.classList.add("timer-expired");
|
item.classList.add("timer-expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.textContent = `${item.dataset.prefix || "Auto-off"} ${formatRemaining(remaining)}`;
|
item.textContent = `${item.dataset.prefix || "Automatic turn-off in"} ${formatRemaining(remaining)}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div class="summary"><strong>{action_count}</strong><span>{actions_label}</span></div>
|
<div class="summary"><strong>{action_count}</strong><span>{actions_label}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{global_actions}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<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>
|
||||||
|
|||||||
@@ -98,12 +98,51 @@ def visual_markup(action, lang):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_card(action, lang, token_query):
|
def camera_group_key(action):
|
||||||
display_name = action.display_name(lang)
|
# Prefer the Frigate camera identifier because all actions discovered from
|
||||||
icon = visual_markup(action, lang)
|
# the same camera share it. Actions without a snapshot remain grouped by id.
|
||||||
|
snapshot = action.cfg.get("snapshot") or {}
|
||||||
|
return (
|
||||||
|
snapshot.get("frigate_url"),
|
||||||
|
snapshot.get("camera") or action.cfg.get("camera_name") or action.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def camera_group_name(action, lang):
|
||||||
|
return (
|
||||||
|
action.cfg.get("camera_name")
|
||||||
|
or (action.cfg.get("snapshot") or {}).get("camera")
|
||||||
|
or action.cfg.get("device", {}).get("name")
|
||||||
|
or action.display_name(lang)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def action_label(action, lang):
|
||||||
|
name_key = action.cfg.get("name_key")
|
||||||
|
short_labels = {
|
||||||
|
"action.siren": "visual.siren",
|
||||||
|
"action.red_blue": "visual.red_blue",
|
||||||
|
"action.manual_white": "visual.white_light",
|
||||||
|
"action.all_off": "visual.all_off",
|
||||||
|
}
|
||||||
|
if name_key in short_labels:
|
||||||
|
return text_for(lang, short_labels[name_key])
|
||||||
|
return action.display_name(lang)
|
||||||
|
|
||||||
|
|
||||||
|
def group_visual_markup(actions, lang):
|
||||||
|
return '<div class="visual-stack">' + "".join(
|
||||||
|
visual_markup(action, lang) for action in actions[:3]
|
||||||
|
) + "</div>"
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_markup_for_group(group, lang, token_query):
|
||||||
|
actions = group["actions"]
|
||||||
|
action = next((item for item in actions if item.cfg.get("snapshot")), actions[0])
|
||||||
|
icons = group_visual_markup(actions, lang)
|
||||||
snapshot_markup = (
|
snapshot_markup = (
|
||||||
'<div class="media media-empty">'
|
'<div class="media media-empty">'
|
||||||
f"{icon}"
|
f"{icons}"
|
||||||
f'<span>{html.escape(text_for(lang, "no_preview"))}</span>'
|
f'<span>{html.escape(text_for(lang, "no_preview"))}</span>'
|
||||||
"</div>"
|
"</div>"
|
||||||
)
|
)
|
||||||
@@ -115,31 +154,16 @@ def render_card(action, lang, token_query):
|
|||||||
snapshot_markup = (
|
snapshot_markup = (
|
||||||
'<div class="media">'
|
'<div class="media">'
|
||||||
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
|
f'<img class="snapshot" src="{html.escape(snapshot_url)}" '
|
||||||
f'alt="{html.escape(display_name)}" loading="lazy" '
|
f'alt="{html.escape(group["name"])}" loading="lazy" '
|
||||||
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
|
'onerror="this.parentElement.classList.add(\'media-empty\');this.remove()">'
|
||||||
f"{icon}"
|
f"{icons}"
|
||||||
"</div>"
|
"</div>"
|
||||||
)
|
)
|
||||||
|
return snapshot_markup
|
||||||
|
|
||||||
buttons = []
|
|
||||||
for command in action.command_list():
|
def render_action_control(action, lang, token_query):
|
||||||
label = text_for(lang, f"button.{command}")
|
display_name = action_label(action, lang)
|
||||||
action_url = (
|
|
||||||
f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}"
|
|
||||||
f"?lang={urllib.parse.quote(lang)}{token_query}"
|
|
||||||
)
|
|
||||||
ack_attrs = ""
|
|
||||||
if command == "on" and action.cfg.get("requires_ack"):
|
|
||||||
ack_attrs = (
|
|
||||||
' data-requires-ack="true"'
|
|
||||||
f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"'
|
|
||||||
)
|
|
||||||
buttons.append(
|
|
||||||
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
|
|
||||||
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
|
|
||||||
f"<span>{html.escape(label)}</span>"
|
|
||||||
"</button></form>"
|
|
||||||
)
|
|
||||||
|
|
||||||
state_value = (action.state or "unknown").lower()
|
state_value = (action.state or "unknown").lower()
|
||||||
state_label = text_for(lang, f"state.{state_value}")
|
state_label = text_for(lang, f"state.{state_value}")
|
||||||
@@ -165,12 +189,10 @@ def render_card(action, lang, token_query):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
'<article class="action-card">'
|
'<section class="action-control">'
|
||||||
f"{snapshot_markup}"
|
'<div class="control-head">'
|
||||||
'<div class="card-body">'
|
f"{visual_markup(action, lang)}"
|
||||||
'<div class="card-main">'
|
f"<h3>{html.escape(display_name)}</h3>"
|
||||||
f"<h2>{html.escape(display_name)}</h2>"
|
|
||||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
|
||||||
"</div>"
|
"</div>"
|
||||||
'<div class="meta-row">'
|
'<div class="meta-row">'
|
||||||
f'<span class="chip">{html.escape(platform_label)}</span>'
|
f'<span class="chip">{html.escape(platform_label)}</span>'
|
||||||
@@ -178,12 +200,65 @@ def render_card(action, lang, token_query):
|
|||||||
f"{html.escape(state_label)}</span>"
|
f"{html.escape(state_label)}</span>"
|
||||||
"</div>"
|
"</div>"
|
||||||
f"{timer_markup}"
|
f"{timer_markup}"
|
||||||
f'<div class="command-row">{"".join(buttons)}</div>'
|
f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>'
|
||||||
|
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_group_card(group, lang, token_query):
|
||||||
|
controls = "".join(render_action_control(action, lang, token_query) for action in group["actions"])
|
||||||
|
return (
|
||||||
|
'<article class="camera-card">'
|
||||||
|
f"{snapshot_markup_for_group(group, lang, token_query)}"
|
||||||
|
'<div class="card-body">'
|
||||||
|
'<div class="card-main">'
|
||||||
|
f"<h2>{html.escape(group['name'])}</h2>"
|
||||||
|
f'<span class="camera-count">{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}</span>'
|
||||||
"</div>"
|
"</div>"
|
||||||
|
f'<div class="control-stack">{controls}</div>'
|
||||||
"</article>"
|
"</article>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_global_action(action, lang, token_query):
|
||||||
|
return (
|
||||||
|
'<article class="global-action">'
|
||||||
|
'<div class="global-main">'
|
||||||
|
f"{visual_markup(action, lang)}"
|
||||||
|
'<div>'
|
||||||
|
f"<h2>{html.escape(action.display_name(lang))}</h2>"
|
||||||
|
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||||
|
"</div>"
|
||||||
|
"</div>"
|
||||||
|
f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>'
|
||||||
|
"</article>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_action_buttons(action, lang, token_query):
|
||||||
|
buttons = []
|
||||||
|
for command in action.command_list():
|
||||||
|
label = text_for(lang, f"button.{command}")
|
||||||
|
action_url = (
|
||||||
|
f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}"
|
||||||
|
f"?lang={urllib.parse.quote(lang)}{token_query}"
|
||||||
|
)
|
||||||
|
ack_attrs = ""
|
||||||
|
if command == "on" and action.cfg.get("requires_ack"):
|
||||||
|
ack_attrs = (
|
||||||
|
' data-requires-ack="true"'
|
||||||
|
f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"'
|
||||||
|
)
|
||||||
|
buttons.append(
|
||||||
|
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
|
||||||
|
f'<button class="cmd cmd-{html.escape(command)}" type="submit">'
|
||||||
|
f"<span>{html.escape(label)}</span>"
|
||||||
|
"</button></form>"
|
||||||
|
)
|
||||||
|
return "".join(buttons)
|
||||||
|
|
||||||
|
|
||||||
def render_language_options(lang):
|
def render_language_options(lang):
|
||||||
options = []
|
options = []
|
||||||
for code, label in available_languages().items():
|
for code, label in available_languages().items():
|
||||||
@@ -192,12 +267,31 @@ def render_language_options(lang):
|
|||||||
return "".join(options)
|
return "".join(options)
|
||||||
|
|
||||||
|
|
||||||
|
def group_actions(actions, lang):
|
||||||
|
grouped = {}
|
||||||
|
order = []
|
||||||
|
for action in actions:
|
||||||
|
if action.kind == "all_off":
|
||||||
|
continue
|
||||||
|
key = camera_group_key(action)
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = {"name": camera_group_name(action, lang), "actions": []}
|
||||||
|
order.append(key)
|
||||||
|
grouped[key]["actions"].append(action)
|
||||||
|
return [grouped[key] for key in order]
|
||||||
|
|
||||||
|
|
||||||
def render_page(actions, lang, token_param=None):
|
def render_page(actions, lang, token_param=None):
|
||||||
lang = normalize_language(lang)
|
lang = normalize_language(lang)
|
||||||
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
||||||
cards = [render_card(action, lang, token_query) for action in actions]
|
global_actions = [action for action in actions if action.kind == "all_off"]
|
||||||
|
global_markup = "".join(render_global_action(action, lang, token_query) for action in global_actions)
|
||||||
|
if global_markup:
|
||||||
|
global_markup = f'<section class="global-strip">{global_markup}</section>'
|
||||||
|
groups = group_actions(actions, lang)
|
||||||
|
cards = [render_group_card(group, lang, token_query) for group in groups]
|
||||||
cards_markup = "".join(cards)
|
cards_markup = "".join(cards)
|
||||||
if not cards_markup:
|
if not cards_markup and not global_markup:
|
||||||
cards_markup = f'<section class="empty-state">{html.escape(text_for(lang, "no_actions"))}</section>'
|
cards_markup = f'<section class="empty-state">{html.escape(text_for(lang, "no_actions"))}</section>'
|
||||||
token_input = (
|
token_input = (
|
||||||
f'<input type="hidden" name="token" value="{html.escape(token_param)}">'
|
f'<input type="hidden" name="token" value="{html.escape(token_param)}">'
|
||||||
@@ -208,10 +302,11 @@ def render_page(actions, lang, token_param=None):
|
|||||||
return template.format(
|
return template.format(
|
||||||
lang=html.escape(lang),
|
lang=html.escape(lang),
|
||||||
title=html.escape(text_for(lang, "title")),
|
title=html.escape(text_for(lang, "title")),
|
||||||
action_count=len(cards),
|
action_count=len(groups),
|
||||||
actions_label=html.escape(text_for(lang, "actions")),
|
actions_label=html.escape(text_for(lang, "cameras")),
|
||||||
language_label=html.escape(text_for(lang, "language")),
|
language_label=html.escape(text_for(lang, "language")),
|
||||||
language_options=render_language_options(lang),
|
language_options=render_language_options(lang),
|
||||||
token_input=token_input,
|
token_input=token_input,
|
||||||
|
global_actions=global_markup,
|
||||||
cards=cards_markup,
|
cards=cards_markup,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ msgstr "State"
|
|||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr "Actions"
|
msgstr "Actions"
|
||||||
|
|
||||||
|
msgid "cameras"
|
||||||
|
msgstr "Cameras"
|
||||||
|
|
||||||
|
msgid "camera.controls_count"
|
||||||
|
msgstr "{count} controls"
|
||||||
|
|
||||||
msgid "snapshot"
|
msgid "snapshot"
|
||||||
msgstr "Snapshot"
|
msgstr "Snapshot"
|
||||||
|
|
||||||
@@ -66,13 +72,13 @@ msgid "visual.generic"
|
|||||||
msgstr "Camera control"
|
msgstr "Camera control"
|
||||||
|
|
||||||
msgid "timer.auto_off"
|
msgid "timer.auto_off"
|
||||||
msgstr "Auto-off in"
|
msgstr "Automatic turn-off in"
|
||||||
|
|
||||||
msgid "timer.expired"
|
msgid "timer.expired"
|
||||||
msgstr "Auto-off pending"
|
msgstr "Automatic turn-off pending"
|
||||||
|
|
||||||
msgid "timer.auto_off_configured"
|
msgid "timer.auto_off_configured"
|
||||||
msgstr "Auto-off after {seconds}s"
|
msgstr "Automatic turn-off after {seconds}s"
|
||||||
|
|
||||||
msgid "ack.siren_on"
|
msgid "ack.siren_on"
|
||||||
msgstr "Confirm siren activation."
|
msgstr "Confirm siren activation."
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ msgstr "Stan"
|
|||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr "Akcje"
|
msgstr "Akcje"
|
||||||
|
|
||||||
|
msgid "cameras"
|
||||||
|
msgstr "Kamery"
|
||||||
|
|
||||||
|
msgid "camera.controls_count"
|
||||||
|
msgstr "Kontrolki: {count}"
|
||||||
|
|
||||||
msgid "snapshot"
|
msgid "snapshot"
|
||||||
msgstr "Migawka"
|
msgstr "Migawka"
|
||||||
|
|
||||||
@@ -66,13 +72,13 @@ msgid "visual.generic"
|
|||||||
msgstr "Sterowanie kamera"
|
msgstr "Sterowanie kamera"
|
||||||
|
|
||||||
msgid "timer.auto_off"
|
msgid "timer.auto_off"
|
||||||
msgstr "Auto-off za"
|
msgstr "Automatyczne wylaczenie za"
|
||||||
|
|
||||||
msgid "timer.expired"
|
msgid "timer.expired"
|
||||||
msgstr "Auto-off w toku"
|
msgstr "Automatyczne wylaczenie w toku"
|
||||||
|
|
||||||
msgid "timer.auto_off_configured"
|
msgid "timer.auto_off_configured"
|
||||||
msgstr "Auto-off po {seconds}s"
|
msgstr "Automatyczne wylaczanie po {seconds}s"
|
||||||
|
|
||||||
msgid "ack.siren_on"
|
msgid "ack.siren_on"
|
||||||
msgstr "Potwierdz wlaczenie syreny."
|
msgstr "Potwierdz wlaczenie syreny."
|
||||||
|
|||||||
+38
-9
@@ -4,21 +4,25 @@ from frigate_camera_control_bridge.ui import render_page
|
|||||||
|
|
||||||
|
|
||||||
class DummyAction:
|
class DummyAction:
|
||||||
def __init__(self):
|
def __init__(self, suffix="siren", name_key="action.siren", state="ON", kind="dahua_coaxial_io"):
|
||||||
self.id = "example_camera_siren"
|
self.id = f"example_camera_{suffix}"
|
||||||
self.platform = "switch"
|
self.platform = "switch"
|
||||||
self.kind = "dahua_coaxial_io"
|
self.kind = kind
|
||||||
self.state = "ON"
|
self.state = state
|
||||||
self.auto_off_seconds = 180
|
self.auto_off_seconds = 180
|
||||||
self.auto_off_at = 1234567890
|
self.auto_off_at = 1234567890
|
||||||
self.cfg = {
|
self.cfg = {
|
||||||
"name_key": "action.siren",
|
"name_key": name_key,
|
||||||
"requires_ack": True,
|
"camera_name": "Example camera",
|
||||||
"snapshot": {"camera": "example", "frigate_url": "http://frigate.example.test:5000"},
|
"requires_ack": name_key == "action.siren",
|
||||||
|
"snapshot": {
|
||||||
|
"camera": "example",
|
||||||
|
"frigate_url": "http://frigate.example.test:5000",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def display_name(self, lang):
|
def display_name(self, lang):
|
||||||
return "Example siren"
|
return f"Example {self.id}"
|
||||||
|
|
||||||
def command_list(self):
|
def command_list(self):
|
||||||
return ["on", "off"]
|
return ["on", "off"]
|
||||||
@@ -30,7 +34,32 @@ class UiRenderTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn('data-auto-off-at="1234567890"', html)
|
self.assertIn('data-auto-off-at="1234567890"', html)
|
||||||
self.assertIn('data-requires-ack="true"', html)
|
self.assertIn('data-requires-ack="true"', html)
|
||||||
self.assertIn("Example siren", html)
|
self.assertIn("Example camera", html)
|
||||||
|
|
||||||
|
def test_controls_from_same_camera_share_one_card(self):
|
||||||
|
actions = [
|
||||||
|
DummyAction("siren", "action.siren"),
|
||||||
|
DummyAction("white", "action.manual_white", state="OFF"),
|
||||||
|
DummyAction("red_blue", "action.red_blue", state="OFF"),
|
||||||
|
]
|
||||||
|
|
||||||
|
html = render_page(actions, "en")
|
||||||
|
|
||||||
|
self.assertEqual(html.count('class="camera-card"'), 1)
|
||||||
|
self.assertEqual(html.count('class="action-control"'), 3)
|
||||||
|
self.assertIn("3 controls", html)
|
||||||
|
self.assertIn("White light", html)
|
||||||
|
self.assertIn("Red-blue light", html)
|
||||||
|
self.assertIn("Siren", html)
|
||||||
|
|
||||||
|
def test_all_off_renders_in_topbar_not_camera_grid(self):
|
||||||
|
action = DummyAction("all_off", "action.all_off", state=None, kind="all_off")
|
||||||
|
|
||||||
|
html = render_page([action], "en")
|
||||||
|
|
||||||
|
self.assertIn('class="global-strip"', html)
|
||||||
|
self.assertIn('class="global-action"', html)
|
||||||
|
self.assertNotIn('class="camera-card"', html)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user