Dashboard views reorganized, mealie/roborock automations, indkorsel snapshots, wavin/sonoff docs, varme/sikkerhed updates
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate HTML gallery for indkorsel person-detection snapshots.
|
||||
Called via shell_command after each new snapshot is saved.
|
||||
Output: /config/www/snapshots/indkorsel_gallery.html
|
||||
"""
|
||||
import os
|
||||
import glob
|
||||
from datetime import datetime
|
||||
|
||||
SNAPSHOT_DIR = "/config/www/snapshots/indkorsel"
|
||||
OUTPUT_FILE = "/config/www/snapshots/indkorsel_gallery.html"
|
||||
LOADER_FILE = "/config/www/snapshots/indkorsel_loader.html"
|
||||
MAX_SNAPSHOTS = 100
|
||||
|
||||
|
||||
def parse_timestamp(filename):
|
||||
base = os.path.basename(filename).replace(".jpg", "")
|
||||
try:
|
||||
dt = datetime.strptime(base, "%Y-%m-%d_%H-%M-%S")
|
||||
return dt.strftime("%-d. %b %Y %H:%M:%S")
|
||||
except Exception:
|
||||
return base
|
||||
|
||||
|
||||
os.makedirs(SNAPSHOT_DIR, exist_ok=True)
|
||||
|
||||
files = sorted(
|
||||
[f for f in glob.glob(os.path.join(SNAPSHOT_DIR, "*.jpg"))
|
||||
if os.path.basename(f) != "latest.jpg"],
|
||||
reverse=True
|
||||
)[:MAX_SNAPSHOTS]
|
||||
|
||||
items_html = ""
|
||||
images_js = [] # [{src, ts}, ...] for JS navigation
|
||||
for f in files:
|
||||
rel = "/local/snapshots/indkorsel/" + os.path.basename(f)
|
||||
ts = parse_timestamp(f)
|
||||
idx = len(images_js)
|
||||
items_html += f"""
|
||||
<div class="thumb" onclick="openModal({idx})">
|
||||
<img src="{rel}" loading="lazy" alt="{ts}"/>
|
||||
<div class="ts">{ts}</div>
|
||||
</div>"""
|
||||
images_js.append({"src": rel, "ts": ts})
|
||||
|
||||
import json as _json
|
||||
images_js_str = _json.dumps(images_js)
|
||||
version = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="da"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Indkorsel snapshots</title>
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<style>
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{background:#111;color:#ddd;font-family:sans-serif;padding:10px}}
|
||||
h2{{padding:8px 0 14px;font-size:13px;opacity:.5;font-weight:normal}}
|
||||
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:6px}}
|
||||
.thumb{{cursor:pointer;border-radius:7px;overflow:hidden;background:#222;transition:transform .12s,opacity .12s}}
|
||||
.thumb:hover{{transform:scale(1.02);opacity:.9}}
|
||||
.thumb img{{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}}
|
||||
.ts{{font-size:10px;padding:4px 6px;opacity:.5;text-align:center}}
|
||||
.modal{{display:none;position:fixed;inset:0;background:rgba(0,0,0,.93);z-index:9999;flex-direction:column;justify-content:center;align-items:center}}
|
||||
.modal.show{{display:flex}}
|
||||
.modal img{{max-width:96vw;max-height:82vh;border-radius:8px;object-fit:contain;box-shadow:0 4px 40px rgba(0,0,0,.6)}}
|
||||
.modal-ts{{margin-top:12px;font-size:14px;opacity:.6;letter-spacing:.3px}}
|
||||
.modal-counter{{margin-top:4px;font-size:11px;opacity:.35;letter-spacing:.3px}}
|
||||
.close{{position:absolute;top:14px;right:18px;font-size:32px;cursor:pointer;opacity:.5;line-height:1;user-select:none}}
|
||||
.close:hover{{opacity:1}}
|
||||
.nav{{position:absolute;top:50%;transform:translateY(-50%);font-size:44px;cursor:pointer;opacity:.35;user-select:none;padding:0 18px;line-height:1}}
|
||||
.nav:hover{{opacity:.9}}
|
||||
.nav.prev{{left:0}} .nav.next{{right:0}}
|
||||
.nav.disabled{{opacity:.08;cursor:default;pointer-events:none}}
|
||||
.empty{{padding:40px;text-align:center;opacity:.4;font-size:14px}}
|
||||
.reload-badge{{position:fixed;bottom:14px;right:14px;background:#1a73e8;color:#fff;padding:6px 14px;border-radius:20px;font-size:12px;cursor:pointer;display:none;z-index:9998}}
|
||||
</style>
|
||||
</head><body>
|
||||
<h2>Viser {len(files)} person-snapshots – Indkorsel</h2>
|
||||
{"<div class='grid'>" + items_html + "</div>" if files else "<div class='empty'>Ingen snapshots endnu.</div>"}
|
||||
<div id="reload-badge" class="reload-badge" onclick="window.location.reload(true)">Nye billeder – tryk for at opdatere</div>
|
||||
<div class="modal" id="modal">
|
||||
<span class="close" onclick="closeModal()">✕</span>
|
||||
<span class="nav prev" id="navPrev" onclick="navigate(-1)">‹</span>
|
||||
<img id="mimg" src="" alt=""/>
|
||||
<span class="nav next" id="navNext" onclick="navigate(1)">›</span>
|
||||
<div class="modal-ts" id="mts"></div>
|
||||
<div class="modal-counter" id="mcounter"></div>
|
||||
</div>
|
||||
<script>
|
||||
const IMGS = {images_js_str};
|
||||
const VERSION = '{version}';
|
||||
let cur = 0;
|
||||
function openModal(idx){{
|
||||
cur = idx;
|
||||
render();
|
||||
document.getElementById('modal').classList.add('show');
|
||||
}}
|
||||
function render(){{
|
||||
const item = IMGS[cur];
|
||||
document.getElementById('mimg').src = item.src;
|
||||
document.getElementById('mts').textContent = item.ts;
|
||||
document.getElementById('mcounter').textContent = (cur+1) + ' / ' + IMGS.length;
|
||||
document.getElementById('navPrev').classList.toggle('disabled', cur === 0);
|
||||
document.getElementById('navNext').classList.toggle('disabled', cur === IMGS.length - 1);
|
||||
}}
|
||||
function navigate(dir){{
|
||||
const next = cur + dir;
|
||||
if(next >= 0 && next < IMGS.length){{ cur = next; render(); }}
|
||||
}}
|
||||
function closeModal(){{
|
||||
document.getElementById('modal').classList.remove('show');
|
||||
document.getElementById('mimg').src = '';
|
||||
}}
|
||||
document.addEventListener('keydown', e => {{
|
||||
if(!document.getElementById('modal').classList.contains('show')) return;
|
||||
if(e.key === 'ArrowLeft') navigate(-1);
|
||||
if(e.key === 'ArrowRight') navigate(1);
|
||||
if(e.key === 'Escape') closeModal();
|
||||
}});
|
||||
document.getElementById('modal').addEventListener('click', function(e){{
|
||||
if(e.target === this) closeModal();
|
||||
}});
|
||||
// Tjek hvert 60 sek om der er en nyere version af galleriet
|
||||
setInterval(() => {{
|
||||
fetch('/local/snapshots/indkorsel_loader.html?_=' + Date.now())
|
||||
.then(r => r.text())
|
||||
.then(html => {{
|
||||
const m = html.match(/[?]v=(\\d+)/);
|
||||
if(m && m[1] !== VERSION) document.getElementById('reload-badge').style.display = 'block';
|
||||
}}).catch(() => {{}});
|
||||
}}, 60000);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as fh:
|
||||
fh.write(html)
|
||||
|
||||
# Write a tiny loader page that always redirects to gallery with current timestamp
|
||||
# so the iframe in HA never serves a cached version
|
||||
cache_bust = version
|
||||
loader = f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta http-equiv="refresh" content="0; url=/local/snapshots/indkorsel_gallery.html?v={cache_bust}">
|
||||
</head><body></body></html>"""
|
||||
with open(LOADER_FILE, "w", encoding="utf-8") as fh:
|
||||
fh.write(loader)
|
||||
|
||||
print(f"Gallery updated: {len(files)} snapshots -> {OUTPUT_FILE}")
|
||||
@@ -273,11 +273,7 @@ def main() -> None:
|
||||
shopping_list_id = reset_shopping_list(MEALIE_BASE_URL, token, TARGET_SHOPPING_LIST_NAME, shopping_list_id)
|
||||
|
||||
recipe_ids = get_mealplan_recipe_ids(MEALIE_BASE_URL, token, start_date, end_date)
|
||||
added_recipes = add_recipes_to_shopping_list(MEALIE_BASE_URL, token, shopping_list_id, recipe_ids)
|
||||
|
||||
mealie_items = fetch_mealie_items(MEALIE_BASE_URL, token, shopping_list_id=shopping_list_id)
|
||||
items = build_items(mealie_items)
|
||||
write_outputs(root, items, start_date, end_date)
|
||||
add_recipes_to_shopping_list(MEALIE_BASE_URL, token, shopping_list_id, recipe_ids)
|
||||
|
||||
print(
|
||||
'OK: '
|
||||
|
||||
Reference in New Issue
Block a user