diff --git a/README.md b/README.md index d530075..e526f7f 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,7 @@ pip install aiohttp && python3 dashboard_studio/server/main.py ``` Then open http://localhost:8099. The Photos widget also has fake Immich albums in this mode. To try your real Immich server, set `IMMICH_URL` and `IMMICH_API_KEY` before starting the server. + +## App icon + +`dashboard_studio/icon.png` is drawn by `tools/make_icon.py` (needs Pillow): `python3 tools/make_icon.py dashboard_studio/icon.png`. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 1f2803f..41a6d43 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -45,6 +45,10 @@ This card works with the [Afvalwijzer](https://github.com/xirixiz/homeassistant- Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days. +### Clock with weather + +Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details. + ### Media players One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher. @@ -92,8 +96,9 @@ A dashboard can have several tabs, each with its own widgets, for example *Overv - **Add tab** creates a tab and turns on the menu. - Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this. - **Show tabs in a side menu** turns the menu on or off. +- **Menu style:** *Icons only* (the default) shows a slim rail along the left edge, with the icons centered and a marker on the current tab. Hovering over an icon, or reaching it with the keyboard, shows the tab's name. *With names* shows the names next to the icons. -The menu sits on the left on computers, becomes a narrow rail on tablets and while editing, and turns into a tab bar at the bottom on phones. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode. +On phones the menu turns into a tab bar at the bottom, which always shows the names, since a phone has no hover. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode. ## Kiosk mode diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index b7859fb..8ea05ab 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.5.0" +version: "1.6.0" slug: dashboard_studio description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. url: https://git.melvanveen.nl/veenm/ha-dashboard-studio diff --git a/dashboard_studio/icon.png b/dashboard_studio/icon.png new file mode 100644 index 0000000..f72b469 Binary files /dev/null and b/dashboard_studio/icon.png differ diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index b672d70..0b761ef 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -255,6 +255,13 @@ class MockHA: entry = (ws, frozenset(subs)) self.clients.add(entry) await ws.send_json({"id": mid, "type": "result", "success": True, "result": None}) + elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts": + ids = (req.get("target") or {}).get("entity_id") or [] + ids = [ids] if isinstance(ids, str) else ids + day = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0) + forecast = [{"datetime": (day + timedelta(days=i)).isoformat(), "condition": c, "temperature": hi, "templow": lo} + for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6)])] + await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}}) elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events": ids = (req.get("target") or {}).get("entity_id") or [] ids = [ids] if isinstance(ids, str) else ids diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 8038306..c2fa62c 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -97,6 +97,15 @@ }); return res?.response || {}; } + /** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */ + async forecast(entityId, type = "daily") { + await this.ready; + const res = await this.send({ + type: "call_service", domain: "weather", service: "get_forecasts", + target: { entity_id: entityId }, service_data: { type }, return_response: true, + }); + return res?.response?.[entityId]?.forecast || []; + } async history(entityIds, hours) { await this.ready; const res = await this.send({ @@ -134,7 +143,7 @@ const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true }; const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); - const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false }, tabs: [newTab(tr("Overview"))] }); + const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] }); // Bring dashboards saved by older versions up to the current shape. function migrate(d) { @@ -147,7 +156,7 @@ d.theme = { ...DEFAULT_THEME, ...t }; d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) }; d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) }; - d.nav = { show: false, ...(d.nav || {}) }; + d.nav = { show: false, style: "icons", ...(d.nav || {}) }; if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; delete d.widgets; return d; @@ -169,8 +178,8 @@ const numeric = (e) => !isNaN(Number(ha.states[e].state)); put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") }); - put("clock", 8, 0, 4, 3, {}); const weather = first("weather")[0]; + put("clock", 8, 0, 4, 3, { weather: weather || "" }); if (weather) put("weather", 0, 1, 4, 2, { entity: weather }); const lights = first("light", 3); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); @@ -394,9 +403,11 @@ navEl.hidden = !show; if (!show) return; const active = tab().id; - navEl.innerHTML = current.tabs.map((t) => ``).join("") + - (editMode ? `` : ""); + (editMode ? `` : ""); } function setTab(id) { @@ -626,6 +637,7 @@ ${Icons.names.map((n) => `
${tr("Tabs")}
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} + ${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")}
${current.tabs.map((t, i) => `
diff --git a/dashboard_studio/web/favicon.png b/dashboard_studio/web/favicon.png new file mode 100644 index 0000000..91ff11c Binary files /dev/null and b/dashboard_studio/web/favicon.png differ diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index fe45604..7fa9c70 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -239,6 +239,9 @@ "Choose the appliance’s status, power or remaining-time entity in the inspector.": "Kies in de inspector de status-, vermogens- of resterende-tijdentiteit van het apparaat.", "In progress": "Bezig", "Finished": "Klaar", "Paused": "Gepauzeerd", "Ready to unload": "Klaar om uit te ruimen", "{0} h {1} min": "{0} u {1} min", "{0} min": "{0} min", "left · ready at {0}": "te gaan · klaar om {0}", "Progress": "Voortgang", + "Weather (optional)": "Weer (optioneel)", "Show today’s high and low": "Toon de hoogste en laagste temperatuur van vandaag", + "High {0}° · Low {1}°": "Max {0}° · min {1}°", "High {0}°": "Max {0}°", + "Menu style": "Menustijl", "icons": "Alleen pictogrammen", "labels": "Met namen", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", }; diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html index 86de590..f6e02e0 100644 --- a/dashboard_studio/web/index.html +++ b/dashboard_studio/web/index.html @@ -6,6 +6,7 @@ Dashboard Studio + diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 58e3048..8701c62 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -352,6 +352,14 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1 .clock { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; } .clock .time { font-size: clamp(30px, min(38cqh, 22cqw), 96px); font-weight: 700; letter-spacing: -0.03em; line-height: 1; } .clock .date { color: var(--d-text-2); margin-top: 6px; font-size: 15px; } +.clock-time { display: flex; flex-direction: column; align-items: center; } +.clock-wx { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 4px 8px; margin-top: 12px; padding: 6px 12px; border-radius: 99px; background: var(--d-fill); max-width: 100%; } +.clock-wx-ico { font-size: 20px; display: flex; color: var(--d-text-2); } +.clock-wx-temp { font-size: 17px; font-weight: 700; } +.clock-wx-text { font-size: 14px; color: var(--d-text-2); } +.clock-hl { font-size: 14px; color: var(--d-text-2); } +.clock-hl:empty { display: none; } +@container (max-height: 150px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } } .cam-btn { position: absolute; inset: 0; } .cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; } @@ -405,6 +413,30 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1 .tabnav-item.add { color: var(--d-text-2); margin-top: 4px; } .tabnav-item.add .i { font-size: 20px; } body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } + +/* Icon-only rail: full height on the left edge, names as tooltips. */ +@media (min-width: 641px) { + #tabnav.icons { + top: 72px; width: 76px; height: calc(100vh - 72px); max-height: none; margin: 0; padding: 12px 10px; + justify-content: center; align-items: center; gap: 10px; overflow: visible; + border: 0; border-right: 1px solid var(--d-separator); border-radius: 0; + background: color-mix(in srgb, var(--d-tile) 72%, transparent); + } + body.kiosk #tabnav.icons { top: 6px; height: calc(100vh - 6px); } + #tabnav.icons .tabnav-item { position: relative; width: 56px; height: 56px; min-height: 0; padding: 0; justify-content: center; border-radius: 16px; transition: background-color .2s, transform .1s; } + #tabnav.icons .tabnav-item:active { transform: scale(0.94); } + #tabnav.icons .tabnav-ico { font-size: 25px; } + #tabnav.icons .tabnav-item[aria-current="page"]::before { content: ""; position: absolute; left: -10px; top: 16px; bottom: 16px; width: 4px; border-radius: 0 3px 3px 0; background: var(--d-accent); } + #tabnav.icons .tabnav-item.add { margin-top: 6px; border: 1.5px dashed var(--d-separator); } + #tabnav.icons .tabnav-label { + position: absolute; left: calc(100% + 14px); top: 50%; z-index: 40; + transform: translate(-4px, -50%); opacity: 0; pointer-events: none; + padding: 6px 10px; border-radius: 9px; background: var(--surface); color: var(--text); + font-size: 13px; font-weight: 600; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); transition: opacity .15s, transform .15s; + } + #tabnav.icons .tabnav-item:hover .tabnav-label, #tabnav.icons .tabnav-item:focus-visible .tabnav-label { opacity: 1; transform: translate(0, -50%); } + .surface-plain #tabnav.icons { border-right-color: transparent; } +} .surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; } .i.flip { transform: rotate(180deg); } .tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } @@ -531,14 +563,14 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; } } /* While editing (and on tablets) the menu becomes a compact rail to leave room for the grid. */ @media (min-width: 641px) { - body.editing #tabnav { width: 84px; padding: 6px; margin-left: 10px; } - body.editing .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } - body.editing .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } + body.editing #tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; } + body.editing #tabnav:not(.icons) .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } + body.editing #tabnav:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } } @media (max-width: 900px) { - #tabnav { width: 84px; padding: 6px; margin-left: 10px; } - .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } - .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } + #tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; } + #tabnav:not(.icons) .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } + #tabnav:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } #palette { width: 180px; } #inspector { width: 260px; } #palette-list { grid-template-columns: 1fr; } @@ -550,7 +582,10 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; } flex-direction: row; max-height: none; overflow-x: auto; padding: 6px; border-radius: 22px; background: var(--glass); border-color: var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); } - body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; } + body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 56px; width: auto; height: auto; } + /* A tab bar keeps its names on phones: there's no hover to reveal them. */ + body:not(.editing) #tabnav.icons .tabnav-label { position: static; opacity: 1; transform: none; background: none; box-shadow: none; padding: 0; font-size: 11px; color: inherit; pointer-events: auto; max-width: 100%; } + body:not(.editing) #tabnav.icons .tabnav-item[aria-current="page"]::before { display: none; } body.has-tabs:not(.editing) #canvas { padding-bottom: 96px; } body.editing #tabnav { position: static; width: auto; flex-direction: row; overflow-x: auto; max-height: none; margin: 4px 10px; } body.editing #stage { flex-direction: column; align-items: stretch; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index ff1a150..41201fe 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -330,8 +330,29 @@ { k: "h24", t: "check", l: "24-hour time", d: true }, { k: "seconds", t: "check", l: "Show seconds", d: false }, { k: "date", t: "check", l: "Show date", d: true }, + { k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] }, + { k: "forecast", t: "check", l: "Show today’s high and low", d: true }, ], - render: (c) => `
${clockHTML(c.h24, c.seconds, c.date)}
`, + entities: (c) => (c.weather ? [c.weather] : []), + render(c, { ha }) { + const s = c.weather && ha.states[c.weather]; + const wx = s ? `` : ""; + return `
${clockHTML(c.h24, c.seconds, c.date)}
${wx}
`; + }, + async after(el, c, ctx) { + if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return; + const cache = ctx.cache; + if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) { + cache.key = c.weather; cache.at = Date.now(); + cache.loading = ctx.ha.forecast(c.weather, "daily").then((f) => (cache.today = f[0])).catch(() => (cache.today = null)); + } + await cache.loading; + const hl = el.querySelector("[data-hl]"); + const f = cache.today; + if (hl && f && f.temperature != null) hl.textContent = f.templow != null ? t("High {0}° · Low {1}°", Math.round(f.temperature), Math.round(f.templow)) : t("High {0}°", Math.round(f.temperature)); + }, }; function clockHTML(h24, sec, date) { diff --git a/tools/make_icon.py b/tools/make_icon.py new file mode 100644 index 0000000..cf9b11e --- /dev/null +++ b/tools/make_icon.py @@ -0,0 +1,74 @@ +"""Render the Dashboard Studio app icon: a dashboard with one lamp-lit tile.""" +import sys +from PIL import Image, ImageDraw, ImageFilter + +S = 1024 # draw large, downscale for anti-aliasing + + +def vgradient(w, h, top, bottom): + g = Image.new("RGBA", (w, h)) + px = g.load() + for y in range(h): + t = y / (h - 1) + c = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + (255,) + for x in range(w): + px[x, y] = c + return g + + +def rounded_mask(size, box, r): + m = Image.new("L", size, 0) + ImageDraw.Draw(m).rounded_rectangle(box, r, fill=255) + return m + + +img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +# background squircle +bg = vgradient(S, S, (34, 44, 78), (14, 17, 30)) +img.paste(bg, (0, 0), rounded_mask((S, S), (0, 0, S - 1, S - 1), 230)) + +pad, gap = 150, 56 +left = (pad, pad, 492, S - pad) # tall lit tile +rt = (492 + gap, pad, S - pad, 492 - gap // 2) # graph tile +rb = (492 + gap, 492 + gap // 2, S - pad, S - pad) # switch tile + +# warm glow behind the lit tile +glow = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +ImageDraw.Draw(glow).rounded_rectangle(left, 64, fill=(255, 196, 92, 170)) +img = Image.alpha_composite(img, glow.filter(ImageFilter.GaussianBlur(60))) + +# lit tile with a warm gradient +lit = vgradient(left[2] - left[0], left[3] - left[1], (255, 236, 190), (255, 196, 96)) +img.paste(lit, left[:2], rounded_mask(lit.size, (0, 0, lit.size[0] - 1, lit.size[1] - 1), 64)) + +# translucent shapes go on a layer that is blended in, not pasted over +layer = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +ld = ImageDraw.Draw(layer) +for box in (rt, rb): + ld.rounded_rectangle(box, 64, fill=(255, 255, 255, 26), outline=(255, 255, 255, 40), width=4) +cx = left[0] + 100 +ld.ellipse((cx - 52, left[1] + 48, cx + 52, left[1] + 152), fill=(255, 255, 255, 140)) +bar = (left[0] + 48, left[3] - 128, left[2] - 48, left[3] - 64) +ld.rounded_rectangle(bar, 32, fill=(122, 82, 16, 50)) +img = Image.alpha_composite(img, layer) + +d = ImageDraw.Draw(img) +# lamp in the badge and the filled part of the brightness bar +d.ellipse((cx - 20, left[1] + 80, cx + 20, left[1] + 120), fill=(122, 82, 16, 255)) +d.rounded_rectangle((bar[0], bar[1], bar[0] + int((bar[2] - bar[0]) * 0.68), bar[3]), 32, fill=(140, 94, 20, 255)) + +# graph tile: a line in the accent blue +pts = [(rt[0] + 44, rt[3] - 70), (rt[0] + 110, rt[3] - 120), (rt[0] + 170, rt[3] - 96), (rt[0] + 240, rt[3] - 170), (rt[2] - 44, rt[1] + 70)] +d.line(pts, fill=(90, 170, 255, 255), width=22, joint="curve") +for p in (pts[0], pts[-1]): + d.ellipse((p[0] - 11, p[1] - 11, p[0] + 11, p[1] + 11), fill=(90, 170, 255, 255)) + +# switch tile: an "on" switch +sw = (rb[0] + 50, (rb[1] + rb[3]) // 2 - 46, rb[2] - 50, (rb[1] + rb[3]) // 2 + 46) +d.rounded_rectangle(sw, 46, fill=(48, 209, 88, 255)) +d.ellipse((sw[2] - 86, sw[1] + 6, sw[2] - 6, sw[3] - 6), fill=(255, 255, 255, 255)) + +out = sys.argv[1] +img.resize((256, 256), Image.LANCZOS).save(out) +img.resize((128, 128), Image.LANCZOS).save(out.replace(".png", "-128.png")) +print("saved", out)