diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md new file mode 100644 index 0000000..9111b95 --- /dev/null +++ b/dashboard_studio/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +## 1.10.0 + +- **Phone dashboards.** Turn on *Designed for phones* in a dashboard's grid settings to design it in a 4-column layout at phone size. Existing widgets are converted. On a phone the dashboard fills the screen without the top bar; touch and hold an empty spot to bring the bar back. Other dashboards still reflow on phones as before. +- **Room card.** Pick a Home Assistant room (area) and the card shows its temperature and humidity, what's on, open or playing, a button for all its lights, and its devices to switch. You can add extra entities, or build a room from entities alone. +- **Changelog.** This list, in Home Assistant's Changelog tab and under *About → What's new* in the app. + +## 1.9.0 + +- The swipe card really swipes now, with a finger or the mouse: it follows along, resists at the ends and reacts to a quick flick. The arrow keys work too. +- Cards in a swipe card fill the whole card. The page dots float on top and hide after a few seconds. +- Tap a clock with a photo background, or a Photos card, to go to the next photo. +- Fixed: removing a player from a media card didn't work inside a scroll or swipe card, or on media cards from before 1.5. +- The vacuum card grows with its size: a robot with a battery ring, stats, suction power and labelled buttons. + +## 1.8.0 + +- New vacuum card: status, battery, start or pause, dock, locate, suction power and an optional live map. +- Clock: choose where the time sits in the card, and add a background photo from links or Immich albums. +- Light card: a light group lists each of its lights with its own switch. + +## 1.7.0 + +- New horizontal scroll card and swipe card, which hold other cards. +- Nine new themes, including High contrast, for 15 in total. +- Choose which dashboard opens on which device (optionally in kiosk mode) and for which person. + +## 1.6.1 + +- The version is shown under *About* in the dashboard settings. +- After an update, browsers load the new version instead of an old stored copy, and open screens reload by themselves. + +## 1.6.0 + +- New app icon. +- The clock can show the weather with today's high and low. +- The tab menu can be a slim rail with icons only (the default), with names on hover. + +## 1.5.0 + +- Any widget can be shown only under conditions, such as "only when the washer is running". +- New appliance card for washers, dryers and dishwashers. +- The media card can control several players. + +## 1.4.0 + +- New waste collection card for the Afvalwijzer integration. +- Dashboards can have tabs, with a menu on the side (a tab bar on phones). + +## 1.3.1 + +- Fixed: on Home Assistant the app showed test data instead of your own devices. +- New menu item: *New dashboard from my devices*. + +## 1.3.0 + +- Dutch translation, following your Home Assistant language, with a per-device choice. + +## 1.2.0 + +- New Photos card with Immich albums, and a calendar card. +- Kiosk mode has no visible exit button any more: tap a corner five times to leave. + +## 1.1.0 + +- New design following Apple's design guidelines, with a System theme that follows light and dark mode. +- Tile styles: cards, outlined or plain. +- New car card and kiosk mode. + +## 1.0.0 + +- First version: a drag-and-drop dashboard editor in its own Home Assistant sidebar tab. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 3c03948..7ac4851 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -69,6 +69,16 @@ When the light card points at a light group, it also lists every light in the gr The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**. +### Room + +Pick one of your Home Assistant rooms (areas) and the card gathers everything in it: + +- the room's temperature and humidity (the sensors set on the area in Home Assistant, or any temperature and humidity sensor in the room; you can also choose them yourself) +- chips for what's going on: how many lights are on (tap to switch them all off, or all on), the thermostat, open doors and windows, motion, and music that's playing +- buttons for the lamps, switches, fans, covers and locks in the room + +Light groups are left out so each lamp counts once. Add extra entities, or skip the room and list the entities yourself. The card glows warm while lights are on, and can have a background photo. Rooms are read from Home Assistant's area, device and entity registries. + ### 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. @@ -109,6 +119,12 @@ Any widget can be shown only under certain conditions. Select it in edit mode, t In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label, so you can still edit them. On the dashboard they disappear, and rows that only held hidden widgets close up so no gaps are left. Changes happen live as the entities change. +## Phone dashboards + +Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns. + +On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, touch and hold an empty spot for a moment; it hides again after 10 seconds. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. + ## Tabs and side menu A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings: @@ -153,7 +169,7 @@ A device's choice comes before a person's. Without either, the last dashboard us ## Version and updates -The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted. +The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). **What's new** there shows the changelog, which is also on the app's **Changelog** tab in Home Assistant. After an update, a short message points you to it. After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted. ## Data diff --git a/dashboard_studio/Dockerfile b/dashboard_studio/Dockerfile index 1c4281a..6212a39 100644 --- a/dashboard_studio/Dockerfile +++ b/dashboard_studio/Dockerfile @@ -6,7 +6,7 @@ RUN apk add --no-cache python3 py3-aiohttp WORKDIR /app COPY server/ /app/server/ COPY web/ /app/web/ -COPY config.yaml /app/config.yaml +COPY config.yaml CHANGELOG.md /app/ COPY run.sh /run.sh RUN chmod a+x /run.sh diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index fbcf145..1455a62 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.9.0" +version: "1.10.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/server/main.py b/dashboard_studio/server/main.py index 7c5b643..f5e1a58 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -141,6 +141,14 @@ async def users(request): return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"}) +async def changelog(request): + try: + text = (APP_DIR / "CHANGELOG.md").read_text() + except OSError: + text = "" + return web.Response(text=text, content_type="text/markdown", headers={"Cache-Control": "no-store"}) + + async def version(request): return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"}) @@ -248,6 +256,7 @@ def make_app(): app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024) app.router.add_get("/", index) app.router.add_get("/api/version", version) + app.router.add_get("/api/changelog", changelog) app.router.add_get("/api/whoami", whoami) app.router.add_get("/api/users", users) app.router.add_get("/api/dashboards", get_dashboards) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index 8006d1a..bcffff2 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -312,6 +312,26 @@ class MockHA: elif typ == "history/history_during_period": start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)}) + elif typ == "config/area_registry/list": + await ws.send_json({"id": mid, "type": "result", "success": True, "result": [ + {"area_id": "living_room", "name": "Living Room", "icon": None, "temperature_entity_id": "sensor.living_room_temperature", "humidity_entity_id": "sensor.living_room_humidity"}, + {"area_id": "kitchen", "name": "Kitchen", "icon": None}, + {"area_id": "bedroom", "name": "Bedroom", "icon": None}, + {"area_id": "office", "name": "Office", "icon": None}, + {"area_id": "garage", "name": "Garage", "icon": None}, + ]}) + elif typ == "config/entity_registry/list": + areas = { + "living_room": ["light.living_room", "light.reading_lamp", "light.downstairs", "media_player.living_room_speaker", "media_player.tv", "sensor.living_room_temperature", "sensor.living_room_humidity", "binary_sensor.hallway_motion", "climate.thermostat"], + "kitchen": ["light.kitchen", "switch.coffee_machine", "media_player.kitchen_speaker", "sensor.washer_status"], + "bedroom": ["light.bedroom", "binary_sensor.front_door"], + "office": ["fan.office", "input_boolean.guest_mode"], + "garage": ["cover.garage_door", "lock.front_door"], + } + rows = [{"entity_id": e, "area_id": a, "device_id": None, "disabled_by": None, "hidden_by": None, "entity_category": None} for a, es in areas.items() for e in es] + await ws.send_json({"id": mid, "type": "result", "success": True, "result": rows}) + elif typ == "config/device_registry/list": + await ws.send_json({"id": mid, "type": "result", "success": True, "result": []}) elif typ == "ping": await ws.send_json({"id": mid, "type": "pong"}) else: diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index e06a216..8bf1f8d 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -30,6 +30,7 @@ this.connected = false; this.onChange = () => {}; this.onStatus = () => {}; + this.onRegistry = () => {}; this.ready = new Promise((r) => (this._markReady = r)); } connect() { @@ -38,6 +39,7 @@ const ws = (this.ws = new WebSocket(url)); ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); ws.onclose = () => { + this._reg = null; if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); this.connected = false; this.onStatus(false); @@ -65,6 +67,7 @@ }); this.connected = true; this._markReady(); + this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card this.onStatus(true); this.onChange(null); return; @@ -101,6 +104,25 @@ }); return res?.response || {}; } + /** Areas (rooms) and which entities are in them, from the area, entity and device registries. */ + registry() { + this._reg ||= Promise.all([ + this.send({ type: "config/area_registry/list" }), + this.send({ type: "config/entity_registry/list" }), + this.send({ type: "config/device_registry/list" }), + ]).then(([areas, ents, devs]) => { + const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id])); + const byArea = {}; + for (const e of ents || []) { + if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities + const area = e.area_id || devArea[e.device_id]; + if (area) (byArea[area] ||= []).push(e.entity_id); + } + this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea }; + return this.reg; + }).catch((err) => { this._reg = null; throw err; }); + return this._reg; + } /** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */ async forecast(entityId, type = "daily") { await this.ready; @@ -205,7 +227,7 @@ const lights = first("light", 3); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); - const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.test(e)).slice(0, 2); + const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress|vacuum|robot|filter|cleaning|cleaned/.test(e)).slice(0, 2); sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e })); const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e })); @@ -358,7 +380,8 @@ const pad = window.innerWidth < 560 ? 12 : 20; const avail = canvasEl.clientWidth - pad * 2; const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; - const compact = !editMode && width < 560; + // Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens. + const compact = !editMode && width < 560 && !current.mobile; const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12); const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const rh = Number(g.rowHeight) || 72; @@ -483,8 +506,10 @@ } } + const phoneScreen = matchMedia("(max-width: 600px)"); function renderGrid() { if (!current) return; + document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches); renderNav(); applyTheme(); computeMetrics(); @@ -633,6 +658,11 @@ ${list.map((e, i) => `
`).join("")} `; } + case "area": { + const areas = ha.reg?.areas; + if (!areas) return `${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}
${tr("Select a widget to edit it.")}
`; } @@ -846,6 +879,7 @@ if (isChange || t.tagName === "BUTTON") renderInspector(); return; } + if (path === "layout") { convertLayout(!!v); renderInspector(); return; } if (path === "nav") { mutate(() => { current.nav[k] = v; }); return; @@ -946,6 +980,31 @@ } } + /** Switch a dashboard between the 12-column layout and the 4-column phone layout, converting every tab. */ + function convertLayout(toMobile) { + if (!!current.mobile === toMobile) return; + mutate(() => { + const from = Number(current.grid.cols) || 12, to = toMobile ? 4 : 12, f = to / from; + current.mobile = toMobile; + current.grid.cols = to; + current.grid.maxWidth = toMobile ? 440 : 1440; + for (const t of current.tabs) { + const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x); + for (const w of ws) { + if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width + w.w = w.w >= from / 3 ? 4 : 2; + w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0; + } else { + w.w = Math.max(1, Math.min(to, Math.round(w.w * f))); + w.x = Math.min(to - w.w, Math.round(w.x * f)); + } + } + const placed = []; // re-pack top-down so nothing overlaps + for (const w of ws) { while (placed.some((o) => overlap(w, o))) w.y++; placed.push(w); } + } + }); + } + function findSpot(w, h, widgets = tab().widgets) { const cols = current.grid.cols; for (let y = 0; y < 500; y++) @@ -956,7 +1015,7 @@ function addWidget(type) { const wd = makeWidget(type); - wd.w = Math.min(wd.w, current.grid.cols); + wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); select(wd.id); @@ -1086,6 +1145,7 @@ const track = a.closest(".hs").querySelector(".hs-track"); track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" }); } + else if (act === "svc-many") { const [d, sv] = a.dataset.v.split("."); ha.callService(d, sv, {}, a.dataset.ids.split(",")); } else if (act === "photo-next") { const c = caches[a.closest(".w").dataset.id]; (c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg @@ -1217,6 +1277,28 @@ card.removeEventListener("change", update); } + /** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */ + async function whatsNew() { + let md = ""; + try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ } + const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1"); + let html = "", inList = false; + for (const line of md.split("\n")) { + if (/^- /.test(line)) { if (!inList) { html += "${tr("The changelog couldn’t be loaded.")}
`}