From 8ef34ff84a97a3602bdc2d227eb3cdebd4395c6d Mon Sep 17 00:00:00 2001 From: CtrlAltcouk <138382428+CtrlAltcouk@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:48:07 +0100 Subject: [PATCH 01/14] docs: define Home Assistant app architecture --- docs/home-assistant-app.md | 275 +++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/home-assistant-app.md diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md new file mode 100644 index 0000000..5182851 --- /dev/null +++ b/docs/home-assistant-app.md @@ -0,0 +1,275 @@ +# Home Assistant App architecture + +Status: Phase 1 architecture decision for the `Home-Assistant` branch. + +InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. + +## Product goals + +1. Package InkPanel as a Home Assistant App (the current Home Assistant name for an add-on). +2. Make the InkPanel Studio available through Home Assistant Ingress. +3. Keep physical ESP32 panels able to reach the InkPanel frame endpoint over the LAN. +4. Use Home Assistant Core as an optional native data provider without requiring a manually-created long-lived access token when InkPanel is running as an App. +5. Add Home Assistant-backed Calendar and To Do choices while retaining InkPanel's existing iCal and local To Do sources. +6. Add a generic Home Assistant Entities widget for sensors and other useful entity state. +7. Preserve the existing firmware, framebuffer profiles, standalone deployment and existing source providers unless a change is explicitly required and regression-tested. + +## Supported deployment modes + +### Standalone + +Existing behaviour remains authoritative: + +- Node/Express application +- `DATA_DIR` defaults to `./data` or `/data` in Docker +- direct HTTP panel/API listener +- optional self-signed HTTPS listener for WebSerial +- optional InkPanel password/session authentication +- existing provider stores and source integrations + +No Home Assistant environment or token is required. + +### Home Assistant App + +Home Assistant Supervisor runs InkPanel as a container. + +The App will: + +- use `/data` for all InkPanel persistent state so Home Assistant backups include it; +- enable `homeassistant_api: true`; +- receive `SUPERVISOR_TOKEN` at runtime; +- talk to Home Assistant Core through `http://supervisor/core/api/`; +- expose the Studio through Ingress; +- expose a LAN-reachable panel endpoint separately from the Ingress browser path; +- not ask the user to copy a Home Assistant long-lived token. + +The App packaging should support `amd64` and `aarch64` if the Chromium/Playwright production image is verified on both architectures. + +## Critical network boundary: Ingress is not the panel URL + +Home Assistant Ingress is for an authenticated browser session. ESP32 panels cannot use an Ingress URL. + +Therefore Home Assistant mode has two distinct concepts: + +1. **Admin/Studio URL** — Home Assistant Ingress, authenticated by Home Assistant. +2. **Panel base URL** — a normal LAN-reachable InkPanel URL used by ESP32 firmware for `/api/devices/:id/frame`. + +`PUBLIC_BASE_URL` in Home Assistant mode must represent the panel-facing LAN address, not the Ingress path. + +The current application auto-detects a non-loopback interface for `PUBLIC_BASE_URL`. Inside a Home Assistant container that may be a container/bridge address and therefore cannot be trusted as the ESP32-visible URL. Home Assistant packaging must provide an explicit or reliably discovered panel base URL. + +## Ingress compatibility + +The current frontend contains root-absolute requests such as `/api/...` and root-absolute image URLs. Those cannot be assumed to work correctly behind an arbitrary Ingress path prefix. + +Before enabling Ingress for production, browser requests must become base-path aware. The implementation should use one central browser API/path helper rather than patching each widget ad hoc. + +Login redirects must also be base-path aware. + +Ingress authentication must not accidentally make the LAN admin interface unauthenticated. The implementation must preserve an explicit trust boundary between Home Assistant-authenticated Ingress traffic and normal LAN traffic. + +## Home Assistant authentication + +When running as a Home Assistant App: + +- `SUPERVISOR_TOKEN` is server-side only; +- it is sent as `Authorization: Bearer ` to the Home Assistant Core proxy; +- it must never be returned to the InkPanel browser, DeviceStore, remembered settings, source cache, framebuffer, logs or ESP32 firmware; +- missing/invalid Supervisor credentials must degrade Home Assistant-backed widgets to a clear unavailable/not-configured state without breaking standalone InkPanel sources. + +Standalone mode may later support an optional manually-configured HA URL/token, but this is explicitly outside the initial App milestone. + +## Home Assistant client + +Add one shared server-side Home Assistant client rather than implementing separate HTTP code in each widget. + +Initial responsibilities: + +- health/config probe; +- list/read entity states; +- list calendar entities and read events; +- call response-returning actions such as `todo.get_items`; +- bounded request timeouts; +- validation of response shapes; +- safe errors without token reflection; +- test seams for deterministic fixtures. + +Potential later responsibility: + +- WebSocket subscriptions for change-driven invalidation or richer discovery. + +V1 should prefer simple HTTP snapshot reads because InkPanel renders on demand and e-paper panels do not need a continuously streaming backend to function. + +## Provider roadmap + +### Phase HA-1 — App/runtime foundation + +- Home Assistant repository metadata and App metadata. +- `/data` persistence. +- Ingress-compatible browser path handling. +- secure panel-facing LAN URL boundary. +- Home Assistant runtime detection and client. +- authenticated health/status diagnostics in Settings. +- CI validation for standalone mode and Home Assistant mode. + +No existing widget should change source in this phase. + +### Phase HA-2 — Home Assistant Calendar + +Extend Calendar configuration with a provider choice: + +- existing iCal URLs; +- Home Assistant calendar entity/entities. + +Home Assistant mode should discover `calendar.*` entities and read events using Home Assistant's calendar API. The existing normalized InkPanel Calendar data/rendering should be reused where possible so selecting HA does not create a second visual design. + +Existing iCal behaviour remains unchanged. + +### Phase HA-3 — Home Assistant To Do + +Extend To Do configuration with a provider choice: + +- existing InkPanel local named list; +- Home Assistant `todo.*` entity. + +Use `todo.get_items` for incomplete items. Preserve InkPanel's current local-list store and editing behaviour. + +A later milestone may allow add/update/complete/delete actions against Home Assistant lists from Studio. The first HA To Do milestone may be read-only if that reduces integration risk. + +### Phase HA-4 — Home Assistant Entities + +Add a generic widget type such as `home_assistant` or `ha_entities`. + +It should allow selecting useful entities from Home Assistant and display normalized rows such as: + +- entity friendly name; +- state; +- unit of measurement where applicable; +- optional icon/category metadata used only by Studio, not required by the monochrome framebuffer. + +Initial supported domains should prioritize display-oriented state: + +- `sensor.*` +- `binary_sensor.*` +- `weather.*` +- `climate.*` +- `person.*` +- `lock.*` +- `alarm_control_panel.*` + +The data model must be generic enough that additional domains can be enabled later without a DeviceStore migration. + +Example physical content: + +``` +HOME +------------------------ +Living room 21.4 C +Outside 16 C +Solar 2.8 kW +House 1.3 kW +Battery 78% +Front door LOCKED +``` + +Mini should use a reduced single-focus or short-list layout rather than attempting to mirror a large full-size list blindly. + +### Phase HA-5 — richer Home Assistant capabilities + +Possible later work: + +- weather provider sourced from `weather.*`; +- energy/power presets built from selected sensors; +- person/presence summary; +- door/window/security summary; +- change-driven server invalidation through the HA WebSocket API; +- safe Studio actions where interaction is genuinely useful. + +These must remain optional and additive. + +## Data and hashing rules + +Home Assistant-backed data follows the same e-paper rules as existing widgets: + +- hash only values that are visibly rendered; +- do not hash HA timestamps, contexts or hidden attributes merely because they changed; +- unchanged visible state preserves the existing ETag/304/no-refresh behaviour; +- one unavailable HA entity should not unnecessarily break unrelated widgets; +- stale behaviour must be chosen per provider. Security/door state should not silently display old values as current; calendar semantics may differ. + +## Persistence + +Home Assistant entity IDs/provider selections may be stored in widget config/remembered preferences because they are not secrets. + +Never persist `SUPERVISOR_TOKEN`. + +App runtime state remains under `/data`, including the existing: + +- `config.json`; +- source cache; +- remembered editor preferences; +- local To Do lists; +- printer connections; +- managed provider credentials; +- session secret; +- generated HTTPS material if still required in HA mode. + +## Firmware compatibility + +Home Assistant integration should require no ESP32 firmware protocol change. + +Both existing hardware profiles remain authoritative: + +- `wft0583-800x480-mono` — 800x480 / 48,000 bytes / four widgets; +- `ssd1681-200x200-mono` — 200x200 / 5,000 bytes / one widget. + +The server continues to render normalized widget data into the existing frame protocol. + +## App repository/package shape + +Current Home Assistant requires `repository.yaml` at the repository root for an App repository. The InkPanel App itself should live in a dedicated folder so the existing application source can remain at repository root. + +Preferred production distribution is a pre-built multi-architecture image in GHCR rather than asking every Home Assistant host to compile Chromium and Node dependencies locally. + +Conceptual shape: + +``` +repository.yaml +home-assistant/ + inkpanel/ + config.yaml + README.md + DOCS.md + CHANGELOG.md + icon.png + logo.png + +Dockerfile +src/ +public/ +firmware/ +... +``` + +The App `config.yaml` can reference a pre-built generic multi-arch GHCR image. The existing root Dockerfile should remain usable for normal Docker deployments; Home Assistant-specific startup/config translation should be implemented without duplicating the InkPanel application source tree. + +## WebFlash note + +Home Assistant Ingress does not remove WebSerial's browser secure-context requirement. The existing InkPanel HTTPS/WebFlash path must be validated on a real Home Assistant installation before it is declared supported through Ingress. + +The first Home Assistant App milestone must not break the current external HTTPS flashing path. If necessary, WebFlash may remain an explicitly separate LAN URL while the normal Studio uses Ingress. + +## Acceptance gates + +Before merging Home Assistant work back toward `main`: + +1. Standalone CI remains green. +2. Existing 7.5-inch and Mini firmware builds remain green. +3. Home Assistant App metadata validates. +4. App starts with `/data` persistence. +5. Studio loads correctly through a non-root Ingress prefix. +6. ESP32 frame requests work through the separate LAN panel URL. +7. Supervisor token is never exposed to browser/API responses/logs. +8. HA connection status can be diagnosed from Settings. +9. At least one real Home Assistant entity can be read from the user's installation before building higher-level widgets. +10. Existing non-HA widgets remain fully usable in the Home Assistant deployment. From c413f094c0eb51190edf7de227dfb6817bada6a0 Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Mon, 24 Aug 2026 15:52:31 +0100 Subject: [PATCH 02/14] Add Home Assistant App runtime foundation --- .github/workflows/home-assistant-image.yml | 82 ++++++++++++++ Dockerfile.home-assistant | 31 ++++++ docs/home-assistant-app.md | 31 +++--- home-assistant/CHANGELOG.md | 8 ++ home-assistant/DOCS.md | 31 ++++++ home-assistant/README.md | 10 ++ home-assistant/config.yaml | 30 +++++ package-lock.json | 19 +++- package.json | 3 +- public/api.js | 6 +- public/flash.js | 42 +++++-- public/index.html | 2 +- public/login.html | 5 +- public/panels.js | 5 +- public/paths.js | 17 +++ public/privacy.html | 2 +- public/settings.js | 26 ++++- public/terms.html | 2 +- repository.yaml | 3 + scripts/home-assistant-start.mjs | 51 +++++++++ src/homeAssistant/client.ts | 123 +++++++++++++++++++++ src/http/app.ts | 55 ++++++++- src/http/homeAssistantRoutes.ts | 11 ++ src/index.ts | 68 +++++++++--- test/homeAssistant/client.test.ts | 69 ++++++++++++ test/homeAssistant/package.test.ts | 49 ++++++++ test/homeAssistant/startup.test.js | 23 ++++ test/http/homeAssistantIngress.test.ts | 84 ++++++++++++++ test/indexConfig.test.ts | 10 +- test/public/flash.test.js | 56 ++++++++-- test/public/paths.test.js | 32 ++++++ test/public/settings.test.js | 23 +++- 32 files changed, 948 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/home-assistant-image.yml create mode 100644 Dockerfile.home-assistant create mode 100644 home-assistant/CHANGELOG.md create mode 100644 home-assistant/DOCS.md create mode 100644 home-assistant/README.md create mode 100644 home-assistant/config.yaml create mode 100644 public/paths.js create mode 100644 repository.yaml create mode 100644 scripts/home-assistant-start.mjs create mode 100644 src/homeAssistant/client.ts create mode 100644 src/http/homeAssistantRoutes.ts create mode 100644 test/homeAssistant/client.test.ts create mode 100644 test/homeAssistant/package.test.ts create mode 100644 test/homeAssistant/startup.test.js create mode 100644 test/http/homeAssistantIngress.test.ts create mode 100644 test/public/paths.test.js diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml new file mode 100644 index 0000000..d53f7e1 --- /dev/null +++ b/.github/workflows/home-assistant-image.yml @@ -0,0 +1,82 @@ +name: Home Assistant App image + +on: + push: + branches: [Home-Assistant] + paths: + - Dockerfile.home-assistant + - home-assistant/** + - repository.yaml + - package*.json + - public/** + - scripts/home-assistant-start.mjs + - src/** + - .github/workflows/home-assistant-image.yml + pull_request: + branches: [main] + paths: + - Dockerfile.home-assistant + - home-assistant/** + - repository.yaml + - package*.json + - public/** + - scripts/home-assistant-start.mjs + - src/** + - .github/workflows/home-assistant-image.yml + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + +env: + IMAGE_NAME: inkpanel-home-assistant + VERSION: 0.1.0-ha.1 + ARCHITECTURES: '["amd64", "aarch64"]' + +jobs: + init: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.prepare.outputs.matrix }} + steps: + - id: prepare + uses: home-assistant/builder/actions/prepare-multi-arch-matrix@4de35182ce1e329181bffcbcc84d33db5e2c7e10 + with: + architectures: ${{ env.ARCHITECTURES }} + image-name: ${{ env.IMAGE_NAME }} + registry-prefix: ghcr.io/ctrlaltcouk + + build: + needs: init + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.init.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 + with: + image: ${{ matrix.image }} + arch: ${{ matrix.arch }} + version: ${{ env.VERSION }} + image-tags: ${{ env.VERSION }} + context: . + file: Dockerfile.home-assistant + push: ${{ github.event_name == 'push' }} + cosign: ${{ github.event_name == 'push' }} + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + + publish: + if: github.event_name == 'push' + needs: build + runs-on: ubuntu-latest + steps: + - uses: home-assistant/builder/actions/publish-multi-arch-manifest@4de35182ce1e329181bffcbcc84d33db5e2c7e10 + with: + image-name: ${{ env.IMAGE_NAME }} + image-tags: ${{ env.VERSION }} + architectures: ${{ env.ARCHITECTURES }} + registry-prefix: ghcr.io/ctrlaltcouk + container-registry-password: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile.home-assistant b/Dockerfile.home-assistant new file mode 100644 index 0000000..008f1d4 --- /dev/null +++ b/Dockerfile.home-assistant @@ -0,0 +1,31 @@ +# This version must match the Playwright package, as enforced by the existing +# Docker regression test. +FROM mcr.microsoft.com/playwright:v1.62.1-noble + +ARG BUILD_VERSION +ARG BUILD_ARCH +LABEL io.hass.version="${BUILD_VERSION}" \ + io.hass.arch="${BUILD_ARCH}" \ + io.hass.type="addon" + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY . . + +ENV DATA_DIR=/data \ + PORT=8080 \ + HTTPS_PORT=8443 \ + HOME_ASSISTANT_MODE=1 \ + HOME_ASSISTANT_INGRESS_PORT=8099 \ + HOME_ASSISTANT_BASE_URL=http://supervisor/core/api + +VOLUME ["/data"] +EXPOSE 8080 8443 8099 + +HEALTHCHECK --interval=60s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "scripts/home-assistant-start.mjs"] diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 5182851..0e222ce 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -43,7 +43,7 @@ The App will: - expose a LAN-reachable panel endpoint separately from the Ingress browser path; - not ask the user to copy a Home Assistant long-lived token. -The App packaging should support `amd64` and `aarch64` if the Chromium/Playwright production image is verified on both architectures. +The App image is built for `amd64` and `aarch64` from the same Playwright base used by standalone InkPanel. ## Critical network boundary: Ingress is not the panel URL @@ -231,33 +231,38 @@ Current Home Assistant requires `repository.yaml` at the repository root for an Preferred production distribution is a pre-built multi-architecture image in GHCR rather than asking every Home Assistant host to compile Chromium and Node dependencies locally. -Conceptual shape: +Repository shape: ``` repository.yaml home-assistant/ - inkpanel/ - config.yaml - README.md - DOCS.md - CHANGELOG.md - icon.png - logo.png - -Dockerfile + config.yaml + README.md + DOCS.md + CHANGELOG.md + +Dockerfile.home-assistant src/ public/ firmware/ ... ``` -The App `config.yaml` can reference a pre-built generic multi-arch GHCR image. The existing root Dockerfile should remain usable for normal Docker deployments; Home Assistant-specific startup/config translation should be implemented without duplicating the InkPanel application source tree. +The App `config.yaml` references `ghcr.io/ctrlaltcouk/inkpanel-home-assistant`. The existing root Dockerfile remains the standalone image; `Dockerfile.home-assistant` and the startup adapter add App packaging without duplicating the application source tree. + +The App starts three deliberately separate listeners: + +- internal port `8099` is the Ingress Studio and accepts only Supervisor proxy traffic; +- LAN HTTP port `8080` retains InkPanel password/session authentication and serves physical panel frames; +- LAN HTTPS port `8443` retains the secure WebFlash Studio. + +The App options adapter requires `panel_base_url` and `lan_password`, sets `/data` persistence, and obtains Home Assistant API authority exclusively from the runtime `SUPERVISOR_TOKEN`. ## WebFlash note Home Assistant Ingress does not remove WebSerial's browser secure-context requirement. The existing InkPanel HTTPS/WebFlash path must be validated on a real Home Assistant installation before it is declared supported through Ingress. -The first Home Assistant App milestone must not break the current external HTTPS flashing path. If necessary, WebFlash may remain an explicitly separate LAN URL while the normal Studio uses Ingress. +The Ingress Flash tab remains present. When WebSerial is unavailable there, it links to the active direct HTTPS Studio root instead of trying to flash inside Ingress or guessing an address. Both existing firmware targets remain available through that direct Studio. ## Acceptance gates diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md new file mode 100644 index 0000000..6631122 --- /dev/null +++ b/home-assistant/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0-ha.1 + +- Add the first Home Assistant App package for amd64 and aarch64. +- Add Studio support for arbitrary Ingress path prefixes. +- Keep panel HTTP and WebFlash HTTPS available as explicit LAN services. +- Add a safe Home Assistant Core connection status in Settings. diff --git a/home-assistant/DOCS.md b/home-assistant/DOCS.md new file mode 100644 index 0000000..045e031 --- /dev/null +++ b/home-assistant/DOCS.md @@ -0,0 +1,31 @@ +# InkPanel Home Assistant App + +This App is experimental. Install it by adding the following App repository in Home Assistant: + +`https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` + +## Configuration + +- `panel_base_url` — the LAN HTTP URL that physical InkPanel devices can reach, for example `http://192.168.1.20:8080`. Use only the origin: no path, query or fragment. +- `lan_password` — required password for the direct LAN Studio. Home Assistant Ingress uses your Home Assistant session instead. + +Save both options and start the App. Open **Web UI** for the Ingress-hosted Studio. + +## Network ports + +- `8099` is internal Ingress traffic only and is not published to the host. +- `8080` is the panel-facing HTTP API and direct LAN Studio. +- `8443` is the self-signed HTTPS Studio used for browser WebSerial flashing. + +The Flash tab remains visible through Ingress. If the browser cannot use WebSerial in the Ingress context, it offers the direct HTTPS Studio address. Accept the local self-signed certificate warning once, then use either supported hardware target normally. + +## Persistence and backups + +All configuration, caches, local lists, connection settings, session material and generated HTTPS material live under `/data`. Home Assistant cold backups include this directory. + +## Home Assistant connection + +The App uses the Supervisor-provided, process-only token to query Home Assistant Core. It never sends that token to the browser or stores it in InkPanel configuration. The Settings page reports a safe connection status. + +Phase HA-1 establishes deployment and runtime integration only. It does not add Home Assistant-backed widgets yet. +Later phases will add optional Home Assistant Calendar, To Do and Entity providers while retaining all existing InkPanel sources. diff --git a/home-assistant/README.md b/home-assistant/README.md new file mode 100644 index 0000000..815de9c --- /dev/null +++ b/home-assistant/README.md @@ -0,0 +1,10 @@ +# InkPanel + +InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. + +This first `0.1.0-ha.1` release is experimental. Add +`https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. + +The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. + +See the **Documentation** tab before starting the App. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml new file mode 100644 index 0000000..e4f6725 --- /dev/null +++ b/home-assistant/config.yaml @@ -0,0 +1,30 @@ +name: InkPanel +version: 0.1.0-ha.1 +slug: inkpanel +description: Self-hosted e-paper dashboard server and Studio +url: https://github.com/CtrlAltcouk/inkpanel +image: ghcr.io/ctrlaltcouk/inkpanel-home-assistant +arch: + - amd64 + - aarch64 +stage: experimental +startup: application +boot: auto +init: false +ingress: true +ingress_port: 8099 +panel_icon: mdi:tablet-dashboard +homeassistant_api: true +ports: + 8080/tcp: 8080 + 8443/tcp: 8443 +ports_description: + 8080/tcp: Panel-facing HTTP and authenticated Studio + 8443/tcp: Secure WebFlash page +options: + panel_base_url: null + lan_password: null +schema: + panel_base_url: url + lan_password: password +backup: cold diff --git a/package-lock.json b/package-lock.json index f0105ea..eefc010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "@types/node": "^24.13.2", "esbuild": "^0.28.1", "esptool-js": "^0.6.1", - "typescript": "~6.0.2" + "typescript": "~6.0.2", + "yaml": "^2.9.0" }, "engines": { "node": ">=22" @@ -2276,6 +2277,22 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 317a0ea..fa0f4e3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/node": "^24.13.2", "esbuild": "^0.28.1", "esptool-js": "^0.6.1", - "typescript": "~6.0.2" + "typescript": "~6.0.2", + "yaml": "^2.9.0" } } diff --git a/public/api.js b/public/api.js index a8961fa..69d8eb2 100644 --- a/public/api.js +++ b/public/api.js @@ -1,3 +1,5 @@ +import { appPath } from './paths.js'; + export class ApiError extends Error { constructor(message, status, issues) { super(message); @@ -7,7 +9,7 @@ export class ApiError extends Error { } async function request(method, path, body) { - const res = await fetch(path, { + const res = await fetch(appPath(path), { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, @@ -16,7 +18,7 @@ async function request(method, path, body) { // A 401 means the password was set, or the session expired. Either way the // only useful action is to send the user to sign in. if (res.status === 401) { - location.href = '/login.html'; + location.href = appPath('/login.html'); throw new ApiError('authentication required', 401); } diff --git a/public/flash.js b/public/flash.js index 81b4ccc..ecc90af 100644 --- a/public/flash.js +++ b/public/flash.js @@ -8,6 +8,7 @@ import { getJson } from './api.js'; import { esc } from './components.js'; import { addFlashProvisioning } from './flashProvisioningImage.js'; +import { appPath } from './paths.js'; const USB_BAUD = 115200; const PROVISION_READY = 'INKPANEL_READY_V1'; @@ -27,20 +28,45 @@ export function httpsUrl(httpsPort, href = window.location.href) { const url = new URL(href); url.protocol = 'https:'; url.port = String(httpsPort); + url.pathname = '/'; + url.search = ''; + url.hash = '#flash'; return url.toString(); } +export function safeWebFlashUrl(value) { + if (typeof value !== 'string') return null; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password) return null; + return url.toString(); + } catch { + return null; + } +} + function looksLikeChromiumFamily() { const ua = navigator.userAgent || ''; return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\//.test(ua); } -export function unsupportedNotice(httpsPort) { - if (window.isSecureContext === false && looksLikeChromiumFamily()) { +export function unsupportedNotice(httpsPort, webFlashUrl = null) { + const directUrl = safeWebFlashUrl(webFlashUrl); + if (looksLikeChromiumFamily() && (window.isSecureContext === false || directUrl)) { const hasPort = Number.isInteger(httpsPort) && httpsPort >= 1 && httpsPort <= 65535; - const link = hasPort - ? `

Open inkpanel over HTTPS and come back to this tab.

` + const secureUrl = directUrl ?? (hasPort ? httpsUrl(httpsPort) : null); + const link = secureUrl + ? `

Open inkpanel over HTTPS and come back to this tab.

` : `

InkPanel could not load its secure-connection settings. Reload this page or check the server logs; no HTTPS address has been guessed.

`; + if (window.isSecureContext !== false && directUrl) { + return `
+

Flashing needs the direct secure Studio

+

Home Assistant Ingress cannot provide the direct browser-to-USB connection used by WebSerial.

+ ${link} +

The certificate is self-signed, so your browser will warn you once. + That is expected on a local network.

+
`; + } return `

Flashing needs a secure connection

Browsers only allow USB access over HTTPS. This page is on plain HTTP, @@ -73,7 +99,7 @@ export async function fetchBinary(name, targetId = 'full') { const targetPath = targetId === 'full' ? `/api/firmware/bin/${encodedName}` : `/api/firmware/targets/${encodeURIComponent(targetId)}/bin/${encodedName}`; - const res = await fetch(targetPath); + const res = await fetch(appPath(targetPath)); if (!res.ok) throw new Error(`could not download ${name} (${res.status})`); const bytes = new Uint8Array(await res.arrayBuffer()); @@ -430,18 +456,20 @@ function newBoardConfigFromUi(root) { export async function renderFlash(root) { if (!serialSupported()) { let httpsPort; - if (window.isSecureContext === false && looksLikeChromiumFamily()) { + let webFlashUrl; + if (looksLikeChromiumFamily()) { try { const runtime = await getJson('/api/runtime-config'); if (Number.isInteger(runtime?.httpsPort) && runtime.httpsPort >= 1 && runtime.httpsPort <= 65535) { httpsPort = runtime.httpsPort; } + webFlashUrl = safeWebFlashUrl(runtime?.webFlashUrl); } catch { // The notice below explains that no secure URL could be determined. } } - root.innerHTML = unsupportedNotice(httpsPort); + root.innerHTML = unsupportedNotice(httpsPort, webFlashUrl); return; } diff --git a/public/index.html b/public/index.html index 14fba4f..823f2c3 100644 --- a/public/index.html +++ b/public/index.html @@ -32,7 +32,7 @@

diff --git a/public/login.html b/public/login.html index 5d624cb..656b514 100644 --- a/public/login.html +++ b/public/login.html @@ -16,6 +16,7 @@

inkpanel

`)); + app.use(express.static(join(process.cwd(), 'public'))); + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + try { + await page.goto(`http://127.0.0.1:${server.address().port}/harness`); + await page.locator('[data-calendar-provider]').waitFor(); + assert.equal(await page.locator('[data-calendar-provider]').inputValue(), 'ical'); + await page.locator('[data-dashboard-select="3"]').click(); + await page.locator('[data-bins-uprn]').fill('456'); + let saved = await page.evaluate(() => window.collect()); + assert.deepEqual(saved.sections[0], { type: 'calendar', version: 1, config: { calendarUrls: ['https://example.com/feed'] } }); + await page.locator('[data-dashboard-select="0"]').click(); + await page.locator('[data-calendar-urls]').fill('https://edited.example/feed'); + assert.equal((await page.evaluate(() => window.collect())).sections[0].version, 1); + await page.locator('[data-calendar-provider]').selectOption('home-assistant'); + assert.match(await page.locator('#editor').textContent(), /Family calendar/); + for (const checkbox of await page.locator('[data-ha-calendar]').all()) await checkbox.check(); + saved = await page.evaluate(() => window.collect()); + assert.deepEqual(saved.sections[0], { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.family', 'calendar.work'] } }); + assert.equal(saved.remembered.slots[0][0].version, 2); + await page.locator('[data-calendar-provider]').selectOption('ical'); + assert.equal(await page.locator('[data-calendar-urls]').inputValue(), 'https://edited.example/feed'); + assert.equal((await page.evaluate(() => window.collect())).sections[0].version, 2); + await page.locator('[data-calendar-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-calendar]:checked').count(), 2); + assert.equal(await page.locator('#state').textContent(), 'Unsaved'); + await page.evaluate(() => window.load({ type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.deleted'] } }, { supported: true, available: true, calendars: [] })); + assert.match(await page.locator('#editor').textContent(), /calendar.deleted \(missing\/unavailable\)/); + assert.deepEqual((await page.evaluate(() => window.collect())).sections[0].config.entityIds, ['calendar.deleted']); + await page.evaluate(() => window.load({ type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: [] } }, { supported: true, available: true, calendars: Array.from({ length: 11 }, (_, i) => ({ entityId: `calendar.c${i}`, name: `Calendar ${i}` })) })); + for (const checkbox of (await page.locator('[data-ha-calendar]').all()).slice(0, 10)) await checkbox.check(); + await page.locator('[data-ha-calendar]').last().click(); + assert.equal(await page.locator('[data-ha-calendar]:checked').count(), 10); + assert.match(await page.locator('[data-calendar-error]').textContent(), /at most 10/); + } finally { await browser.close(); await new Promise((resolve) => server.close(resolve)); } +}); diff --git a/test/render/homeAssistantCalendar.test.ts b/test/render/homeAssistantCalendar.test.ts new file mode 100644 index 0000000..1801eb9 --- /dev/null +++ b/test/render/homeAssistantCalendar.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FrameService } from '../../src/render/frameService.ts'; +import { Renderer } from '../../src/render/browser.ts'; +import { SourceCache } from '../../src/sources/cache.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { defaultDevice, type DeviceRecord } from '../../src/devices/types.ts'; +import { localDateKey } from '../../src/sources/ical.ts'; +import { WFT0583, SSD1681_200X200 } from '../../src/panel/profile.ts'; +import { WEATHER } from '../fixtures/dashboard.ts'; + +for (const profile of [WFT0583, SSD1681_200X200]) test(`HA Calendar uses the unchanged ${profile.id} renderer and stable visible content memo`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-ha-frame-')); + const renderer = new Renderer(); + let screenshots = 0; let lastHtml = ''; let title = 'HA family birthday'; let uid = 'first'; let reverse = false; + const today = localDateKey(new Date(), 'Europe/London'); + const tomorrow = new Date(Date.parse(`${today}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); + const client = new HomeAssistantClient({ enabled: true, token: 'secret-frame-token', fetchImpl: async (url) => { + if (String(url).includes('calendar.failed')) return new Response('secret error', { status: 503 }); + const events = [ + { summary: title, uid, start: { date: today }, end: { date: tomorrow }, description: 'metadata' }, + { summary: 'Another event', uid: 'other', start: { date: today }, end: { date: tomorrow } }, + ]; + return Response.json(reverse ? events.reverse() : events); + } }); + let icalCalls = 0; + const service = new FrameService({ + renderer: { screenshot: async (html, panel) => { screenshots++; lastHtml = html; return renderer.screenshot(html, panel); } } as Renderer, + cache: new SourceCache(dir), homeAssistantClient: client, + weatherSource: { id: 'weather', fetch: async () => ({ status: 'ok', data: WEATHER, fetchedAt: new Date().toISOString() }) }, + calendarSource: { id: 'ical', fetch: async () => { icalCalls++; return { status: 'ok', data: 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR', fetchedAt: new Date().toISOString() }; } }, + }); + const calendar = { type: 'calendar' as const, version: 2 as const, config: { provider: 'home-assistant' as const, entityIds: ['calendar.family'] } }; + const device: DeviceRecord = { ...defaultDevice('ha-panel'), claimed: true, panelProfileId: profile.dashboardSlots === 1 ? 'ssd1681-200x200-mono' : 'wft0583-800x480-mono', dashboardSections: profile.dashboardSlots === 1 ? [calendar] : [calendar, { type: 'weather', version: 1, config: {} }, { type: 'empty', version: 1, config: {} }, { type: 'empty', version: 1, config: {} }] }; + try { + const frame = await service.frameFor(device, 4); + assert.equal(frame.buffer.length, profile.width * profile.height / 8); + assert.match(lastHtml, /HA family birthday/); + assert.doesNotMatch(lastHtml, /secret-frame-token|metadata|first/); + uid = 'changed-hidden-uid'; reverse = true; + const again = await service.frameFor(device, 4); + assert.equal(again.etag, frame.etag); assert.equal(screenshots, 1); + title = 'Changed visible title'; + await service.frameFor(device, 4); assert.equal(screenshots, 2); + assert.equal(icalCalls, 0, 'HA never invokes the iCal runner'); + for (const widget of [ + { type: 'calendar' as const, version: 1 as const, config: { calendarUrls: ['https://example.com/feed'] } }, + { type: 'calendar' as const, version: 2 as const, config: { provider: 'ical' as const, calendarUrls: ['https://example.com/feed'] } }, + ]) await service.previewHtml({ ...device, dashboardSections: [widget, ...device.dashboardSections.slice(1)] }); + assert.equal(icalCalls, 2, 'both iCal widget versions use the existing runner'); + const failed = { ...calendar, config: { ...calendar.config, entityIds: ['calendar.failed'] } }; + const html = await service.previewHtml({ ...device, dashboardSections: [failed, ...device.dashboardSections.slice(1)] }); + const body = html.split('')[1]!; + assert.match(body, /unavailable/i); + if (profile.dashboardSlots === 4) assert.match(body, /Next 3 days/); + } finally { await renderer.close(); await rm(dir, { recursive: true, force: true }); } +}); diff --git a/test/sources/homeAssistantCalendar.test.ts b/test/sources/homeAssistantCalendar.test.ts new file mode 100644 index 0000000..579c8f0 --- /dev/null +++ b/test/sources/homeAssistantCalendar.test.ts @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { homeAssistantCalendarEventsSchema, type HomeAssistantCalendarEvent } from '../../src/homeAssistant/calendarSchemas.ts'; +import { SourceCache } from '../../src/sources/cache.ts'; +import { homeAssistantCalendarWindow, normalizeHomeAssistantCalendars, runHomeAssistantCalendars } from '../../src/sources/homeAssistantCalendar.ts'; + +const timed = (summary: string, start: string, end = start): HomeAssistantCalendarEvent => ({ summary, start: { dateTime: start }, end: { dateTime: end } }); +const allDay = (summary: string, start: string, end: string): HomeAssistantCalendarEvent => ({ summary, start: { date: start }, end: { date: end } }); +const normalize = (events: HomeAssistantCalendarEvent[], now = '2026-08-27T12:00:00Z', timezone = 'Europe/London') => normalizeHomeAssistantCalendars([{ entityId: 'calendar.home', events }], new Date(now), timezone); + +test('HA timed and all-day events populate today/tomorrow with exclusive date ends', () => { + const data = normalize([ + timed('Today', '2026-08-27T14:00:00+01:00', '2026-08-27T15:00:00+01:00'), + timed('Tomorrow', '2026-08-28T14:00:00+01:00'), + allDay('Birthday', '2026-08-27', '2026-08-28'), + allDay('Holiday', '2026-08-28', '2026-08-29'), + allDay('Ended', '2026-08-26', '2026-08-27'), + allDay('Multi-day', '2026-08-26', '2026-08-29'), + ]); + assert.deepEqual(data.today.map((event) => event.title), ['Multi-day', 'Birthday', 'Today']); + assert.deepEqual(data.tomorrow.map((event) => event.title), ['Multi-day', 'Holiday', 'Tomorrow']); + assert.equal(data.today[1]!.allDay, true); + assert.equal(data.today[2]!.start, '2026-08-27T13:00:00.000Z'); +}); + +for (const [label, now, zone, today, tomorrow] of [ + ['BST midnight', '2026-08-27T23:30:00Z', 'Europe/London', '2026-08-27T23:15:00Z', '2026-08-28T23:15:00Z'], + ['GMT change', '2026-10-25T12:00:00Z', 'Europe/London', '2026-10-25T01:30:00+01:00', '2026-10-26T00:30:00Z'], + ['BST change', '2026-03-29T12:00:00Z', 'Europe/London', '2026-03-29T00:30:00Z', '2026-03-29T23:30:00Z'], + ['New York midnight', '2026-08-28T02:00:00Z', 'America/New_York', '2026-08-28T03:30:00Z', '2026-08-28T04:30:00Z'], + ['New York DST', '2026-11-01T12:00:00Z', 'America/New_York', '2026-11-01T01:30:00-04:00', '2026-11-02T00:30:00-05:00'], + ['Auckland midnight', '2026-08-27T12:30:00Z', 'Pacific/Auckland', '2026-08-27T12:15:00Z', '2026-08-28T12:15:00Z'], +]) test(`HA date classification uses panel timezone: ${label}`, () => { + const data = normalize([timed('today', today!), timed('tomorrow', tomorrow!)], now, zone); + assert.deepEqual(data.today.map((event) => event.title), ['today']); + assert.deepEqual(data.tomorrow.map((event) => event.title), ['tomorrow']); +}); + +test('floating all-day dates never shift west/east and the bounded range covers extreme offsets', () => { + for (const zone of ['America/Los_Angeles', 'Pacific/Kiritimati']) { + const now = zone === 'Pacific/Kiritimati' ? '2026-08-27T00:00:00Z' : '2026-08-27T12:00:00Z'; + const data = normalize([allDay('day', '2026-08-27', '2026-08-28')], now, zone); + assert.equal(data.today[0]!.title, 'day'); + assert.equal(data.tomorrow.length, 0); + assert.deepEqual(homeAssistantCalendarWindow(new Date(now), zone), { start: '2026-08-26T00:00:00.000Z', end: '2026-08-30T00:00:00.000Z' }); + } +}); + +test('fallback UIDs, title trimming and ordering are deterministic; irrelevant fields are discarded', () => { + const events = homeAssistantCalendarEventsSchema.parse([ + { ...timed(' Z ', '2026-08-27T12:00:00Z'), uid: '', description: 'secret', location: 'ignored' }, + timed(' A ', '2026-08-27T12:00:00Z'), + { ...allDay(' ', '2026-08-27', '2026-08-28'), uid: 'ha-uid' }, + ]); + const first = normalize(events); + assert.deepEqual(normalize([...events].reverse()), first); + assert.deepEqual(first.today.map((event) => event.title), ['(no title)', 'A', 'Z']); + assert.equal(first.today[0]!.uid, 'ha-uid'); + assert.equal(first.today[2]!.uid.length, 64); + assert.doesNotMatch(JSON.stringify(first), /secret|ignored/); +}); + +test('HA calendars fetch concurrently, deduplicate IDs, aggregate failure and isolate stale caches', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-ha-cal-')); + const cache = new SourceCache(dir); + const options = { deviceId: 'panel', timeoutMs: 1000, now: new Date('2026-08-27T12:00:00Z') }; + const token = 'never-cache-supervisor-token'; + let failing = false; + let concurrent = 0; let peak = 0; let calls = 0; + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async (url) => { + calls++; concurrent++; peak = Math.max(peak, concurrent); + await new Promise((resolve) => setTimeout(resolve, 10)); concurrent--; + if (failing || String(url).includes('calendar.bad')) return new Response('private detail', { status: 500 }); + return Response.json([allDay('Available', '2026-08-27', '2026-08-28')]); + } }); + try { + const partial = await runHomeAssistantCalendars(['calendar.good', 'calendar.bad', 'calendar.good'], 'Europe/London', client, cache, options); + assert.equal(calls, 2); assert.equal(peak, 2); + assert.equal(partial.data!.today[0]!.title, 'Available'); + assert.equal(partial.health.status, 'error'); + assert.equal(partial.health.error, '1 of 2 Home Assistant calendars unavailable'); + const multiple = await runHomeAssistantCalendars(['calendar.good', 'calendar.other'], 'Europe/London', client, cache, options); + assert.equal(multiple.health.status, 'ok'); assert.equal(multiple.data!.today.length, 2); + const healthy = await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', client, cache, options); + assert.equal(healthy.health.status, 'ok'); + failing = true; + const stale = await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', client, cache, options); + assert.equal(stale.health.status, 'stale'); assert.deepEqual(stale.data, healthy.data); + const allFailed = await runHomeAssistantCalendars(['calendar.bad'], 'Europe/London', client, cache, options); + assert.equal(allFailed.data, null); assert.equal(allFailed.health.status, 'error'); + const other = new HomeAssistantClient({ enabled: true, baseUrl: 'http://other/core/api/', token, fetchImpl: async () => { throw new Error(token); } }); + assert.equal((await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', other, cache, options)).data, null); + assert.equal((await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', client, cache, { ...options, deviceId: 'other-panel' })).data, null); + assert.equal((await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', client, cache, { ...options, now: new Date('2026-08-28T12:00:00Z') })).data, null); + assert.equal((await runHomeAssistantCalendars(['calendar.good'], 'Europe/London', undefined, cache, options)).data, null); + for (const name of await readdir(dir)) assert.doesNotMatch(name + await readFile(join(dir, name), 'utf8'), /never-cache|supervisor|private detail/); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/test/widgets/calendarV2.test.ts b/test/widgets/calendarV2.test.ts new file mode 100644 index 0000000..0d33e8d --- /dev/null +++ b/test/widgets/calendarV2.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { calendarWidgetConfigV1Schema, calendarWidgetV1Schema, dashboardWidgetSchema } from '../../src/widgets/registry.ts'; + +test('Calendar V1 stays frozen and Calendar V2 has strict separate providers', () => { + const legacy = { type: 'calendar', version: 1, config: { calendarUrls: ['http://192.168.1.2/feed'] } }; + assert.deepEqual(calendarWidgetV1Schema.parse(legacy), legacy); + assert.equal(calendarWidgetConfigV1Schema.safeParse({ provider: 'ical', calendarUrls: [] }).success, false); + for (const config of [ + { provider: 'ical', calendarUrls: ['https://calendar.example/feed'] }, + { provider: 'home-assistant', entityIds: ['calendar.family', 'calendar.birthdays'] }, + ]) assert.deepEqual(dashboardWidgetSchema.parse({ type: 'calendar', version: 2, config }).config, config); + for (const config of [ + { provider: 'ical', calendarUrls: [], entityIds: [] }, + { provider: 'home-assistant', entityIds: [], calendarUrls: [] }, + { provider: 'home-assistant', entityIds: ['light.kitchen'] }, + { provider: 'home-assistant', entityIds: ['calendar.home/../../config'] }, + { provider: 'home-assistant', entityIds: ['calendar.home?x=secret'] }, + { provider: 'home-assistant', entityIds: ['calendar.home', 'calendar.home'] }, + { provider: 'home-assistant', entityIds: Array.from({ length: 11 }, (_, i) => `calendar.c${i}`) }, + { provider: 'ical', calendarUrls: Array(11).fill('https://calendar.example/feed') }, + { calendarUrls: [] }, + ]) assert.equal(dashboardWidgetSchema.safeParse({ type: 'calendar', version: 2, config }).success, false); +}); diff --git a/test/widgets/editorPreferences.test.ts b/test/widgets/editorPreferences.test.ts index b28074e..b9608a0 100644 --- a/test/widgets/editorPreferences.test.ts +++ b/test/widgets/editorPreferences.test.ts @@ -12,6 +12,28 @@ function emptySlots(): DashboardEditorSlots { return [[], [], [], []]; } +test('Calendar V2 preferences preserve version and empty provider drafts never replace useful shared settings', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-calendar-prefs-')); + const path = join(dir, 'preferences.json'); + try { + const store = new DashboardEditorPreferencesStore(path); + const slots = emptySlots(); + slots[0] = [{ type: 'calendar', version: 1, config: { calendarUrls: ['https://example.com/feed'] } }]; + await store.set('p', slots); + slots[0] = [{ type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: [] } }]; + await store.set('p', slots); + assert.equal(store.get('new').shared[0]!.version, 1); + slots[0] = [{ type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.home'] } }]; + await store.set('p', slots); + const loaded = new DashboardEditorPreferencesStore(path); await loaded.load(); + assert.deepEqual(loaded.get('p').slots, slots); + assert.deepEqual(loaded.get('new').shared, slots[0]); + slots[0] = [{ type: 'calendar', version: 2, config: { provider: 'ical', calendarUrls: [] } }]; + await loaded.set('p', slots); + assert.deepEqual(loaded.get('new').shared[0]!.config, { provider: 'home-assistant', entityIds: ['calendar.home'] }); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + test('remembered drafts persist per panel while useful values become shared fallbacks', async () => { const dir = await mkdtemp(join(tmpdir(), 'inkpanel-editor-prefs-')); const path = join(dir, '.dashboard-editor-preferences.json'); From 686272b8145c6bb26b288e9c2451fc78aa1d9cfb Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Thu, 27 Aug 2026 20:08:39 +0100 Subject: [PATCH 08/14] Seed new HA panels from installation location for ha.6 --- .github/workflows/home-assistant-image.yml | 2 +- docs/home-assistant-app.md | 16 ++- home-assistant/CHANGELOG.md | 7 ++ home-assistant/README.md | 6 +- home-assistant/config.yaml | 2 +- src/devices/store.ts | 13 ++- src/homeAssistant/client.ts | 31 ++++++ src/homeAssistant/enrolment.ts | 14 +++ src/http/app.ts | 5 +- src/http/deviceEnrolment.ts | 4 + src/http/deviceRoutes.ts | 11 ++ src/index.ts | 2 + test/devices/store.test.ts | 30 +++++ test/homeAssistant/client.test.ts | 51 +++++++++ test/homeAssistant/package.test.ts | 4 +- test/http/deviceRoutes.test.ts | 123 +++++++++++++++++++++ 16 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 src/homeAssistant/enrolment.ts diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 56efd7e..45c6a35 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.5 + VERSION: 0.1.0-ha.6 ARCHITECTURES: '["amd64", "aarch64"]' jobs: diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 22a2e97..60708a9 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1 complete and validated on real hardware. HA-2 implemented in `0.1.0-ha.5`, awaiting real-world validation on the `Home-Assistant` branch. +Status: HA-1 and HA-2 are implemented and validated on real Home Assistant hardware. The `0.1.0-ha.6` cleanup release adds installation-location defaults for first-time panel enrolment on the `Home-Assistant` branch. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -114,7 +114,7 @@ V1 should prefer simple HTTP snapshot reads because InkPanel renders on demand a Validated on a real Home Assistant installation: Ingress and authenticated LAN Studio, Supervisor API, direct HTTPS WebFlash, full-size/Mini firmware flashing and enrolment, and Home Assistant-owned updates. Standalone remains independent. -### Phase HA-2 — Home Assistant Calendar (implemented; awaiting real-world validation) +### Phase HA-2 — Home Assistant Calendar (implemented and validated on real hardware) Extend Calendar configuration with a provider choice: @@ -150,7 +150,17 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.5` for linux/amd64 and linux/arm64. +The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.6` for linux/amd64 and linux/arm64. + +#### First-time panel location defaults (ha.6) + +In Home Assistant App mode, an unknown panel's first enrolment reads the installation location from `/api/config` through the server-only `HomeAssistantClient.installationLocation()` method. Latitude (-90..90), longitude (-180..180), a valid IANA timezone and a non-empty location name are validated and projected into `latitude`, `longitude`, `timezone` and `locationLabel`. Full-size panels retain four dashboard slots; Mini panels retain one. + +The deployment adapter supplies an optional generic location-defaults provider to the HTTP enrolment flow. DeviceStore has no Home Assistant dependency: it applies only those four fields to a new profile-specific default record and validates the complete current record before writing. Historical migration/default schemas are unchanged; no schema bump is required. + +Known devices never request installation location and are never automatically updated when HA's location changes. Manual per-panel Studio settings remain authoritative. If HA is unavailable or returns invalid location data for an unknown panel, enrolment returns HTTP 503 with a 300-second retry interval and writes no device. It does not silently fall back to historical location defaults. Standalone enrolment remains unchanged. Supervisor credentials and unrelated HA config fields are never included in the seed or HTTP response. + +After upgrading to ha.6, validate a genuinely new panel of each size against the installation location in Home Assistant. Existing panels intentionally retain their saved location; update those manually in Studio if necessary. Check that a manual location edit survives subsequent wakes, and that a known panel continues to receive frames during a temporary HA API outage (individual HA-backed widgets retain their existing unavailable/stale semantics). ### Phase HA-3 — Home Assistant To Do diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index e8c1ed0..b4d67e5 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.0-ha.6 + +- Seed newly enrolled full-size and Mini panels from the validated Home Assistant installation latitude, longitude, timezone and location name. +- Keep existing panels and manual Studio location choices unchanged; standalone defaults and frozen migrations are unchanged. +- Return a retryable error without creating a device if first-enrolment installation location is unavailable or invalid. +- HA-1 and HA-2 native Calendar are validated on real Home Assistant hardware. No renderer, firmware or framebuffer changes. + ## 0.1.0-ha.5 - Add native Home Assistant Calendar discovery and multi-calendar selection through the server-only Supervisor client. diff --git a/home-assistant/README.md b/home-assistant/README.md index 1ae4b8f..3d2cb67 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.5` release is experimental. Add +This `0.1.0-ha.6` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -11,6 +11,8 @@ The App image includes the verified production WebFlash packages for both the fu Home Assistant owns App updates, so the standalone InkPanel updater is intentionally unavailable in this deployment. -The Calendar widget can now use existing Home Assistant calendar entities, with multiple calendars and no copied token or secret iCal URL. Choose the provider and calendars in Studio, then save the panel. Existing iCal calendars remain supported, and both display sizes keep their existing visual layout. HA-2 is implemented and awaiting real-world validation. +The Calendar widget can now use existing Home Assistant calendar entities, with multiple calendars and no copied token or secret iCal URL. Choose the provider and calendars in Studio, then save the panel. Existing iCal calendars remain supported, and both display sizes keep their existing visual layout. HA-2 is implemented and validated on real Home Assistant hardware. + +New panels use Home Assistant's installation location and timezone at first enrolment. Existing panels keep their saved settings, including manual Studio edits. If the installation location cannot be read, a new panel retries later instead of saving an incorrect default location. See the **Documentation** tab before starting the App. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 7816ac9..2ece440 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.5 +version: 0.1.0-ha.6 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel diff --git a/src/devices/store.ts b/src/devices/store.ts index 9efc448..3d9c0c9 100644 --- a/src/devices/store.ts +++ b/src/devices/store.ts @@ -18,6 +18,9 @@ export type DeviceStoreErrorCode = | 'config_io' | 'config_unsupported_version'; +/** Optional deployment defaults, applied only when creating a new record. */ +export type DeviceInitialLocation = Pick; + /** * A storage failure that callers must not reinterpret as an empty installation. * @@ -191,11 +194,19 @@ export class DeviceStore { async getOrCreateWithStatus( id: string, panelProfileId: PanelProfileId = 'wft0583-800x480-mono', + initialLocation?: DeviceInitialLocation, ): Promise<{ device: DeviceRecord; created: boolean }> { return this.mutate((file) => { const existing = file.devices.find((d) => d.id === id); if (existing) return { device: existing, created: false }; - const validation = currentDeviceRecordSchema.safeParse(defaultDevice(id, panelProfileId)); + const defaults = defaultDevice(id, panelProfileId); + const validation = currentDeviceRecordSchema.safeParse(initialLocation ? { + ...defaults, + latitude: initialLocation.latitude, + longitude: initialLocation.longitude, + timezone: initialLocation.timezone, + locationLabel: initialLocation.locationLabel, + } : defaults); if (!validation.success) { throw new DeviceStoreError( 'config_invalid', diff --git a/src/homeAssistant/client.ts b/src/homeAssistant/client.ts index de730f9..8128bab 100644 --- a/src/homeAssistant/client.ts +++ b/src/homeAssistant/client.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { createHash } from 'node:crypto'; +import { isValidTimezone } from '../devices/schema.ts'; import { calendarEntityIdSchema, homeAssistantCalendarListSchema, homeAssistantCalendarEventsSchema, type HomeAssistantCalendarEvent, @@ -38,6 +39,23 @@ const configSchema = z.object({ time_zone: z.string().min(1), }).passthrough(); +/** Only these validated installation fields may seed a new panel. */ +export interface HomeAssistantInstallationLocation { + latitude: number; + longitude: number; + timezone: string; + locationLabel: string; +} + +// Location validation is stricter than the diagnostic probe: diagnostics can +// still report Core's version when its location is unsuitable for enrolment. +const installationConfigSchema = configSchema.extend({ + latitude: z.number().min(-90).max(90), + longitude: z.number().min(-180).max(180), + time_zone: z.string().trim().min(1).max(255).refine(isValidTimezone), + location_name: z.string().trim().min(1), +}); + function standaloneStatus(): HomeAssistantStatus { return { available: false, @@ -139,6 +157,19 @@ export class HomeAssistantClient { }; } + async installationLocation(signal?: AbortSignal): Promise> { + const result = await this.request('config', installationConfigSchema, 'installation config', signal); + if (!result.available) return result; + // Explicit projection: arbitrary HA config metadata/credentials never leave + // this server-only boundary or become part of a DeviceRecord. + return { available: true, data: { + latitude: result.data.latitude, + longitude: result.data.longitude, + timezone: result.data.time_zone, + locationLabel: result.data.location_name, + } }; + } + async listCalendars(signal?: AbortSignal): Promise { const result = await this.request('calendars', homeAssistantCalendarListSchema, 'calendars', signal); return { diff --git a/src/homeAssistant/enrolment.ts b/src/homeAssistant/enrolment.ts new file mode 100644 index 0000000..a6854a4 --- /dev/null +++ b/src/homeAssistant/enrolment.ts @@ -0,0 +1,14 @@ +import type { HomeAssistantClient } from './client.ts'; +import type { DeviceEnrolmentDefaultsProvider } from '../http/deviceEnrolment.ts'; + +/** Deployment adapter; standalone enrolment has no HA dependency. */ +export function homeAssistantEnrolmentDefaults( + enabled: boolean, + client: HomeAssistantClient, +): DeviceEnrolmentDefaultsProvider | undefined { + if (!enabled) return undefined; + return async () => { + const result = await client.installationLocation(); + return result.available ? result.data : null; + }; +} diff --git a/src/http/app.ts b/src/http/app.ts index efaca61..b9301d5 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -10,7 +10,7 @@ import type { GoogleMapsCredentialStore } from '../sources/googleMapsCredentials import type { RuntimeState } from '../runtimeConfig.ts'; import { createAuth, type AuthOptions } from './auth.ts'; import { deviceRoutes } from './deviceRoutes.ts'; -import type { DeviceEnrolmentLimiter } from './deviceEnrolment.ts'; +import type { DeviceEnrolmentLimiter, DeviceEnrolmentDefaultsProvider } from './deviceEnrolment.ts'; import { editorPreferencesRoutes } from './editorPreferencesRoutes.ts'; import { firmwareRoutes } from './firmwareRoutes.ts'; import { manageRoutes } from './manageRoutes.ts'; @@ -67,6 +67,8 @@ export interface AppDeps { moonrakerClient?: MoonrakerClient; /** Shared Supervisor API client. Standalone tests/embedders may omit it. */ homeAssistantClient?: HomeAssistantClient; + /** First-enrolment defaults supplied by the deployment, never used for known panels. */ + enrolmentDefaults?: DeviceEnrolmentDefaultsProvider; /** Deployment capability shared by runtime UI and mutation routes. Defaults to standalone. */ updateMode?: UpdateMode; /** Selects the request trust boundary without duplicating application routes. */ @@ -196,6 +198,7 @@ export function createApp(deps: AppDeps): express.Express { deps.frames, deps.publicBaseUrl, deps.enrolmentLimiter, + deps.enrolmentDefaults, )); app.use('/api', manageRoutes( deps.store, diff --git a/src/http/deviceEnrolment.ts b/src/http/deviceEnrolment.ts index 6ed4e3b..91a4a93 100644 --- a/src/http/deviceEnrolment.ts +++ b/src/http/deviceEnrolment.ts @@ -1,4 +1,8 @@ import { z } from 'zod'; +import type { DeviceInitialLocation } from '../devices/store.ts'; + +/** null means temporarily unavailable, not permission to use fallback defaults. */ +export type DeviceEnrolmentDefaultsProvider = () => Promise; export const DEVICE_ENROLMENT_WINDOW_MS = 60 * 60 * 1000; export const DEVICE_ENROLMENT_PER_IP_LIMIT = 5; diff --git a/src/http/deviceRoutes.ts b/src/http/deviceRoutes.ts index 6e54fce..16feb04 100644 --- a/src/http/deviceRoutes.ts +++ b/src/http/deviceRoutes.ts @@ -8,6 +8,7 @@ import { panelProfile, WFT0583 } from '../panel/profile.ts'; import { DeviceEnrolmentLimiter, firmwareAutoEnrolmentIdSchema, + type DeviceEnrolmentDefaultsProvider, } from './deviceEnrolment.ts'; const ERROR_RETRY_SECONDS = 300; @@ -30,6 +31,7 @@ export function deviceRoutes( frames: FrameService, publicBaseUrl: string, enrolmentLimiter = new DeviceEnrolmentLimiter(), + enrolmentDefaults?: DeviceEnrolmentDefaultsProvider, ): Router { const router = Router(); @@ -63,11 +65,20 @@ export function deviceRoutes( } try { + const initialLocation = enrolmentDefaults ? await enrolmentDefaults() : undefined; + if (initialLocation === null) { + reserved.reservation.complete(false); + res.set('Retry-After', String(ERROR_RETRY_SECONDS)); + res.set('X-Next-Wake-Seconds', String(ERROR_RETRY_SECONDS)); + res.status(503).json({ error: 'device enrolment defaults temporarily unavailable' }); + return; + } // Firmware 0.1.4 predates the profile header. Missing therefore means // the existing 7.5-inch profile, preserving old-board auto-enrolment. const result = await store.getOrCreateWithStatus( id, advertisedProfile ?? WFT0583.id as PanelProfileId, + initialLocation, ); reserved.reservation.complete(result.created); device = result.device; diff --git a/src/index.ts b/src/index.ts index 4a31698..0b264c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { TodoStore } from './todo/store.ts'; import { PrinterConnectionStore } from './printers/store.ts'; import { MoonrakerClient } from './printers/moonraker.ts'; import { HomeAssistantClient, isHomeAssistantMode } from './homeAssistant/client.ts'; +import { homeAssistantEnrolmentDefaults } from './homeAssistant/enrolment.ts'; import { updateModeForDeployment } from './system/updateOwnership.ts'; export const version = '0.1.0'; @@ -182,6 +183,7 @@ export async function main(): Promise { moonrakerClient, homeAssistantClient, updateMode, + enrolmentDefaults: homeAssistantEnrolmentDefaults(homeAssistantMode, homeAssistantClient), }; const app = createApp({ ...sharedDeps, access: { mode: 'lan' } }); const server = app.listen(port); diff --git a/test/devices/store.test.ts b/test/devices/store.test.ts index 69becef..74985c5 100644 --- a/test/devices/store.test.ts +++ b/test/devices/store.test.ts @@ -60,6 +60,36 @@ test('returns the same record on second sight', async () => { }); }); +test('new-device location is validated as part of the complete record before persistence', async () => { + await withStore(async (store, path) => { + const location = { latitude: 45, longitude: 2, timezone: 'Europe/Paris', locationLabel: 'Home' }; + await expectStoreError(() => store.getOrCreateWithStatus('esp32-seed', undefined, { + ...location, latitude: 999, + }), 'config_invalid'); + assert.deepEqual(await store.list(), []); + await assert.rejects(readFile(path), { code: 'ENOENT' }); + const result = await store.getOrCreateWithStatus('esp32-seed', 'ssd1681-200x200-mono', location); + assert.equal(result.created, true); + assert.equal(result.device.latitude, 45); + assert.equal(result.device.dashboardSections.length, 1); + assert.ok(currentDeviceRecordSchema.safeParse(result.device).success); + }); +}); + +test('concurrent enrolment seeds cannot overwrite the winning record or its profile', async () => { + await withStore(async (store) => { + const location = { latitude: 45, longitude: 2, timezone: 'Europe/Paris', locationLabel: 'Home' }; + const [first, duplicate] = await Promise.all([ + store.getOrCreateWithStatus('esp32-seed', 'ssd1681-200x200-mono', location), + store.getOrCreateWithStatus('esp32-seed', 'wft0583-800x480-mono', { ...location, latitude: 50 }), + ]); + assert.equal(first.created, true); + assert.equal(duplicate.created, false); + assert.deepEqual(duplicate.device, first.device); + assert.equal((await store.list()).length, 1); + }); +}); + test('persists across instances', async () => { await withStore(async (store, path) => { await store.getOrCreate('esp32-a1b2c3'); diff --git a/test/homeAssistant/client.test.ts b/test/homeAssistant/client.test.ts index 251d4b9..bb8571c 100644 --- a/test/homeAssistant/client.test.ts +++ b/test/homeAssistant/client.test.ts @@ -2,6 +2,57 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { HomeAssistantClient, isHomeAssistantMode } from '../../src/homeAssistant/client.ts'; +const installationConfig = { + version: '2026.8.1', latitude: -36.85, longitude: 174.76, + time_zone: 'Pacific/Auckland', location_name: 'Home', +}; + +test('installation location validates config and exposes only device location fields', async () => { + const client = new HomeAssistantClient({ + enabled: true, token: 'super-secret-token', + fetchImpl: async (input, init) => { + assert.equal(String(input), 'http://supervisor/core/api/config'); + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer super-secret-token'); + return Response.json({ ...installationConfig, token: 'super-secret-token', extra: 'not a device field' }); + }, + }); + assert.deepEqual(await client.installationLocation(), { available: true, data: { + latitude: -36.85, longitude: 174.76, timezone: 'Pacific/Auckland', locationLabel: 'Home', + } }); + assert.equal((await client.status()).version, '2026.8.1', 'diagnostic probe remains available'); +}); + +test('malformed installation location is rejected without reflecting input or credentials', async () => { + for (const patch of [ + { latitude: -91 }, { latitude: 91 }, { latitude: '52.04' }, { latitude: null }, + { latitude: undefined }, { latitude: Infinity }, { latitude: NaN }, + { longitude: -181 }, { longitude: 181 }, { longitude: '0' }, { longitude: undefined }, + { time_zone: '' }, { time_zone: ' ' }, { time_zone: 'Invalid/secret-token' }, + { location_name: '' }, { location_name: ' ' }, { location_name: null }, { version: null }, + ]) { + const client = new HomeAssistantClient({ + enabled: true, token: 'secret-token', + fetchImpl: async () => Response.json({ ...installationConfig, ...patch }), + }); + assert.deepEqual(await client.installationLocation(), { + available: false, error: 'Home Assistant returned an invalid installation config response', + }); + } +}); + +test('installation location accepts coordinate boundaries and trims names', async () => { + for (const [latitude, longitude] of [[-90, -180], [90, 180], [0, 0]]) { + const client = new HomeAssistantClient({ + enabled: true, token: 'secret', fetchImpl: async () => Response.json({ + ...installationConfig, latitude, longitude, location_name: ' Home ', time_zone: ' UTC ', + }), + }); + assert.deepEqual(await client.installationLocation(), { available: true, data: { + latitude, longitude, locationLabel: 'Home', timezone: 'UTC', + } }); + } +}); + test('standalone mode is explicitly unavailable without making a request', async () => { let calls = 0; const client = new HomeAssistantClient({ diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index 6ce3a5e..36231be 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.5'); + assert.equal(config.version, '0.1.0-ha.6'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); @@ -48,7 +48,7 @@ test('the image workflow builds, verifies and embeds production firmware before assert.match(workflow, /build-image@4de35182/); assert.match(workflow, /publish-multi-arch-manifest@4de35182/); assert.match(workflow, /\["amd64", "aarch64"\]/); - assert.match(workflow, /VERSION: 0\.1\.0-ha\.5/); + assert.match(workflow, /VERSION: 0\.1\.0-ha\.6/); assert.match(workflow, /matrix: \$\{\{ steps\.prepare\.outputs\.matrix \}\}/); assert.match(workflow, /runs-on: \$\{\{ matrix\.os \}\}/); assert.match(workflow, /registry-prefix: ghcr\.io\/ctrlaltcouk/); diff --git a/test/http/deviceRoutes.test.ts b/test/http/deviceRoutes.test.ts index 6eede8b..4c403ee 100644 --- a/test/http/deviceRoutes.test.ts +++ b/test/http/deviceRoutes.test.ts @@ -8,6 +8,9 @@ import { createApp } from '../../src/http/app.ts'; import { DeviceStore, DeviceStoreError } from '../../src/devices/store.ts'; import type { FrameService } from '../../src/render/frameService.ts'; import { DeviceEnrolmentLimiter } from '../../src/http/deviceEnrolment.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { homeAssistantEnrolmentDefaults } from '../../src/homeAssistant/enrolment.ts'; +import { currentDeviceRecordSchema } from '../../src/devices/schema.ts'; const ETAG = 'a'.repeat(32); @@ -34,6 +37,8 @@ async function withServer( limiter?: DeviceEnrolmentLimiter; trustProxy?: boolean | number | string; password?: string | null; + homeAssistantClient?: HomeAssistantClient; + homeAssistantMode?: boolean; prepare?: (store: DeviceStore, configPath: string) => Promise; } = {}, ) { @@ -48,6 +53,11 @@ async function withServer( auth: { password: options.password ?? null, secret: randomBytes(32) }, trustProxy: options.trustProxy, enrolmentLimiter: options.limiter, + homeAssistantClient: options.homeAssistantClient, + enrolmentDefaults: homeAssistantEnrolmentDefaults( + options.homeAssistantMode ?? false, + options.homeAssistantClient ?? new HomeAssistantClient({ enabled: false }), + ), }); const server = app.listen(0); await new Promise((resolve) => server.once('listening', resolve)); @@ -82,6 +92,119 @@ test('serves an enrolment frame for an unknown device', async () => { }); }); +const haConfig = { + version: '2026.8.1', latitude: -36.85, longitude: 174.76, + time_zone: 'Pacific/Auckland', location_name: 'My HA home', + supervisor_token: 'never-persist-supervisor-token', +}; +const haLocation = { + latitude: haConfig.latitude, longitude: haConfig.longitude, + timezone: haConfig.time_zone, locationLabel: haConfig.location_name, +}; + +test('HA first enrolment seeds full-size and Mini location without credentials or profile changes', async () => { + let requests = 0; + const homeAssistantClient = new HomeAssistantClient({ + enabled: true, token: haConfig.supervisor_token, + fetchImpl: async () => { requests += 1; return Response.json(haConfig); }, + }); + await withServer(async (base, store, configPath) => { + for (const [id, profile, slots] of [ + ['esp32-000001', 'wft0583-800x480-mono', 4], + ['esp32-000002', 'ssd1681-200x200-mono', 1], + ] as const) { + const res = await fetch(`${base}/api/devices/${id}/frame`, { + headers: { 'X-InkPanel-Profile': profile }, + }); + assert.equal(res.status, 200); + assert.doesNotMatch(await res.text(), /never-persist-supervisor-token/); + assert.doesNotMatch(JSON.stringify([...res.headers]), /never-persist-supervisor-token/); + const device = currentDeviceRecordSchema.parse(await store.get(id)); + for (const key of ['latitude', 'longitude', 'timezone', 'locationLabel'] as const) { + assert.equal(device[key], haLocation[key]); + } + assert.equal(device.panelProfileId, profile); + assert.equal(device.dashboardSections.length, slots); + } + assert.equal(requests, 2); + assert.doesNotMatch(await readFile(configPath, 'utf8'), /supervisor_token|never-persist-supervisor-token/); + }, { homeAssistantClient, homeAssistantMode: true }); +}); + +test('known HA panels preserve manual location and work without another HA request', async () => { + let requests = 0; + let config = haConfig; + let offline = false; + const homeAssistantClient = new HomeAssistantClient({ + enabled: true, token: haConfig.supervisor_token, + fetchImpl: async () => { + requests += 1; + if (offline) throw new Error(haConfig.supervisor_token); + return Response.json(config); + }, + }); + await withServer(async (base, store) => { + const url = `${base}/api/devices/esp32-000001/frame`; + assert.equal((await fetch(url)).status, 200); + const manual = { latitude: 40.71, longitude: -74, timezone: 'America/New_York', locationLabel: 'Office' }; + await store.update('esp32-000001', { ...manual, claimed: true }); + config = { ...haConfig, latitude: 48.85, longitude: 2.35, time_zone: 'Europe/Paris' }; + assert.equal((await fetch(url)).status, 200); + offline = true; + assert.equal((await fetch(url)).status, 200); + assert.equal(requests, 1, 'known devices must not fetch installation config'); + const device = (await store.get('esp32-000001'))!; + for (const key of ['latitude', 'longitude', 'timezone', 'locationLabel'] as const) { + assert.equal(device[key], manual[key]); + } + }, { homeAssistantClient, homeAssistantMode: true }); +}); + +test('failed HA first enrolment is retryable, persists nothing and refunds capacity', async () => { + for (const failure of ['http', 'network', 'malformed'] as const) { + let recovered = false; + const homeAssistantClient = new HomeAssistantClient({ + enabled: true, token: haConfig.supervisor_token, + fetchImpl: async () => { + if (recovered) return Response.json(haConfig); + if (failure === 'network') throw new Error(haConfig.supervisor_token); + if (failure === 'http') return new Response(haConfig.supervisor_token, { status: 503 }); + return Response.json({ ...haConfig, latitude: 999 }); + }, + }); + await withServer(async (base, store, configPath) => { + const url = `${base}/api/devices/esp32-000001/frame`; + const res = await fetch(url); + assert.equal(res.status, 503, failure); + assert.equal(res.headers.get('retry-after'), '300'); + assert.equal(res.headers.get('x-next-wake-seconds'), '300'); + assert.deepEqual(await res.json(), { error: 'device enrolment defaults temporarily unavailable' }); + assert.deepEqual(await store.list(), []); + await assert.rejects(stat(configPath), { code: 'ENOENT' }); + recovered = true; + assert.equal((await fetch(url)).status, 200); + assert.equal((await store.get('esp32-000001'))?.latitude, haLocation.latitude); + }, { + homeAssistantClient, homeAssistantMode: true, + limiter: new DeviceEnrolmentLimiter({ perIpLimit: 1, globalLimit: 1 }), + }); + } +}); + +test('standalone enrolment retains historical location defaults and never calls HA', async () => { + const homeAssistantClient = new HomeAssistantClient({ + enabled: true, token: 'unused', fetchImpl: async () => { assert.fail('standalone must not request HA config'); }, + }); + await withServer(async (base, store) => { + assert.equal((await fetch(`${base}/api/devices/esp32-000001/frame`)).status, 200); + const device = (await store.get('esp32-000001'))!; + assert.equal(device.latitude, 52.04); + assert.equal(device.longitude, -0.76); + assert.equal(device.timezone, 'Europe/London'); + assert.equal(device.dashboardSections.length, 4); + }, { homeAssistantClient, homeAssistantMode: false }); +}); + test('unclaimed devices are told to come back quickly', async () => { await withServer(async (base) => { const res = await fetch(`${base}/api/devices/esp32-a1b2c3/frame`); From 3dc9fb21748239ec5bf8d753ea9721580552edc0 Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Thu, 27 Aug 2026 20:34:54 +0100 Subject: [PATCH 09/14] Add read-only Home Assistant To Do provider for ha.7 --- .github/workflows/home-assistant-image.yml | 2 +- docs/home-assistant-app.md | 45 ++++++++--- home-assistant/CHANGELOG.md | 8 ++ home-assistant/README.md | 4 +- home-assistant/config.yaml | 2 +- public/calendarEditor.js | 11 +-- public/dashboardEditor.js | 42 ++++++---- public/panels.js | 6 +- public/providerDrafts.js | 32 ++++++++ public/todoEditor.js | 32 ++++++++ src/homeAssistant/client.ts | 32 +++++++- src/homeAssistant/todoSchemas.ts | 37 +++++++++ src/http/homeAssistantRoutes.ts | 4 + src/http/manageRoutes.ts | 4 +- src/http/todoRoutes.ts | 2 +- src/model/dashboard.ts | 2 +- src/render/frameService.ts | 8 +- src/sources/homeAssistantTodo.ts | 22 +++++ src/widgets/editorPreferences.ts | 29 ++++--- src/widgets/registry.ts | 12 ++- test/homeAssistant/package.test.ts | 4 +- test/homeAssistant/todo.test.ts | 86 ++++++++++++++++++++ test/http/homeAssistantIngress.test.ts | 9 +++ test/http/todoRoutes.test.ts | 25 ++++++ test/public/todoProviderUx.test.js | 75 +++++++++++++++++ test/render/homeAssistantTodo.test.ts | 94 ++++++++++++++++++++++ test/widgets/editorPreferences.test.ts | 25 +++++- test/widgets/todoRegistry.test.ts | 17 ++++ 28 files changed, 613 insertions(+), 58 deletions(-) create mode 100644 public/providerDrafts.js create mode 100644 public/todoEditor.js create mode 100644 src/homeAssistant/todoSchemas.ts create mode 100644 src/sources/homeAssistantTodo.ts create mode 100644 test/homeAssistant/todo.test.ts create mode 100644 test/public/todoProviderUx.test.js create mode 100644 test/render/homeAssistantTodo.test.ts diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 45c6a35..6414690 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.6 + VERSION: 0.1.0-ha.7 ARCHITECTURES: '["amd64", "aarch64"]' jobs: diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 60708a9..c0ac845 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1 and HA-2 are implemented and validated on real Home Assistant hardware. The `0.1.0-ha.6` cleanup release adds installation-location defaults for first-time panel enrolment on the `Home-Assistant` branch. +Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do is implemented in `0.1.0-ha.7` and awaits real-installation validation on the `Home-Assistant` branch. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -150,9 +150,9 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.6` for linux/amd64 and linux/arm64. +The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.7` for linux/amd64 and linux/arm64. -#### First-time panel location defaults (ha.6) +#### First-time panel location defaults (ha.6; validated on real hardware) In Home Assistant App mode, an unknown panel's first enrolment reads the installation location from `/api/config` through the server-only `HomeAssistantClient.installationLocation()` method. Latitude (-90..90), longitude (-180..180), a valid IANA timezone and a non-empty location name are validated and projected into `latitude`, `longitude`, `timezone` and `locationLabel`. Full-size panels retain four dashboard slots; Mini panels retain one. @@ -162,16 +162,43 @@ Known devices never request installation location and are never automatically up After upgrading to ha.6, validate a genuinely new panel of each size against the installation location in Home Assistant. Existing panels intentionally retain their saved location; update those manually in Studio if necessary. Check that a manual location edit survives subsequent wakes, and that a known panel continues to receive frames during a temporary HA API outage (individual HA-backed widgets retain their existing unavailable/stale semantics). -### Phase HA-3 — Home Assistant To Do +### Phase HA-3 — Home Assistant To Do (implemented; awaiting real-world validation) -Extend To Do configuration with a provider choice: +To Do V2 adds a strict provider choice while existing To Do V1 records remain valid, local, and unchanged on load or unrelated saves: -- existing InkPanel local named list; -- Home Assistant `todo.*` entity. +- `{"type":"todo","version":2,"config":{"provider":"local","listId":"..."}}` +- `{"type":"todo","version":2,"config":{"provider":"home-assistant","entityId":"todo.shopping_list"}}` -Use `todo.get_items` for incomplete items. Preserve InkPanel's current local-list store and editing behaviour. +An empty selection is allowed as not set up. HA entity IDs must match `^todo\.[a-z0-9_]+$` (maximum 255 characters). Local list-ID validation is unchanged. No DeviceStore schema bump or frozen migration/default change is involved. -A later milestone may allow add/update/complete/delete actions against Home Assistant lists from Studio. The first HA To Do milestone may be read-only if that reduces integration risk. +#### Server-only API and live data + +The shared `HomeAssistantClient` discovers lists using `GET states`, validating the envelope and projecting only To Do entity IDs and friendly names (or readable fallback names). The authenticated InkPanel endpoint `GET /api/home-assistant/todo-lists` returns these safe choices. Standalone returns `supported: false` without contacting HA. + +Items use the official [`todo.get_items` action](https://www.home-assistant.io/actions/todo.get_items/) via `POST services/todo/get_items?return_response`, with JSON `{"entity_id":"todo.example","status":"needs_action"}`. This follows the [REST response-producing service contract](https://developers.home-assistant.io/docs/api/rest/#post-apiservicesdomainservice). The existing normalized Supervisor base, bearer authentication, redirect rejection, timeouts, cancellation and safe errors are shared by GET and JSON POST requests. + +The selected entity's `service_response` is validated. Only non-empty trimmed `needs_action` summaries, in HA order and limited to five, become the existing `TodoData` (`{ items: string[] }`). UIDs, descriptions, due metadata, arbitrary state attributes and credentials do not enter rendering or caching. + +FrameService uses a live-only source: no persistent last-good task list is replayed. A configured entity remains configured during an outage, with null data and diagnostic error health. Other widgets continue independently. Duplicate identical To Do sections share the existing per-frame request promise. Empty lists use the existing ALL DONE state; absent selections and unavailable data retain the existing renderer semantics. Full-size and Mini visual templates, framebuffer sizes and firmware are unchanged. Only visible item text/order affects the existing pixel hash. + +#### Studio and remembered settings + +In App mode the Provider selector offers **InkPanel list** and **Home Assistant**. InkPanel retains its complete existing local list/task editor and immediate CRUD persistence. HA mode shows only a list selector and read-only help: manage tasks in Home Assistant. Provider/entity selection is panel configuration and requires **Save changes**. Local task-content edits retain their separate preview-refresh/dirty-state behaviour. + +Calendar and To Do share provider draft handling. Active widget drafts retain their associated versions; explicit provider switches save V2. Both provider choices survive switching widget types, saving/reloading, and per-slot/shared remembered settings. The separate editor-preferences store accepts one entry per widget/provider (legacy one-per-type entries remain readable), with the active choice first and useful shared fallbacks retained independently. This is convenience state, not a DeviceStore migration. + +Missing or removed HA entities are shown as missing/unavailable without clearing the saved ID. Syntax is sufficient for saving HA config; discovery availability is never required to read/save an existing panel. Local V1/V2 selections still require a real TodoStore list when saved. + +#### Real-installation validation for ha.7 + +1. Upgrade the App to ha.7 and open Studio through Ingress. +2. Select To Do → Home Assistant, choose a list, and save on a full-size panel and a Mini. +3. Verify the first five incomplete items match HA ordering, then complete/add/reorder items in HA and wake the panel or refresh its preview. +4. Complete all items and verify ALL DONE. Temporarily make the source unavailable and verify no stale task list is replayed and unrelated widgets remain usable. +5. Switch to InkPanel list and back, save/reopen, and verify both selections survive. Existing local CRUD and Calendar provider choices should remain intact. +6. Remove a selected HA entity and verify Studio retains its missing selection until explicitly changed. + +Future HA write support (`todo.add_item`, `todo.update_item`, `todo.remove_item`) is a separate milestone. ha.7 exposes none of those actions from Studio. ### Phase HA-4 — Home Assistant Entities diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index b4d67e5..fccb5d6 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.0-ha.7 + +- Add read-only Home Assistant To Do discovery and incomplete-item fetching through the server-only Supervisor client. +- Add To Do V2 local/Home Assistant provider selection without rewriting V1 records or changing DeviceStore schema/migrations. +- Preserve both Calendar and To Do provider drafts across switching, saved per-slot settings and shared fallbacks. +- Keep local To Do CRUD, full-size/Mini visual layouts and firmware unchanged; HA task lists are live-only, not stale-cached. +- HA-1, HA-2 and ha.6 location defaults are real-hardware validated. HA-3 awaits real-installation validation. + ## 0.1.0-ha.6 - Seed newly enrolled full-size and Mini panels from the validated Home Assistant installation latitude, longitude, timezone and location name. diff --git a/home-assistant/README.md b/home-assistant/README.md index 3d2cb67..74d1848 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.6` release is experimental. Add +This `0.1.0-ha.7` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -16,3 +16,5 @@ The Calendar widget can now use existing Home Assistant calendar entities, with New panels use Home Assistant's installation location and timezone at first enrolment. Existing panels keep their saved settings, including manual Studio edits. If the installation location cannot be read, a new panel retries later instead of saving an incorrect default location. See the **Documentation** tab before starting the App. + +To Do can now display a Home Assistant `todo.*` list using the existing full-size or Mini layout. Choose **Home Assistant** as the provider, select a list, and click **Save changes**. This milestone is read-only: edit tasks in Home Assistant. Existing InkPanel lists keep their full local editor. Both provider selections are remembered, missing entities remain visible, and HA outages show unavailable data rather than replaying stale tasks. HA-3 awaits real-installation validation. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 2ece440..43c4ff7 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.6 +version: 0.1.0-ha.7 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel diff --git a/public/calendarEditor.js b/public/calendarEditor.js index 89adb12..80b148e 100644 --- a/public/calendarEditor.js +++ b/public/calendarEditor.js @@ -1,4 +1,5 @@ import { esc } from './components.js'; +import { switchProviderDraft } from './providerDrafts.js'; export function calendarControlsHtml(config, discovery = {}) { const provider = config.provider ?? 'ical'; @@ -26,13 +27,5 @@ export function rememberCalendarConfig(panel, slot) { /** Provider switches upgrade explicitly; loading a V1 widget never does. */ export function switchCalendarProvider(slot, provider) { if (!['ical', 'home-assistant'].includes(provider)) return; - const current = slot.drafts.calendar; - slot.calendarProviderDrafts ??= {}; - slot.calendarProviderDrafts[current.provider ?? 'ical'] = structuredClone(current); - const restored = slot.calendarProviderDrafts[provider]; - slot.drafts.calendar = provider === 'ical' - ? { provider, calendarUrls: [...(restored?.calendarUrls ?? [])] } - : { provider, entityIds: [...(restored?.entityIds ?? [])] }; - slot.versions ??= {}; - slot.versions.calendar = 2; + switchProviderDraft(slot, 'calendar', provider, provider === 'ical' ? { calendarUrls: [] } : { entityIds: [] }); } diff --git a/public/dashboardEditor.js b/public/dashboardEditor.js index 9864e77..9efe843 100644 --- a/public/dashboardEditor.js +++ b/public/dashboardEditor.js @@ -3,6 +3,8 @@ import { renderStationPicker } from './stationPicker.js'; import { renderBusStopPicker } from './busStopPicker.js'; import { getJson, sendJson } from './api.js'; import { calendarControlsHtml, rememberCalendarConfig, switchCalendarProvider } from './calendarEditor.js'; +import { todoProviderHtml, homeAssistantTodoControlsHtml, rememberTodoConfig, switchTodoProvider } from './todoEditor.js'; +import { providerDraftState, rememberedProviderDrafts } from './providerDrafts.js'; const TYPES = ['calendar', 'weather', 'trains', 'bus', 'traffic', 'octopus', 'printers', 'todo', 'bins', 'empty']; const POSITIONS = ['Top Left', 'Top Right', 'Bottom Left', 'Bottom Right']; @@ -29,8 +31,8 @@ function defaultConfig(type) { } function clone(value) { return JSON.parse(JSON.stringify(value)); } -function widgetsByType(widgets = []) { return Object.fromEntries(widgets.map((widget) => [widget.type, clone(widget.config)])); } -function versionsByType(widgets = []) { return Object.fromEntries(widgets.map((widget) => [widget.type, widget.version])); } +function widgetsByType(widgets = []) { return Object.fromEntries([...widgets].reverse().map((widget) => [widget.type, clone(widget.config)])); } +function versionsByType(widgets = []) { return Object.fromEntries([...widgets].reverse().map((widget) => [widget.type, widget.version])); } export function normalizePrinterUrlValue(value) { const trimmed = value.trim(); @@ -49,8 +51,7 @@ export function createDashboardDraftState(sections, remembered = {}) { type: widget.type, drafts: { ...clone(shared), ...widgetsByType(rememberedSlots[index] ?? []), [widget.type]: clone(widget.config) }, versions: { ...versionsByType(remembered.shared), ...versionsByType(rememberedSlots[index]), [widget.type]: widget.version }, - calendarProviderDrafts: Object.fromEntries([...(remembered.shared ?? []), ...(rememberedSlots[index] ?? []), widget] - .filter((entry) => entry.type === 'calendar').map((entry) => [entry.config.provider ?? 'ical', clone(entry.config)])), + providerDrafts: providerDraftState([...(remembered.shared ?? []), ...(rememberedSlots[index] ?? []), widget]), })); } @@ -93,7 +94,7 @@ function rememberCell(cell, slot, type = slot.type) { } else if (type === 'octopus') { slot.drafts[type] = { tariffCode: cell.querySelector('[data-octopus-tariff]')?.value.trim().toUpperCase() ?? '' }; } else if (type === 'todo') { - slot.drafts[type] = { listId: cell.querySelector('[data-todo-list]')?.value ?? '' }; + slot.drafts[type] = rememberTodoConfig(cell, slot); } else if (type === 'printers') { const mini = cell.querySelector('[data-printer-single]'); slot.drafts[type] = { @@ -166,7 +167,7 @@ function printerControlsHtml(config, printers, isMini) {
`; } -function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, busConfigured, busId, busKey, trafficConfigured, trafficKey, todoLists, printers, isMini, haCalendars) { +function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, busConfigured, busId, busKey, trafficConfigured, trafficKey, todoLists, printers, isMini, haCalendars, haTodos) { if (type === 'calendar') return calendarControlsHtml(config, haCalendars); if (type === 'bins') return `

Milton Keynes only. Find your UPRN at findmyaddress.co.uk.

`; if (type === 'weather') return `

Uses panel location: ${esc(locationLabel || 'current panel location')}.

`; @@ -174,14 +175,15 @@ function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, bu if (type === 'trains') return trainControlsHtml(trainConfigured, trainKey); if (type === 'bus') return busControlsHtml(config, busConfigured, busId, busKey); if (type === 'traffic') return trafficControlsHtml(config, trafficConfigured, trafficKey); - if (type === 'todo') return todoControlsHtml(config, todoLists); + if (type === 'todo') return todoProviderHtml(config, haTodos) + (config.provider === 'home-assistant' + ? homeAssistantTodoControlsHtml(config, haTodos) : todoControlsHtml(config, todoLists)); if (type === 'printers') return printerControlsHtml(config, printers, isMini); return `

Paste the full electricity tariff code from Octopus. No Octopus API key is required for public Agile prices. See Octopus tariff/API details.

`; } -export function dashboardCellHtml(deviceId, index, slot, locationLabel = '', trainApi = {}, busApi = {}, trafficApi = {}, positionLabel = POSITIONS[index], todoLists = [], printers = [], isMini = false, haCalendars = {}) { +export function dashboardCellHtml(deviceId, index, slot, locationLabel = '', trainApi = {}, busApi = {}, trafficApi = {}, positionLabel = POSITIONS[index], todoLists = [], printers = [], isMini = false, haCalendars = {}, haTodos = {}) { const config = slot.drafts[slot.type] ?? defaultConfig(slot.type); - return `
${esc(positionLabel)}

${typeLabel(slot.type)}

${controlsHtml(slot.type, config, locationLabel, Boolean(trainApi.configured), trainApi.keyDraft ?? '', Boolean(busApi.configured), busApi.appIdDraft ?? '', busApi.appKeyDraft ?? '', Boolean(trafficApi.configured), trafficApi.keyDraft ?? '', todoLists, printers, isMini, haCalendars)}
`; + return `
${esc(positionLabel)}

${typeLabel(slot.type)}

${controlsHtml(slot.type, config, locationLabel, Boolean(trainApi.configured), trainApi.keyDraft ?? '', Boolean(busApi.configured), busApi.appIdDraft ?? '', busApi.appKeyDraft ?? '', Boolean(trafficApi.configured), trafficApi.keyDraft ?? '', todoLists, printers, isMini, haCalendars, haTodos)}
`; } function summary(type, config, locationLabel, todoLists = []) { @@ -191,7 +193,8 @@ function summary(type, config, locationLabel, todoLists = []) { if (type === 'bus') return config.stopLabel || config.stopCode || 'Not set up'; if (type === 'traffic') return config.origin && config.destination ? `${config.origin} → ${config.destination}` : 'Not set up'; if (type === 'octopus') return config.tariffCode || 'Not set up'; - if (type === 'todo') return todoLists.find((list) => list.id === config.listId)?.name || 'Not set up'; + if (type === 'todo') return config.provider === 'home-assistant' + ? config.entityId || 'Not set up' : todoLists.find((list) => list.id === config.listId)?.name || 'Not set up'; if (type === 'printers') return config.printerIds?.length ? `${config.printerIds.length} printer${config.printerIds.length === 1 ? '' : 's'}` : 'Not set up'; if (type === 'bins') return config.uprn ? `UPRN ${config.uprn}` : 'Not set up'; return 'Blank section'; @@ -429,7 +432,7 @@ function renderEditor(root) { { configured: state.trainApiConfigured, keyDraft: state.trainApiKeyDraft }, { configured: state.busApiConfigured, appIdDraft: state.busAppIdDraft, appKeyDraft: state.busAppKeyDraft }, { configured: state.trafficApiConfigured, keyDraft: state.trafficApiKeyDraft }, - slotPosition(state, index), state.todoLists, state.printers, state.isMini, state.haCalendars); + slotPosition(state, index), state.todoLists, state.printers, state.isMini, state.haCalendars, state.haTodos); panel.querySelector('[data-widget-type]').addEventListener('change', (event) => { const previous = slot.type; @@ -462,16 +465,25 @@ function renderEditor(root) { } else if (slot.type === 'traffic') { panel.querySelector('[data-traffic-api-key]').addEventListener('input', (e) => { state.trafficApiKeyDraft = e.currentTarget.value; }); } else if (slot.type === 'todo') { - bindTodoEditor(root, state, slot, panel); + panel.querySelector('[data-todo-provider]')?.addEventListener('change', (event) => { + rememberCell(panel, slot); + switchTodoProvider(slot, event.target.value); + renderLayout(root); renderEditor(root); markDashboardChanged(root); + }); + if (config.provider === 'home-assistant') { + panel.querySelector('[data-ha-todo-list]').addEventListener('change', () => { + rememberCell(panel, slot); renderLayout(root); markDashboardChanged(root); + }); + } else bindTodoEditor(root, state, slot, panel); } else if (slot.type === 'printers') { bindPrinterEditor(root, state, slot, panel); } } -export function renderDashboardEditor(root, device, trainApi = { configured: false }, busApi = { configured: false }, trafficApi = { configured: false }, remembered = { shared: [], slots: [[], [], [], []] }, todoLists = [], printers = [], haCalendars = {}) { +export function renderDashboardEditor(root, device, trainApi = { configured: false }, busApi = { configured: false }, trafficApi = { configured: false }, remembered = { shared: [], slots: [[], [], [], []] }, todoLists = [], printers = [], haCalendars = {}, haTodos = {}) { const slots = createDashboardDraftState(device.dashboardSections, remembered); const isMini = device.panelProfileId === MINI_PROFILE; - stateByRoot.set(root, { deviceId: device.id, locationLabel: device.locationLabel, slots, selectedIndex: 0, isMini, trainApiConfigured: Boolean(trainApi.configured), trainApiKeyDraft: '', busApiConfigured: Boolean(busApi.configured), busAppIdDraft: '', busAppKeyDraft: '', trafficApiConfigured: Boolean(trafficApi.configured), trafficApiKeyDraft: '', todoLists: clone(todoLists), printers: clone(printers), haCalendars }); + stateByRoot.set(root, { deviceId: device.id, locationLabel: device.locationLabel, slots, selectedIndex: 0, isMini, trainApiConfigured: Boolean(trainApi.configured), trainApiKeyDraft: '', busApiConfigured: Boolean(busApi.configured), busAppIdDraft: '', busAppKeyDraft: '', trafficApiConfigured: Boolean(trafficApi.configured), trafficApiKeyDraft: '', todoLists: clone(todoLists), printers: clone(printers), haCalendars, haTodos }); root.innerHTML = `
`; renderLayout(root); renderEditor(root); } @@ -491,7 +503,7 @@ export function collectRememberedDashboardSettings(root) { } export function serialiseRememberedDashboardDrafts(slots) { - return { slots: slots.map((slot) => Object.entries(slot.drafts).map(([type, config]) => ({ type, version: slot.versions[type], config: clone(config) }))) }; + return { slots: slots.map(rememberedProviderDrafts) }; } export function collectTrainApiKey(root) { diff --git a/public/panels.js b/public/panels.js index 58ecc94..30a5f61 100644 --- a/public/panels.js +++ b/public/panels.js @@ -204,12 +204,12 @@ async function renderDetail(root, device, serviceStatus) { const dashboardEditor = detailEl.querySelector('#dashboard-editor'); bindTodoPreviewRefresh(dashboardEditor, root, device.id); bindPrinterPreviewRefresh(dashboardEditor, root, device.id); - renderDashboardEditor(dashboardEditor, device, serviceStatus.trainApi, serviceStatus.busApi, serviceStatus.trafficApi, remembered, serviceStatus.todoLists, serviceStatus.printers, serviceStatus.haCalendars); + renderDashboardEditor(dashboardEditor, device, serviceStatus.trainApi, serviceStatus.busApi, serviceStatus.trafficApi, remembered, serviceStatus.todoLists, serviceStatus.printers, serviceStatus.haCalendars, serviceStatus.haTodos); } export async function renderPanels(root) { - const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, haCalendars] = await Promise.all([getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers'), getJson('/api/home-assistant/calendars').catch(() => ({ supported: false, available: false, calendars: [] }))]); + const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, haCalendars, haTodos] = await Promise.all([getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers'), getJson('/api/home-assistant/calendars').catch(() => ({ supported: false, available: false, calendars: [] })), getJson('/api/home-assistant/todo-lists').catch(() => ({ supported: false, available: false, lists: [] }))]); if (!devices.length) { root.innerHTML = '

No panels yet

Power one on and it will appear in the sidebar.

'; return; } if (!devices.some((d) => d.id === selectedId)) selectedId = devices[0].id; - await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars }); + await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars, haTodos }); } diff --git a/public/providerDrafts.js b/public/providerDrafts.js new file mode 100644 index 0000000..445891e --- /dev/null +++ b/public/providerDrafts.js @@ -0,0 +1,32 @@ +const defaults = { calendar: 'ical', todo: 'local' }; + +export function providerOf(type, config) { return defaults[type] ? config.provider ?? defaults[type] : null; } + +export function providerDraftState(widgets) { + const result = {}; + for (const widget of widgets) { + const provider = providerOf(widget.type, widget.config); + if (provider) (result[widget.type] ??= {})[provider] = structuredClone(widget); + } + return result; +} + +/** Only an explicit provider switch upgrades the active widget to V2. */ +export function switchProviderDraft(slot, type, provider, emptyConfig) { + const current = slot.drafts[type]; + slot.providerDrafts ??= {}; + const drafts = slot.providerDrafts[type] ??= {}; + drafts[providerOf(type, current)] = { type, version: slot.versions[type], config: structuredClone(current) }; + slot.drafts[type] = { ...structuredClone(drafts[provider]?.config ?? emptyConfig), provider }; + slot.versions[type] = 2; +} + +/** Active config first; inactive providers follow without replacing its version. */ +export function rememberedProviderDrafts(slot) { + return Object.entries(slot.drafts).flatMap(([type, config]) => [ + { type, version: slot.versions[type], config: structuredClone(config) }, + ...Object.entries(slot.providerDrafts?.[type] ?? {}) + .filter(([provider]) => provider !== providerOf(type, config)) + .map(([, widget]) => structuredClone(widget)), + ]); +} diff --git a/public/todoEditor.js b/public/todoEditor.js new file mode 100644 index 0000000..72a5d2b --- /dev/null +++ b/public/todoEditor.js @@ -0,0 +1,32 @@ +import { esc } from './components.js'; +import { switchProviderDraft } from './providerDrafts.js'; + +export function todoProviderHtml(config, discovery = {}) { + const provider = config.provider ?? 'local'; + return discovery.supported || provider === 'home-assistant' + ? `` : ''; +} + +export function homeAssistantTodoControlsHtml(config, discovery = {}) { + const known = discovery.lists ?? []; + const lists = [...known]; + if (config.entityId && !known.some((list) => list.entityId === config.entityId)) { + lists.push({ entityId: config.entityId, name: `${config.entityId} (missing/unavailable)` }); + } + const status = !discovery.available ? 'Home Assistant To Do lists are unavailable. Saved selection is retained.' + : known.length === 0 ? 'No Home Assistant To Do lists found.' : ''; + return `${status ? `

${status}

` : ''}

Read only in InkPanel. Manage tasks in Home Assistant, then select a list here and click Save changes.

`; +} + +export function rememberTodoConfig(panel, slot) { + if (slot.drafts.todo.provider === 'home-assistant') { + return { provider: 'home-assistant', entityId: panel.querySelector('[data-ha-todo-list]').value }; + } + const listId = panel.querySelector('[data-todo-list]')?.value ?? ''; + return slot.versions.todo === 2 ? { provider: 'local', listId } : { listId }; +} + +export function switchTodoProvider(slot, provider) { + if (!['local', 'home-assistant'].includes(provider)) return; + switchProviderDraft(slot, 'todo', provider, provider === 'local' ? { listId: '' } : { entityId: '' }); +} diff --git a/src/homeAssistant/client.ts b/src/homeAssistant/client.ts index 8128bab..f95c529 100644 --- a/src/homeAssistant/client.ts +++ b/src/homeAssistant/client.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { createHash } from 'node:crypto'; import { isValidTimezone } from '../devices/schema.ts'; +import { todoEntityIdSchema, homeAssistantTodoListsSchema, homeAssistantTodoResponseSchema } from './todoSchemas.ts'; +import type { TodoData } from '../model/dashboard.ts'; import { calendarEntityIdSchema, homeAssistantCalendarListSchema, homeAssistantCalendarEventsSchema, type HomeAssistantCalendarEvent, @@ -16,6 +18,13 @@ export interface HomeAssistantCalendarDiscovery { export type HomeAssistantMode = 'standalone' | 'home-assistant-app'; +export interface HomeAssistantTodoDiscovery { + supported: boolean; + available: boolean; + lists: Array<{ entityId: string; name: string }>; + error: string | null; +} + export interface HomeAssistantStatus { available: boolean; mode: HomeAssistantMode; @@ -120,7 +129,9 @@ export class HomeAssistantClient { ? createHash('sha256').update(this.baseUrl.href).digest('hex') : null; } - private async request(path: string, schema: z.ZodType, label: string, externalSignal?: AbortSignal): Promise> { + private async request(path: string, schema: z.ZodType, label: string, externalSignal?: AbortSignal, + options: { method: 'POST'; body: Record } | { method?: 'GET' } = {}, + ): Promise> { if (!this.enabled) return { available: false, error: 'Home Assistant is not enabled' }; if (!this.baseUrl) return { available: false, error: this.configurationError ?? 'Home Assistant configuration is invalid' }; if (!this.token) return { available: false, error: 'Supervisor token is unavailable' }; @@ -128,12 +139,14 @@ export class HomeAssistantClient { const signal = externalSignal ? AbortSignal.any([externalSignal, timeout]) : timeout; try { const response = await this.fetchImpl(new URL(path, this.baseUrl), { - method: 'GET', + method: options.method ?? 'GET', redirect: 'error', headers: { accept: 'application/json', authorization: `Bearer ${this.token}`, + ...(options.method === 'POST' ? { 'content-type': 'application/json' } : {}), }, + ...(options.method === 'POST' ? { body: JSON.stringify(options.body) } : {}), signal, }); if (!response.ok) return { available: false, error: `Home Assistant request failed (${response.status})` }; @@ -194,6 +207,21 @@ export class HomeAssistantClient { const query = new URLSearchParams({ start, end }); return this.request(`calendars/${encodeURIComponent(entityId)}?${query}`, homeAssistantCalendarEventsSchema, 'calendar events', signal); } + + async listTodoLists(signal?: AbortSignal): Promise { + const result = await this.request('states', homeAssistantTodoListsSchema, 'To Do discovery', signal); + return { supported: this.enabled, available: result.available, + lists: result.available ? result.data : [], + error: result.available || !this.enabled ? null : result.error }; + } + + async getTodoItems(entityId: string, signal?: AbortSignal): Promise> { + if (!todoEntityIdSchema.safeParse(entityId).success) { + return { available: false, error: 'invalid Home Assistant To Do entity ID' }; + } + return this.request('services/todo/get_items?return_response', homeAssistantTodoResponseSchema(entityId), + 'To Do items', signal, { method: 'POST', body: { entity_id: entityId, status: 'needs_action' } }); + } } export function isHomeAssistantMode(raw: string | undefined): boolean { diff --git a/src/homeAssistant/todoSchemas.ts b/src/homeAssistant/todoSchemas.ts new file mode 100644 index 0000000..0386826 --- /dev/null +++ b/src/homeAssistant/todoSchemas.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const todoEntityIdSchema = z.string().max(255).regex(/^todo\.[a-z0-9_]+$/, 'invalid Home Assistant To Do entity ID'); + +/** Validate the states envelope, then project only To Do identities and names. */ +export const homeAssistantTodoListsSchema = z.array(z.object({ + entity_id: z.string().min(1), attributes: z.unknown().optional(), +})).transform((states, ctx) => { + const lists: Array<{ entityId: string; name: string }> = []; + for (const state of states.filter((entry) => entry.entity_id.startsWith('todo.'))) { + const id = todoEntityIdSchema.safeParse(state.entity_id); + if (!id.success) { + ctx.addIssue({ code: 'custom', message: 'invalid To Do entity ID' }); + return z.NEVER; + } + const attributes = z.object({ friendly_name: z.unknown().optional() }).safeParse(state.attributes); + const name = z.string().trim().min(1).max(255).safeParse(attributes.success ? attributes.data.friendly_name : undefined); + lists.push({ entityId: id.data, name: name.success ? name.data : id.data.slice(5).replaceAll('_', ' ') }); + } + return [...new Map(lists.map((list) => [list.entityId, list])).values()] + .sort((a, b) => a.name.localeCompare(b.name) || a.entityId.localeCompare(b.entityId)); +}); + +const itemSchema = z.object({ + summary: z.string().trim().min(1).max(4096), + status: z.enum(['needs_action', 'completed']), +}); + +export function homeAssistantTodoResponseSchema(entityId: string) { + return z.object({ + changed_states: z.array(z.unknown()), + service_response: z.object({ [entityId]: z.object({ items: z.array(itemSchema) }) }), + }).transform((response) => ({ + items: response.service_response[entityId]!.items + .filter((item) => item.status === 'needs_action').slice(0, 5).map((item) => item.summary), + })); +} diff --git a/src/http/homeAssistantRoutes.ts b/src/http/homeAssistantRoutes.ts index ab9f4fd..b0b2905 100644 --- a/src/http/homeAssistantRoutes.ts +++ b/src/http/homeAssistantRoutes.ts @@ -3,6 +3,10 @@ import type { HomeAssistantClient } from '../homeAssistant/client.ts'; export function homeAssistantRoutes(client: HomeAssistantClient): Router { const router = Router(); + router.get('/home-assistant/todo-lists', async (_req, res) => { + res.set('cache-control', 'no-store'); + res.json(await client.listTodoLists()); + }); router.get('/home-assistant/calendars', async (_req, res) => { res.set('cache-control', 'no-store'); res.json(await client.listCalendars()); diff --git a/src/http/manageRoutes.ts b/src/http/manageRoutes.ts index fa66aa1..962f490 100644 --- a/src/http/manageRoutes.ts +++ b/src/http/manageRoutes.ts @@ -19,6 +19,7 @@ import { calendarUrlInputSchema } from '../sources/calendarUrl.ts'; import type { TodoStore } from '../todo/store.ts'; import type { PrinterConnectionStore } from '../printers/store.ts'; import { calendarEntityIdsSchema } from '../homeAssistant/calendarSchemas.ts'; +import { todoWidgetV2Schema } from '../widgets/registry.ts'; const stationCodeInputSchema = z .string() @@ -42,6 +43,7 @@ const octopusTariffCodeInputSchema = z ); const dashboardSectionInputSchema = z.union([ + todoWidgetV2Schema, z.strictObject({ type: z.literal('calendar'), version: z.literal(1), config: z.strictObject({ calendarUrls: z.array(calendarUrlInputSchema).max(10) }), @@ -288,7 +290,7 @@ export function manageRoutes( } for (const widget of sections) { - if (widget.type === 'todo' && widget.config.listId + if (widget.type === 'todo' && 'listId' in widget.config && widget.config.listId && (!todoStore || !(await todoStore.get(widget.config.listId)))) { res.status(400).json({ error: 'unknown To Do list', listId: widget.config.listId }); return; diff --git a/src/http/todoRoutes.ts b/src/http/todoRoutes.ts index 78951d6..c7bdf53 100644 --- a/src/http/todoRoutes.ts +++ b/src/http/todoRoutes.ts @@ -50,7 +50,7 @@ export function todoRoutes(devices: DeviceStore, todos: TodoStore): Router { if (!id.success) return invalid(res, id); const referencedBy = (await devices.list()) .filter((device) => device.dashboardSections.some( - (widget) => widget.type === 'todo' && widget.config.listId === id.data, + (widget) => widget.type === 'todo' && 'listId' in widget.config && widget.config.listId === id.data, )) .map((device) => ({ id: device.id, name: device.name })); if (referencedBy.length > 0) { diff --git a/src/model/dashboard.ts b/src/model/dashboard.ts index 2c75460..160de03 100644 --- a/src/model/dashboard.ts +++ b/src/model/dashboard.ts @@ -75,7 +75,7 @@ export type DashboardSectionData = | { type: 'bus'; data: BusData | null; health: SourceHealth | null } | { type: 'traffic'; data: TrafficData | null; health: SourceHealth | null } | { type: 'octopus'; data: OctopusAgileData | null; health: SourceHealth | null } - | { type: 'todo'; data: TodoData | null; configured: boolean; health: null } + | { type: 'todo'; data: TodoData | null; configured: boolean; health: SourceHealth | null } | { type: 'printers'; data: { printers: PrinterStatus[] } | null; configured: boolean; health: SourceHealth | null } | { type: 'bins'; data: BinsData | null; health: SourceHealth | null } | { type: 'empty' }; diff --git a/src/render/frameService.ts b/src/render/frameService.ts index 515306b..77fed79 100644 --- a/src/render/frameService.ts +++ b/src/render/frameService.ts @@ -15,6 +15,7 @@ import type { DeviceRecord } from '../devices/types.ts'; import type { IcalFeedConfig } from '../sources/ical.ts'; import { runCalendars } from '../sources/calendarRunner.ts'; import { runHomeAssistantCalendars } from '../sources/homeAssistantCalendar.ts'; +import { runHomeAssistantTodo } from '../sources/homeAssistantTodo.ts'; import type { HomeAssistantClient } from '../homeAssistant/client.ts'; import { openMeteoSource } from '../sources/openMeteo.ts'; import { binsSource } from '../sources/bins.ts'; @@ -237,7 +238,12 @@ export class FrameService { break; } case 'todo': { - if (!widget.config.listId || !this.deps.todoStore) { + if ('entityId' in widget.config) { + request = widget.config.entityId + ? runHomeAssistantTodo(widget.config.entityId, this.deps.homeAssistantClient, runOptions) + .then((outcome) => ({ type: 'todo', data: outcome.data, configured: true, health: outcome.health })) + : Promise.resolve({ type: 'todo', data: null, configured: false, health: null }); + } else if (!widget.config.listId || !this.deps.todoStore) { request = Promise.resolve({ type: 'todo', data: null, configured: false, health: null }); } else { request = this.deps.todoStore.get(widget.config.listId).then((list) => ({ diff --git a/src/sources/homeAssistantTodo.ts b/src/sources/homeAssistantTodo.ts new file mode 100644 index 0000000..231aa99 --- /dev/null +++ b/src/sources/homeAssistantTodo.ts @@ -0,0 +1,22 @@ +import type { HomeAssistantClient } from '../homeAssistant/client.ts'; +import type { TodoData } from '../model/dashboard.ts'; +import { runLiveSource, type RunSourceOptions } from './runner.ts'; +import type { Source } from './types.ts'; + +/** Task completion is live-only: never replay a stale task list from disk. */ +export function runHomeAssistantTodo(entityId: string, client: HomeAssistantClient | undefined, options: RunSourceOptions) { + const source: Source = { + id: 'home-assistant-todo', + async fetch(id, signal) { + try { + const result = await client?.getTodoItems(id, signal); + return result?.available + ? { status: 'ok', data: result.data, fetchedAt: new Date().toISOString() } + : { status: 'error', error: result?.error ?? 'Home Assistant To Do is unavailable' }; + } catch { + return { status: 'error', error: 'Home Assistant To Do is unavailable' }; + } + }, + }; + return runLiveSource(source, entityId, options); +} diff --git a/src/widgets/editorPreferences.ts b/src/widgets/editorPreferences.ts index 6aaf6a8..87e9d0a 100644 --- a/src/widgets/editorPreferences.ts +++ b/src/widgets/editorPreferences.ts @@ -9,19 +9,25 @@ import { const MAX_WIDGET_TYPES = Object.keys(widgetRegistry).length; +function draftKey(widget: DashboardWidget): string { + const provider = widget.type === 'calendar' ? ('provider' in widget.config ? widget.config.provider : 'ical') + : widget.type === 'todo' ? ('provider' in widget.config ? widget.config.provider : 'local') : ''; + return `${widget.type}:${provider}`; +} + export const dashboardEditorSlotSchema = z.array(dashboardWidgetSchema) - .max(MAX_WIDGET_TYPES) + .max(MAX_WIDGET_TYPES + 2) // Calendar and To Do each have two provider drafts. .superRefine((widgets, ctx) => { const seen = new Set(); widgets.forEach((widget, index) => { - if (seen.has(widget.type)) { + if (seen.has(draftKey(widget))) { ctx.addIssue({ code: 'custom', path: [index, 'type'], message: `duplicate remembered widget type: ${widget.type}`, }); } - seen.add(widget.type); + seen.add(draftKey(widget)); }); }); @@ -67,7 +73,7 @@ function meaningful(widget: DashboardWidget): boolean { case 'bus': return Boolean(widget.config.stopCode); case 'traffic': return Boolean(widget.config.origin.trim() && widget.config.destination.trim()); case 'octopus': return Boolean(widget.config.tariffCode); - case 'todo': return Boolean(widget.config.listId); + case 'todo': return Boolean('entityId' in widget.config ? widget.config.entityId : widget.config.listId); case 'printers': return widget.config.printerIds.length > 0; case 'bins': return Boolean(widget.config.uprn); case 'weather': @@ -77,13 +83,18 @@ function meaningful(widget: DashboardWidget): boolean { } function mergeShared(current: DashboardWidget[], slots: DashboardEditorSlots): DashboardWidget[] { - const byType = new Map(current.map((widget) => [widget.type, clone(widget)])); + // Entries are active-first; process in reverse so the active provider ends + // up first again, and the most recently saved useful selection wins. + const byDraft = new Map([...current].reverse().map((widget) => [draftKey(widget), clone(widget)])); for (const slot of slots) { - for (const widget of slot) { - if (meaningful(widget)) byType.set(widget.type, clone(widget)); + for (const widget of [...slot].reverse()) { + if (meaningful(widget)) { + byDraft.delete(draftKey(widget)); + byDraft.set(draftKey(widget), clone(widget)); + } } } - return [...byType.values()]; + return [...byDraft.values()].reverse(); } /** @@ -91,7 +102,7 @@ function mergeShared(current: DashboardWidget[], slots: DashboardEditorSlots): D * * DeviceStore continues to describe only what a panel is actively rendering. * This owner-only file remembers inactive widget drafts for each panel/slot and - * one last-useful config per type as a fallback for other panels. Calendar URLs + * one last-useful config per type/provider as a fallback for other panels. Calendar URLs * and route addresses can be sensitive, so it uses the same 0600 atomic file * helper as managed provider credentials. * diff --git a/src/widgets/registry.ts b/src/widgets/registry.ts index 6658989..98b55c6 100644 --- a/src/widgets/registry.ts +++ b/src/widgets/registry.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { calendarEntityIdsSchema } from '../homeAssistant/calendarSchemas.ts'; +import { todoEntityIdSchema } from '../homeAssistant/todoSchemas.ts'; /** Persisted calendar URLs stay broad so existing private feeds remain readable. */ export const calendarWidgetConfigV1Schema = z.strictObject({ @@ -55,6 +56,14 @@ export const todoWidgetConfigV1Schema = z.strictObject({ listId: z.string().regex(/^(?:|[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)$/, 'invalid To Do list id'), }); +export const todoWidgetConfigV2Schema = z.discriminatedUnion('provider', [ + z.strictObject({ provider: z.literal('local'), listId: todoWidgetConfigV1Schema.shape.listId }), + z.strictObject({ provider: z.literal('home-assistant'), entityId: z.union([z.literal(''), todoEntityIdSchema]) }), +]); +export const todoWidgetV2Schema = z.strictObject({ + type: z.literal('todo'), version: z.literal(2), config: todoWidgetConfigV2Schema, +}); + export const printersWidgetConfigV1Schema = z.strictObject({ printerIds: z.array(z.string().uuid('invalid printer id')).max(4) .refine((ids) => new Set(ids).size === ids.length, 'printer IDs must be unique'), @@ -126,6 +135,7 @@ export type DashboardWidget = | z.infer | z.infer | z.infer + | z.infer | z.infer | z.infer | z.infer; @@ -138,7 +148,7 @@ export const widgetRegistry = { bus: { 1: busWidgetV1Schema }, traffic: { 1: trafficWidgetV1Schema }, octopus: { 1: octopusWidgetV1Schema }, - todo: { 1: todoWidgetV1Schema }, + todo: { 1: todoWidgetV1Schema, 2: todoWidgetV2Schema }, printers: { 1: printersWidgetV1Schema }, bins: { 1: binsWidgetV1Schema }, empty: { 1: emptyWidgetV1Schema }, diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index 36231be..f9e4c69 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.6'); + assert.equal(config.version, '0.1.0-ha.7'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); @@ -48,7 +48,7 @@ test('the image workflow builds, verifies and embeds production firmware before assert.match(workflow, /build-image@4de35182/); assert.match(workflow, /publish-multi-arch-manifest@4de35182/); assert.match(workflow, /\["amd64", "aarch64"\]/); - assert.match(workflow, /VERSION: 0\.1\.0-ha\.6/); + assert.match(workflow, /VERSION: 0\.1\.0-ha\.7/); assert.match(workflow, /matrix: \$\{\{ steps\.prepare\.outputs\.matrix \}\}/); assert.match(workflow, /runs-on: \$\{\{ matrix\.os \}\}/); assert.match(workflow, /registry-prefix: ghcr\.io\/ctrlaltcouk/); diff --git a/test/homeAssistant/todo.test.ts b/test/homeAssistant/todo.test.ts new file mode 100644 index 0000000..e498c17 --- /dev/null +++ b/test/homeAssistant/todo.test.ts @@ -0,0 +1,86 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; + +const token = 'supervisor-secret-not-for-output'; +const response = (items: unknown[] = []) => ({ changed_states: [{ attributes: { token } }], service_response: { 'todo.home': { items } } }); +const item = (summary: string, status = 'needs_action') => ({ summary, status, uid: token, description: token, due: token }); + +test('To Do discovery filters states and projects safe friendly/fallback names', async () => { + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async (url, init) => { + assert.equal(String(url), 'http://supervisor/core/api/states'); + assert.equal(init?.method, 'GET'); + assert.equal(init?.redirect, 'error'); + assert.equal(new Headers(init?.headers).get('authorization'), `Bearer ${token}`); + return Response.json([ + { entity_id: 'sensor.secret', attributes: { token } }, + { entity_id: 'todo.home', attributes: { friendly_name: ' Home tasks ', token }, state: '2' }, + { entity_id: 'todo.shopping_list', attributes: { friendly_name: 123, token } }, + { entity_id: 'todo.work', attributes: {} }, + ]); + } }); + assert.deepEqual(await client.listTodoLists(), { supported: true, available: true, error: null, lists: [ + { entityId: 'todo.home', name: 'Home tasks' }, { entityId: 'todo.shopping_list', name: 'shopping list' }, + { entityId: 'todo.work', name: 'work' }, + ] }); +}); + +test('To Do discovery is unsupported offline and rejects malformed responses safely', async () => { + const disabled = new HomeAssistantClient({ enabled: false, fetchImpl: async () => { assert.fail('no standalone HA fetch'); } }); + assert.deepEqual(await disabled.listTodoLists(), { supported: false, available: false, lists: [], error: null }); + for (const body of [{ states: [] }, [null], [{ attributes: {} }], [{ entity_id: 'todo.bad/path' }]]) { + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async () => Response.json(body) }); + assert.deepEqual(await client.listTodoLists(), { supported: true, available: false, lists: [], error: 'Home Assistant returned an invalid To Do discovery response' }); + } + const offline = new HomeAssistantClient({ enabled: true, token, fetchImpl: async () => { throw new Error(token); } }); + assert.equal((await offline.listTodoLists()).error, 'Home Assistant is unavailable'); +}); + +test('get_items posts needs_action with return_response, preserves order and projects five texts only', async () => { + const client = new HomeAssistantClient({ enabled: true, baseUrl: 'http://ha.test/api', token, fetchImpl: async (url, init) => { + assert.equal(String(url), 'http://ha.test/api/services/todo/get_items?return_response'); + assert.equal(init?.method, 'POST'); + assert.equal(init?.redirect, 'error'); + assert.equal(new Headers(init?.headers).get('authorization'), `Bearer ${token}`); + assert.equal(new Headers(init?.headers).get('content-type'), 'application/json'); + assert.deepEqual(JSON.parse(String(init?.body)), { entity_id: 'todo.home', status: 'needs_action' }); + return Response.json(response([item(' Done ', 'completed'), ...[' Z ', 'A', 'C', 'B', 'E', 'Hidden'].map((text) => item(text))])); + } }); + assert.deepEqual(await client.getTodoItems('todo.home'), { available: true, data: { items: ['Z', 'A', 'C', 'B', 'E'] } }); +}); + +test('get_items validates IDs before HTTP and rejects malformed service data', async () => { + let calls = 0; + let body: unknown = response(); + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async () => { calls++; return Response.json(body); } }); + for (const id of ['', 'calendar.home', 'todo.A', 'todo.x/../../services', 'todo.x?token=secret', `todo.${'a'.repeat(256)}`]) { + assert.equal((await client.getTodoItems(id)).available, false); + } + assert.equal(calls, 0); + assert.deepEqual(await client.getTodoItems('todo.home'), { available: true, data: { items: [] } }); + for (const invalid of [[], {}, { ...response(), changed_states: null }, + { changed_states: [], service_response: { 'todo.other': { items: [] } } }, + response([item(' ')]), response([item('Text', 'unknown')]), response([{ summary: 'Text' }]), + response([{ summary: 12, status: 'needs_action' }]), response([null]), + ]) { + body = invalid; + assert.deepEqual(await client.getTodoItems('todo.home'), { available: false, error: 'Home Assistant returned an invalid To Do items response' }); + } +}); + +test('POST service errors, timeouts and caller cancellation are safe and retryable', async () => { + for (const status of [400, 401, 404, 500, 503]) { + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async () => new Response(token, { status }) }); + assert.deepEqual(await client.getTodoItems('todo.home'), { available: false, error: `Home Assistant request failed (${status})` }); + } + const client = new HomeAssistantClient({ enabled: true, token, timeoutMs: 10, + fetchImpl: async (_url, init) => new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error(token)), 500); + const abort = () => { clearTimeout(timer); reject(new Error(token)); }; + if (init?.signal?.aborted) abort(); + else init?.signal?.addEventListener('abort', abort, { once: true }); + }), + }); + assert.deepEqual(await client.getTodoItems('todo.home'), { available: false, error: 'Home Assistant request timed out' }); + assert.deepEqual(await client.getTodoItems('todo.home', AbortSignal.abort()), { available: false, error: 'Home Assistant request was cancelled' }); +}); diff --git a/test/http/homeAssistantIngress.test.ts b/test/http/homeAssistantIngress.test.ts index 9add5a6..15497ac 100644 --- a/test/http/homeAssistantIngress.test.ts +++ b/test/http/homeAssistantIngress.test.ts @@ -22,6 +22,7 @@ function app(access: 'lan' | 'trusted-ingress' | 'real-ingress', activeHttps: nu token: 'server-only-supervisor-token', fetchImpl: async (url) => String(url).endsWith('/calendars') ? Response.json([{ entity_id: 'calendar.family', name: 'Family', attributes: { secret: 'server-only-supervisor-token' } }]) + : String(url).endsWith('/states') ? Response.json([{ entity_id: 'todo.family', attributes: { friendly_name: 'Family', token: 'server-only-supervisor-token' } }]) : Response.json({ version: '2026.8.1', location_name: 'Home', time_zone: 'Europe/London', }), @@ -83,6 +84,14 @@ test('calendar discovery uses the existing authentication boundary and returns o assert.doesNotMatch(JSON.stringify(result.body), /supervisor|authorization|attributes|http:/i); }); +test('To Do discovery shares LAN/Ingress auth and projects only identities and names', async () => { + assert.equal((await requestJson(app('lan'), '/api/home-assistant/todo-lists')).status, 401); + assert.equal((await requestJson(app('real-ingress'), '/api/home-assistant/todo-lists')).status, 403); + const result = await requestJson(app('trusted-ingress'), '/api/home-assistant/todo-lists'); + assert.deepEqual(result.body, { supported: true, available: true, lists: [{ entityId: 'todo.family', name: 'Family' }], error: null }); + assert.doesNotMatch(JSON.stringify(result.body), /supervisor|token|attributes|http:/i); +}); + test('HA runtime config exposes only the active direct HTTPS root for WebFlash', async () => { assert.deepEqual((await requestJson(app('trusted-ingress'), '/api/runtime-config')).body, { httpsPort: null, updateMode: 'home-assistant', diff --git a/test/http/todoRoutes.test.ts b/test/http/todoRoutes.test.ts index f93b0bd..e214aa7 100644 --- a/test/http/todoRoutes.test.ts +++ b/test/http/todoRoutes.test.ts @@ -71,6 +71,31 @@ test('To Do management CRUD is authenticated and preserves task order/completion }); }); +test('V2 local saves validate list existence; HA IDs persist offline without migrating V1', async () => { + await withServer(async (base, cookie, devices, todos) => { + assert.equal((await fetch(`${base}/api/home-assistant/todo-lists`)).status, 401); + assert.deepEqual(await (await request(base, cookie, 'GET', '/api/home-assistant/todo-lists')).json(), { + supported: false, available: false, lists: [], error: null, + }); + const device = await devices.getOrCreate('esp32-provider', 'ssd1681-200x200-mono'); + const list = await todos.create('Home'); + const save = (widget: unknown) => request(base, cookie, 'PUT', `/api/devices/${device.id}`, { dashboardSections: [widget] }); + const local = { type: 'todo', version: 2, config: { provider: 'local', listId: list.id } }; + assert.equal((await save({ ...local, config: { provider: 'local', listId: 'missing' } })).status, 400); + assert.equal((await save(local)).status, 200); + assert.equal((await request(base, cookie, 'DELETE', `/api/todo-lists/${list.id}`)).status, 409); + const ha = { type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.removed' } }; + assert.equal((await save(ha)).status, 200, 'syntax is sufficient; discovery need not be available'); + assert.deepEqual((await devices.get(device.id))?.dashboardSections, [ha]); + assert.equal((await request(base, cookie, 'DELETE', `/api/todo-lists/${list.id}`)).status, 204, 'HA config is not a local list reference'); + const legacy = await todos.create('Legacy'); + const v1 = { type: 'todo', version: 1, config: { listId: legacy.id } }; + assert.equal((await save(v1)).status, 200); + assert.equal((await request(base, cookie, 'PUT', `/api/devices/${device.id}`, { name: 'Renamed' })).status, 200); + assert.deepEqual((await devices.get(device.id))?.dashboardSections, [v1]); + }); +}); + test('invalid and stale To Do requests return useful client errors', async () => { await withServer(async (base, cookie, devices) => { assert.equal((await request(base, cookie, 'POST', '/api/todo-lists', { name: '' })).status, 400); diff --git a/test/public/todoProviderUx.test.js b/test/public/todoProviderUx.test.js new file mode 100644 index 0000000..b8e81b5 --- /dev/null +++ b/test/public/todoProviderUx.test.js @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { chromium } from 'playwright'; +import { join } from 'node:path'; + +test('To Do provider UX preserves local CRUD, versions, both drafts, missing entities and dirty state', async () => { + const app = express(); + app.get('/harness', (_req, res) => res.type('html').send(`
Saved
`)); + app.use(express.static(join(process.cwd(), 'public'))); + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + try { + await page.goto(`http://127.0.0.1:${server.address().port}/harness`); + await page.locator('[data-todo-list]').waitFor(); + assert.equal(await page.locator('[data-todo-provider]').count(), 0, 'standalone UI unchanged'); + assert.equal((await page.evaluate(() => window.collect())).sections[0].version, 1); + for (const mini of [true, false]) { + await page.evaluate((mini) => window.load({ type: 'todo', version: 1, config: { listId: 'home' } }, true, {}, mini), mini); + assert.equal(await page.locator('[data-todo-provider]').inputValue(), 'local'); + for (const selector of ['[data-todo-create]', '[data-todo-rename-button]', '[data-todo-delete-list]', '[data-todo-add]', '[data-todo-completed]', '[data-todo-move]', '[data-todo-delete-item]']) { + assert.ok(await page.locator(selector).count(), selector); + } + await page.locator('[data-todo-new-task]').fill('Only a task draft'); + assert.equal(await page.locator('#state').textContent(), 'Saved'); + await page.locator('[data-todo-list]').selectOption('work'); + assert.equal(await page.locator('#state').textContent(), 'Unsaved'); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-todo-create], [data-todo-completed], [data-todo-list]').count(), 0, 'HA provider is read-only'); + assert.match(await page.locator('#editor').textContent(), /Shopping |Read only/); + await page.locator('[data-ha-todo-list]').selectOption('todo.shopping'); + let saved = await page.evaluate(() => window.collect()); + assert.deepEqual(saved.sections[0], { type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.shopping' } }); + assert.equal(saved.remembered.slots[0].filter((widget) => widget.type === 'todo').length, 2); + await page.locator('[data-todo-provider]').selectOption('local'); + assert.equal(await page.locator('[data-todo-list]').inputValue(), 'work'); + assert.deepEqual((await page.evaluate(() => window.collect())).sections[0], {type:'todo', version:2, config:{provider:'local', listId:'work'}}); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), 'todo.shopping'); + await page.locator('[data-widget-type]').selectOption('weather'); + await page.locator('[data-widget-type]').selectOption('todo'); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), 'todo.shopping'); + saved = await page.evaluate(() => window.collect()); + await page.evaluate((saved) => window.load(saved.sections[0], true, saved.remembered), saved); + await page.locator('[data-todo-provider]').selectOption('local'); + assert.equal(await page.locator('[data-todo-list]').inputValue(), 'work', 'local draft survives save/reload'); + await page.evaluate((saved) => window.load({type:'weather', version:1, config:{}}, true, {shared:saved.remembered.slots[0]}), saved); + await page.locator('[data-widget-type]').selectOption('todo'); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), 'todo.shopping', 'shared active provider is retained'); + } + await page.evaluate(() => window.load({type:'todo', version:2, config:{provider:'home-assistant', entityId:'todo.removed'}}, false)); + assert.match(await page.locator('#editor').textContent(), /todo.removed \(missing\/unavailable\)/); + assert.equal((await page.evaluate(() => window.collect())).sections[0].config.entityId, 'todo.removed'); + assert.equal(await page.locator('#state').textContent(), 'Saved'); + await page.evaluate(() => window.load({type:'todo', version:2, config:{provider:'home-assistant', entityId:'todo.removed'}}, true)); + await page.locator('[data-ha-todo-list]').selectOption('todo.work'); + assert.equal((await page.evaluate(() => window.collect())).sections[0].config.entityId, 'todo.work'); + assert.equal(await page.locator('#state').textContent(), 'Unsaved', 'missing selection changes only on explicit choice'); + } finally { await browser.close(); await new Promise((resolve) => server.close(resolve)); } +}); diff --git a/test/render/homeAssistantTodo.test.ts b/test/render/homeAssistantTodo.test.ts new file mode 100644 index 0000000..08ab871 --- /dev/null +++ b/test/render/homeAssistantTodo.test.ts @@ -0,0 +1,94 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FrameService } from '../../src/render/frameService.ts'; +import { Renderer } from '../../src/render/browser.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { SourceCache } from '../../src/sources/cache.ts'; +import { defaultDevice } from '../../src/devices/types.ts'; +import { TodoStore } from '../../src/todo/store.ts'; +import { WEATHER } from '../fixtures/dashboard.ts'; + +const weatherSource = { id: 'weather', async fetch() { return { status: 'ok' as const, data: WEATHER, fetchedAt: new Date().toISOString() }; } }; + +test('HA To Do uses unchanged full-size/Mini layouts, live-only data and per-frame deduplication', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-ha-todo-')); + const renderer = new Renderer(); + let calls = 0; + let failed = false; + let items = ['Buy milk', 'Take bins out', 'Third', 'Fourth', 'Fifth', 'Hidden sixth'] + .map((summary) => ({ summary, status: 'needs_action', uid: 'private-uid', description: 'private-description' })); + const client = new HomeAssistantClient({ enabled: true, token: 'secret-supervisor-token', fetchImpl: async (url) => { + if (String(url).includes('/calendars/')) return Response.json([]); + calls++; + if (failed) return new Response('secret-supervisor-token', { status: 503 }); + return Response.json({ changed_states: [], service_response: { 'todo.home': { items } } }); + } }); + const cachePath = join(dir, 'cache'); + const frames = new FrameService({ renderer, cache: new SourceCache(cachePath), weatherSource, homeAssistantClient: client }); + try { + for (const profile of ['wft0583-800x480-mono', 'ssd1681-200x200-mono'] as const) { + failed = false; + items = ['Buy milk', 'Take bins out', 'Third', 'Fourth', 'Fifth', 'Hidden sixth'] + .map((summary) => ({ summary, status: 'needs_action', uid: 'private-uid', description: 'private-description' })); + const device = defaultDevice(`esp32-${profile}`, profile); + const todo = { type: 'todo' as const, version: 2 as const, config: { provider: 'home-assistant' as const, entityId: 'todo.home' } }; + device.dashboardSections = profile === 'ssd1681-200x200-mono' ? [todo] : [todo, todo, + { type: 'weather', version: 1, config: {} }, + { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.home'] } }]; + const html = await frames.previewHtml(device); + assert.match(html, /Buy milk/); + assert.doesNotMatch(html, /Hidden sixth|private-uid|private-description|secret-supervisor-token/); + const beforeCalls = calls; + const first = await frames.frameFor(device, null); + assert.equal(calls - beforeCalls, 1, 'identical HA widgets share one request within a frame'); + assert.equal(first.buffer.length, profile === 'ssd1681-200x200-mono' ? 5000 : 48000); + items[0]!.uid = 'changed-hidden-metadata'; + items[5]!.summary = 'Changed invisible sixth'; + assert.equal((await frames.frameFor(device, null)).etag, first.etag); + items[0]!.status = 'completed'; + assert.notEqual((await frames.frameFor(device, null)).etag, first.etag, 'completion changes the visible list'); + failed = true; + const unavailable = await frames.previewHtml(device); + assert.doesNotMatch(unavailable, /Buy milk|Take bins out/, 'no stale task replay'); + await frames.frameFor(device, null); + assert.ok(frames.sourceIssues().some((issue) => issue.deviceId === device.id && issue.sourceId.includes('home-assistant-todo'))); + if (profile === 'wft0583-800x480-mono') { + assert.match(unavailable.split('')[1]!, /Next 3 days/); + assert.equal(frames.sourceIssues().filter((issue) => issue.deviceId === device.id).length, 2, 'only the two To Do slots fail'); + } + failed = false; + items = []; + assert.match(await frames.previewHtml(device), /All done|ALL DONE/); + const beforeEmpty = calls; + device.dashboardSections[0] = { ...todo, config: { provider: 'home-assistant', entityId: '' } }; + if (device.dashboardSections.length === 4) device.dashboardSections[1] = { type: 'empty', version: 1, config: {} }; + assert.match(await frames.previewHtml(device), /not set up|Not set up/); + assert.equal(calls, beforeEmpty, 'unconfigured HA To Do makes no service request'); + } + for (const file of await readdir(cachePath)) { + assert.doesNotMatch(await readFile(join(cachePath, file), 'utf8'), /Buy milk|private-uid|private-description|secret-supervisor-token/); + } + } finally { await renderer.close(); await rm(dir, { recursive: true, force: true }); } +}); + +test('V2 local To Do renders exactly like V1 on both profiles', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-local-v2-')); + const renderer = new Renderer(); + try { + const todos = new TodoStore(join(dir, 'todos.json')); + const list = await todos.create('Local'); + await todos.addItem(list.id, 'Existing local task'); + const frames = new FrameService({ renderer, cache: new SourceCache(join(dir, 'cache')), weatherSource, todoStore: todos }); + for (const profile of ['wft0583-800x480-mono', 'ssd1681-200x200-mono'] as const) { + const device = defaultDevice(`esp32-${profile}`, profile); + device.dashboardSections = device.dashboardSections.map(() => ({ type: 'todo', version: 1, config: { listId: list.id } })); + const first = await frames.frameFor(device, null); + device.dashboardSections = device.dashboardSections.map(() => ({ type: 'todo', version: 2, config: { provider: 'local', listId: list.id } })); + assert.equal((await frames.frameFor(device, null)).etag, first.etag); + assert.match(await frames.previewHtml(device), /Existing local task/); + } + } finally { await renderer.close(); await rm(dir, { recursive: true, force: true }); } +}); diff --git a/test/widgets/editorPreferences.test.ts b/test/widgets/editorPreferences.test.ts index b9608a0..04dd1b5 100644 --- a/test/widgets/editorPreferences.test.ts +++ b/test/widgets/editorPreferences.test.ts @@ -27,13 +27,36 @@ test('Calendar V2 preferences preserve version and empty provider drafts never r await store.set('p', slots); const loaded = new DashboardEditorPreferencesStore(path); await loaded.load(); assert.deepEqual(loaded.get('p').slots, slots); - assert.deepEqual(loaded.get('new').shared, slots[0]); + assert.deepEqual(loaded.get('new').shared[0], slots[0][0]); + assert.deepEqual(loaded.get('new').shared[1]?.config, { calendarUrls: ['https://example.com/feed'] }, 'inactive iCal provider remains remembered'); slots[0] = [{ type: 'calendar', version: 2, config: { provider: 'ical', calendarUrls: [] } }]; await loaded.set('p', slots); assert.deepEqual(loaded.get('new').shared[0]!.config, { provider: 'home-assistant', entityIds: ['calendar.home'] }); } finally { await rm(dir, { recursive: true, force: true }); } }); +test('provider-specific To Do and Calendar drafts persist per-slot and as shared fallbacks', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-provider-prefs-')); + const path = join(dir, 'preferences.json'); + try { + const store = new DashboardEditorPreferencesStore(path); + const slots = emptySlots(); + slots[0] = [ + { type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.home' } }, + { type: 'todo', version: 1, config: { listId: 'local-home' } }, + { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.home'] } }, + { type: 'calendar', version: 1, config: { calendarUrls: ['https://example.com/feed'] } }, + ]; + await store.set('p', slots); + const reloaded = new DashboardEditorPreferencesStore(path); await reloaded.load(); + assert.deepEqual(reloaded.get('p').slots, slots); + assert.deepEqual(reloaded.get('other').shared, slots[0]); + const duplicate = emptySlots(); + duplicate[0] = [slots[0][0]!, slots[0][0]!]; + await assert.rejects(store.set('p', duplicate), /duplicate remembered/); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + test('remembered drafts persist per panel while useful values become shared fallbacks', async () => { const dir = await mkdtemp(join(tmpdir(), 'inkpanel-editor-prefs-')); const path = join(dir, '.dashboard-editor-preferences.json'); diff --git a/test/widgets/todoRegistry.test.ts b/test/widgets/todoRegistry.test.ts index ffefb02..4c22911 100644 --- a/test/widgets/todoRegistry.test.ts +++ b/test/widgets/todoRegistry.test.ts @@ -30,3 +30,20 @@ test('adding To Do uses the generic V3 envelope without a DeviceStore migration' const existing = defaultDeviceV3('esp32-existing'); assert.equal(deviceRecordV3Schema.safeParse(existing).success, true, 'existing widget configurations remain valid'); }); + +test('To Do V2 discriminates local and HA providers without changing V1 or store versions', () => { + for (const config of [{ provider: 'local', listId: 'home' }, { provider: 'home-assistant', entityId: 'todo.shopping_list' }, + { provider: 'home-assistant', entityId: '' }]) { + const widget = { type: 'todo', version: 2, config }; + assert.deepEqual(dashboardWidgetSchema.parse(widget), widget); + const mini = defaultDeviceV3('esp32-mini', 'ssd1681-200x200-mono'); + assert.ok(deviceRecordV3Schema.safeParse({ ...mini, dashboardSections: [widget] }).success); + } + for (const config of [{ provider: 'local', entityId: 'todo.home' }, { provider: 'home-assistant', listId: 'home' }, + { provider: 'home-assistant', entityId: 'todo.HOME' }, { provider: 'home-assistant', entityId: 'todo.a/evil' }, + { provider: 'home-assistant', entityId: 'sensor.home' }, { provider: 'other', listId: 'home' }, + { provider: 'local', listId: 'BAD ID' }, { provider: 'home-assistant', entityId: 'todo.home', token: 'secret' }]) { + assert.equal(dashboardWidgetSchema.safeParse({ type: 'todo', version: 2, config }).success, false); + } + assert.equal(CURRENT_DEVICE_STORE_SCHEMA_VERSION, 3); +}); From 160b81e59a2c6e3b2b0ca3ec46ec99a8f9c4459e Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Thu, 27 Aug 2026 20:58:13 +0100 Subject: [PATCH 10/14] Fix Studio asset and preview freshness for ha.8 --- .github/workflows/home-assistant-image.yml | 2 +- docs/home-assistant-app.md | 27 ++-- home-assistant/CHANGELOG.md | 8 + home-assistant/README.md | 6 +- home-assistant/config.yaml | 2 +- public/panels.js | 23 ++- src/http/app.ts | 8 +- test/homeAssistant/package.test.ts | 4 +- test/http/app.test.ts | 26 ++++ test/http/manageRoutes.test.ts | 1 + test/public/panelsReliability.test.js | 171 +++++++++++++++++++++ test/public/paths.test.js | 3 +- 12 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 test/public/panelsReliability.test.js diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 6414690..5e12bef 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.7 + VERSION: 0.1.0-ha.8 ARCHITECTURES: '["amd64", "aarch64"]' jobs: diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index c0ac845..663c03f 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do is implemented in `0.1.0-ha.7` and awaits real-installation validation on the `Home-Assistant` branch. +Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do was implemented in `0.1.0-ha.7`; real-installation testing exposed Studio reliability issues addressed in `0.1.0-ha.8` on the `Home-Assistant` branch. HA-3 is not yet fully validated and requires retesting. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -150,7 +150,7 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.7` for linux/amd64 and linux/arm64. +The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.8` for linux/amd64 and linux/arm64. #### First-time panel location defaults (ha.6; validated on real hardware) @@ -189,14 +189,23 @@ Calendar and To Do share provider draft handling. Active widget drafts retain th Missing or removed HA entities are shown as missing/unavailable without clearing the saved ID. Syntax is sufficient for saving HA config; discovery availability is never required to read/save an existing panel. Local V1/V2 selections still require a real TodoStore list when saved. -#### Real-installation validation for ha.7 +#### ha.8 Studio reliability -1. Upgrade the App to ha.7 and open Studio through Ingress. -2. Select To Do → Home Assistant, choose a list, and save on a full-size panel and a Mini. -3. Verify the first five incomplete items match HA ordering, then complete/add/reorder items in HA and wake the panel or refresh its preview. -4. Complete all items and verify ALL DONE. Temporarily make the source unavailable and verify no stale task list is replayed and unrelated widgets remain usable. -5. Switch to InkPanel list and back, save/reopen, and verify both selections survive. Existing local CRUD and Calendar provider choices should remain intact. -6. Remove a selected HA entity and verify Studio retains its missing selection until explicitly changed. +Stable-URL Studio HTML, JS, CSS and dynamic modules now use `Cache-Control: no-store` with asset validators disabled. Vendor fonts retain their intentional immutable policy. The same static serving policy applies to Ingress and LAN; WebFlash/module paths are unchanged. + +Studio uses the existing non-secret `/api/runtime-config` `updateMode` as the authoritative HA capability signal, rather than treating a failed discovery request as unsupported. Discovery supplies availability and choices. Calendar and To Do selectors stay enabled in App mode during discovery failure, show their existing unavailable messages and retain saved IDs. Standalone keeps HA providers unavailable. A failed runtime capability read reports a page error rather than silently downgrading support. No new version registry or credentials are exposed. + +One preview-URL helper adds a timestamp plus per-page revision on initial open, save/reopen and explicit refresh (including Push and local-content edits). Even two opens within the same millisecond use distinct URLs. The existing `render.png` route still selects the claimed dashboard or enrolment frame and sends `Cache-Control: no-store`. FrameService memoisation, physical frame hashes/ETags and panel polling are unchanged. + +#### Real-installation validation for ha.8 + +1. Upgrade the App to ha.8 and confirm that version in Home Assistant. Close/reopen Studio through Ingress normally, without clearing caches or using Ctrl+F5. Repeat through authenticated LAN Studio. In browser Network tools verify Studio HTML/JS/CSS responses have `Cache-Control: no-store`. +2. On both full-size and Mini, select To Do and verify **Provider → InkPanel list / Home Assistant** appears. Choose Home Assistant, select a list, save, close/reopen the panel, and verify the saved choice remains. +3. In browser request-blocking tools temporarily block `*/api/home-assistant/todo-lists` and `*/api/home-assistant/calendars`, leaving runtime-config accessible. Reopen the panel: both provider selectors must remain available and saved IDs must remain visible with unavailable messages. Unblock and reopen to recover discovery. +4. Open an already-claimed panel without Push and verify the preview immediately shows its dashboard. On an unclaimed test panel, tick **Claimed**, save and return to Dashboard: the enrolment preview must be replaced. Close/reopen it and verify again. Then verify **Push to display** still refreshes the preview. +5. Verify the first five incomplete To Do items match HA ordering, then complete/add/reorder items in HA and wake the physical panel or reopen its preview. Complete all items and verify ALL DONE. Temporarily make the source unavailable and verify no stale task list is replayed and unrelated widgets remain usable. +6. Switch to InkPanel list and back, save/reopen, and verify both selections survive. Test local add/edit/complete/reorder/delete and Calendar provider choices. Remove a selected HA entity and verify Studio retains its missing selection until explicitly changed. +7. Confirm full-size/Mini physical display behaviour, existing location defaults and direct HTTPS WebFlash remain unchanged. HA-3 remains awaiting validation until these real-installation checks pass. Future HA write support (`todo.add_item`, `todo.update_item`, `todo.remove_item`) is a separate milestone. ha.7 exposes none of those actions from Studio. diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index fccb5d6..624165c 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.0-ha.8 + +- Fix Studio/cache reliability issues discovered during real-installation HA-3 validation; HA-3 is not yet fully validated and needs retesting. +- Serve normal Studio assets with `Cache-Control: no-store`, without stale asset validators; keep intentional immutable vendor-font caching. +- Determine Calendar/To Do provider support from runtime deployment mode, independently of discovery availability. Temporary discovery failures keep the HA provider visible and saved entities intact. +- Use fresh preview URLs on initial open, save/reopen and Push so a claimed panel does not reuse an old enrolment preview. Keep server frame memoisation and preview `no-store` behaviour unchanged. +- No e-ink renderer/template/CSS, firmware, framebuffer/protocol, profile, DeviceStore schema or migration changes. + ## 0.1.0-ha.7 - Add read-only Home Assistant To Do discovery and incomplete-item fetching through the server-only Supervisor client. diff --git a/home-assistant/README.md b/home-assistant/README.md index 74d1848..494c032 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.7` release is experimental. Add +This `0.1.0-ha.8` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -17,4 +17,6 @@ New panels use Home Assistant's installation location and timezone at first enro See the **Documentation** tab before starting the App. -To Do can now display a Home Assistant `todo.*` list using the existing full-size or Mini layout. Choose **Home Assistant** as the provider, select a list, and click **Save changes**. This milestone is read-only: edit tasks in Home Assistant. Existing InkPanel lists keep their full local editor. Both provider selections are remembered, missing entities remain visible, and HA outages show unavailable data rather than replaying stale tasks. HA-3 awaits real-installation validation. +To Do can now display a Home Assistant `todo.*` list using the existing full-size or Mini layout. Choose **Home Assistant** as the provider, select a list, and click **Save changes**. This milestone is read-only: edit tasks in Home Assistant. Existing InkPanel lists keep their full local editor. Both provider selections are remembered, missing entities remain visible, and HA outages show unavailable data rather than replaying stale tasks. + +ha.8 fixes Studio reliability issues found while validating HA-3: upgrade-safe asset caching, HA provider selectors that remain available during discovery failures, and a fresh live preview when opening or saving a claimed panel without requiring Push. HA-3 still needs real-installation retesting; it is not yet fully validated. No firmware or e-ink layout changes are included. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 43c4ff7..213b08a 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.7 +version: 0.1.0-ha.8 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel diff --git a/public/panels.js b/public/panels.js index 30a5f61..cada893 100644 --- a/public/panels.js +++ b/public/panels.js @@ -13,6 +13,12 @@ import { const MINI_PROFILE = 'ssd1681-200x200-mono'; let selectedId = null; let selectedPanelTab = 'dashboard'; +let previewRevision = 0; + +function panelPreviewUrl(deviceId) { + // Fresh on open, save/reopen and explicit refresh, even within one clock tick. + return appPath(`/api/devices/${encodeURIComponent(deviceId)}/render.png?t=${Date.now()}-${++previewRevision}`); +} export function setSelectedPanel(id) { selectedId = id; } function isMini(device) { return device.panelProfileId === MINI_PROFILE; } @@ -54,7 +60,7 @@ function detail(device) {

Live e-ink preview

${esc(displayLabel(device))} · exactly what this panel will show

- What ${esc(device.name)} is showing + What ${esc(device.name)} is showing
@@ -137,7 +143,7 @@ function pushMessage(result) { export function refreshPanelPreview(root, deviceId) { const img = root.querySelector('.panel-preview-image'); - if (img) img.src = appPath(`/api/devices/${encodeURIComponent(deviceId)}/render.png?t=${Date.now()}`); + if (img) img.src = panelPreviewUrl(deviceId); } export function bindTodoPreviewRefresh(editor, root, deviceId) { @@ -208,7 +214,18 @@ async function renderDetail(root, device, serviceStatus) { } export async function renderPanels(root) { - const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, haCalendars, haTodos] = await Promise.all([getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers'), getJson('/api/home-assistant/calendars').catch(() => ({ supported: false, available: false, calendars: [] })), getJson('/api/home-assistant/todo-lists').catch(() => ({ supported: false, available: false, lists: [] }))]); + const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, runtime, calendars, todos] = await Promise.all([ + getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), + getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers'), + getJson('/api/runtime-config'), + getJson('/api/home-assistant/calendars').catch(() => ({ available: false, calendars: [] })), + getJson('/api/home-assistant/todo-lists').catch(() => ({ available: false, lists: [] })), + ]); + // Deployment capability is independent of discovery availability. A failed + // runtime read surfaces as a page error instead of silently hiding providers. + const supported = runtime.updateMode === 'home-assistant'; + const haCalendars = { ...calendars, supported }; + const haTodos = { ...todos, supported }; if (!devices.length) { root.innerHTML = '

No panels yet

Power one on and it will appear in the sidebar.

'; return; } if (!devices.some((d) => d.id === selectedId)) selectedId = devices[0].id; await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars, haTodos }); diff --git a/src/http/app.ts b/src/http/app.ts index b9301d5..8d858f3 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -251,7 +251,13 @@ export function createApp(deps: AppDeps): express.Express { app.use('/vendor/fonts', express.static(fontDir('@fontsource/dela-gothic-one'), fontOptions)); app.use('/vendor/fonts', express.static(fontDir('@fontsource/inter'), fontOptions)); - app.use(express.static(publicDir)); + // Studio modules keep stable URLs across App upgrades, including Ingress. + // Do not let old HTML/JS/CSS (or their validators) survive a release change. + app.use(express.static(publicDir, { + etag: false, + lastModified: false, + setHeaders: (res) => { res.setHeader('Cache-Control', 'no-store'); }, + })); return app; } diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index f9e4c69..bc6157e 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.7'); + assert.equal(config.version, '0.1.0-ha.8'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); @@ -48,7 +48,7 @@ test('the image workflow builds, verifies and embeds production firmware before assert.match(workflow, /build-image@4de35182/); assert.match(workflow, /publish-multi-arch-manifest@4de35182/); assert.match(workflow, /\["amd64", "aarch64"\]/); - assert.match(workflow, /VERSION: 0\.1\.0-ha\.7/); + assert.match(workflow, /VERSION: 0\.1\.0-ha\.8/); assert.match(workflow, /matrix: \$\{\{ steps\.prepare\.outputs\.matrix \}\}/); assert.match(workflow, /runs-on: \$\{\{ matrix\.os \}\}/); assert.match(workflow, /registry-prefix: ghcr\.io\/ctrlaltcouk/); diff --git a/test/http/app.test.ts b/test/http/app.test.ts index 52ccb7b..a6563f8 100644 --- a/test/http/app.test.ts +++ b/test/http/app.test.ts @@ -47,6 +47,32 @@ test('/api/runtime-config reads current active HTTPS state before the auth gate' 'the password must genuinely be enabled while runtime config remains public'); }); +test('Studio assets are upgrade-safe while vendor fonts retain intentional immutable caching', async () => { + const server = makeApp().listen(0, '127.0.0.1'); + await once(server, 'listening'); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + for (const path of ['/', '/index.html', '/login.html', '/app.js', '/panels.js', '/todoEditor.js', '/cityPicker.js', '/flash.js', '/styles.css', '/studio.css', '/vendor/esptool-js.js']) { + const response = await fetch(`${base}${path}`, { + headers: { 'if-modified-since': 'Wed, 01 Jan 2099 00:00:00 GMT', 'if-none-match': '"old-release"' }, + }); + assert.equal(response.status, 200, path); + assert.equal(response.headers.get('cache-control'), 'no-store', path); + assert.equal(response.headers.get('etag'), null, path); + assert.equal(response.headers.get('last-modified'), null, path); + await response.arrayBuffer(); + } + for (const font of ['dela-gothic-one-latin-400-normal.woff2', 'inter-latin-400-normal.woff2']) { + const response = await fetch(`${base}/vendor/fonts/${font}`); + assert.equal(response.status, 200); + assert.match(response.headers.get('cache-control') ?? '', /max-age=2592000.*immutable/); + await response.arrayBuffer(); + } + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + async function requestJson(app: ReturnType, path: string) { const server = app.listen(0, '127.0.0.1'); await once(server, 'listening'); diff --git a/test/http/manageRoutes.test.ts b/test/http/manageRoutes.test.ts index 556c137..844f53f 100644 --- a/test/http/manageRoutes.test.ts +++ b/test/http/manageRoutes.test.ts @@ -244,6 +244,7 @@ test('serves preview HTML and a PNG of the real output', async () => { const png = await fetch(`${base}/api/devices/esp32-1/render.png`); assert.equal(png.headers.get('content-type'), 'image/png'); + assert.equal(png.headers.get('cache-control'), 'no-store'); const bytes = Buffer.from(await png.arrayBuffer()); assert.deepEqual(bytes.subarray(0, 4), Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'PNG magic'); }); diff --git a/test/public/panelsReliability.test.js b/test/public/panelsReliability.test.js new file mode 100644 index 0000000..d7ea37d --- /dev/null +++ b/test/public/panelsReliability.test.js @@ -0,0 +1,171 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import express from 'express'; +import { chromium } from 'playwright'; +import { createApp } from '../../src/http/app.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; + +const MINI = 'ssd1681-200x200-mono'; +const FULL = 'wft0583-800x480-mono'; + +async function withStudio({ ha = true, prefix = '', profile = MINI } = {}, run) { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-studio-reliability-')); + const store = new DeviceStore(join(dir, 'config.json')); + await store.getOrCreate('panel-a', profile); + const sections = (first) => profile === MINI ? [first] : [first, ...Array.from({ length: 3 }, () => ({ type: 'weather', version: 1, config: {} }))]; + await store.update('panel-a', { dashboardSections: sections({ type: 'todo', version: 1, config: { listId: '' } }) }); + const calls = { enrolment: 0, dashboard: 0, push: 0 }; + const frame = (fill) => ({ buffer: Buffer.alloc(profile === MINI ? 5000 : 48000, fill), etag: 'a'.repeat(32), renderedAt: new Date().toISOString() }); + const frames = { + warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, + enrolmentFrame: async () => { calls.enrolment++; return frame(0); }, + frameFor: async () => { calls.dashboard++; return frame(255); }, + renderNow: async () => { calls.push++; return frame(255); }, + }; + let discoveryFails = true; + const router = express.Router(); + router.get('/harness', (_req, res) => res.type('html').send(`
+ `)); + for (const [endpoint, key, entityId] of [['calendars', 'calendars', 'calendar.family'], ['todo-lists', 'lists', 'todo.shopping']]) { + router.get(`/api/home-assistant/${endpoint}`, (_req, res) => { + if (discoveryFails) return res.status(503).json({ error: 'Discovery unavailable' }); + // Deliberately contradictory support: runtime, not discovery, owns it. + res.json({ supported: !ha, available: true, [key]: [{ entityId, name: 'Family' }] }); + }); + } + router.use(createApp({ + store, frames, publicBaseUrl: 'http://panel.test:8080', runtimeState: createRuntimeState(), + dataDir: dir, firmwareDir: dir, auth: { password: null, secret: randomBytes(32) }, + updateMode: ha ? 'home-assistant' : 'self', + ...(prefix ? { access: { mode: 'home-assistant-ingress', isTrustedRequest: () => true } } : {}), + })); + const app = express(); + app.use(prefix || '/', router); + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}${prefix}/harness`); + await page.waitForFunction(() => window.ready); + await run({ page, store, calls, sections, recoverDiscovery: () => { discoveryFails = false; } }); + } finally { + await browser.close(); + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +} + +for (const profile of [MINI, FULL]) { + test(`HA provider capability survives failed discovery and retains saved entities (${profile})`, async () => { + await withStudio({ profile, prefix: '/api/hassio_ingress/test-token' }, async ({ page, store, sections, recoverDiscovery }) => { + const todo = page.locator('[data-todo-provider]'); + assert.equal(await todo.count(), 1); + assert.equal(await todo.locator('option[value="home-assistant"]').isDisabled(), false); + await todo.selectOption('home-assistant'); + assert.match(await page.locator('[data-widget-controls]').textContent(), /To Do lists are unavailable/); + + await store.update('panel-a', { dashboardSections: sections({ type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.saved_missing' } }) }); + await page.evaluate(() => window.reopen()); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), 'todo.saved_missing'); + assert.match(await page.locator('[data-widget-controls]').textContent(), /Saved selection is retained/); + assert.equal(await page.locator('[data-todo-add]').count(), 0, 'HA editor remains read-only'); + + await page.locator('[data-widget-type]').selectOption('calendar'); + await page.locator('[data-calendar-provider]').selectOption('home-assistant'); + assert.match(await page.locator('[data-widget-controls]').textContent(), /calendars are unavailable/); + await store.update('panel-a', { dashboardSections: sections({ type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.saved_missing'] } }) }); + await page.evaluate(() => window.reopen()); + assert.equal(await page.locator('[data-ha-calendar][value="calendar.saved_missing"]').isChecked(), true); + assert.match(await page.locator('[data-widget-controls]').textContent(), /Saved selections are retained/); + + recoverDiscovery(); + await page.evaluate(() => window.reopen()); + assert.equal(await page.locator('[data-ha-calendar][value="calendar.family"]').count(), 1); + assert.equal(await page.locator('[data-ha-calendar][value="calendar.saved_missing"]').isChecked(), true); + await page.locator('[data-widget-type]').selectOption('todo'); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-todo-list] option[value="todo.shopping"]').count(), 1); + }); + }); + + test(`open/save/reopen/Push preview URLs are fresh without changing frame routing (${profile})`, async () => { + const prefix = profile === MINI ? '/api/hassio_ingress/test-token' : ''; + await withStudio({ profile, prefix }, async ({ page, store, calls }) => { + const image = page.locator('.panel-preview-image'); + const loaded = () => page.waitForFunction(() => { + const img = document.querySelector('.panel-preview-image'); + return img.complete && img.naturalWidth > 0; + }); + const pixel = () => image.evaluate((img) => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0); + return context.getImageData(0, 0, 1, 1).data[0]; + }); + await loaded(); + const enrolmentUrl = await image.getAttribute('src'); + assert.match(enrolmentUrl, /render\.png\?t=\d+-\d+$/); + assert.ok(enrolmentUrl.startsWith(`${prefix}/api/devices/panel-a/`)); + const enrolmentPixel = await pixel(); + assert.equal(calls.enrolment, 1); + assert.equal(calls.dashboard, 0); + + await page.locator('[data-panel-tab="device"]').click(); + await page.locator('[name="claimed"]').check(); + await page.locator('[name="name"]').fill('Claimed panel'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction((previous) => document.querySelector('.panel-preview-image').getAttribute('src') !== previous, enrolmentUrl); + await page.waitForFunction(() => document.querySelector('[data-widget-type]')); + await loaded(); + const claimedUrl = await image.getAttribute('src'); + assert.notEqual(claimedUrl, enrolmentUrl); + assert.notEqual(await pixel(), enrolmentPixel, 'save immediately loads dashboard, not enrolment'); + assert.equal((await store.get('panel-a')).claimed, true); + assert.equal((await store.get('panel-a')).name, 'Claimed panel'); + assert.equal(await page.locator('#save-state').textContent(), 'All changes saved'); + assert.equal(calls.push, 0, 'claiming does not require Push'); + assert.equal(calls.dashboard, 1); + + await page.evaluate(() => window.reopen()); + await loaded(); + const reopenedUrl = await image.getAttribute('src'); + assert.notEqual(reopenedUrl, claimedUrl, 'same-millisecond reopen still gets a new revision'); + assert.notEqual(reopenedUrl, enrolmentUrl); + assert.notEqual(await pixel(), enrolmentPixel); + assert.equal(calls.dashboard, 2); + await page.locator('[data-panel-tab="dashboard"]').click(); + await page.locator('[data-push]').click(); + await page.waitForFunction((previous) => document.querySelector('.panel-preview-image').getAttribute('src') !== previous, reopenedUrl); + await loaded(); + assert.equal(calls.push, 1); + assert.equal(calls.dashboard, 3); + assert.equal(calls.enrolment, 1); + }); + }); +} + +test('standalone keeps HA providers hidden regardless of discovery support or failure', async () => { + await withStudio({ ha: false }, async ({ page, recoverDiscovery }) => { + for (let attempt = 0; attempt < 2; attempt++) { + assert.equal(await page.locator('[data-todo-provider]').count(), 0); + assert.equal(await page.locator('[data-todo-list]').count(), 1); + await page.locator('[data-widget-type]').selectOption('calendar'); + assert.equal(await page.locator('[data-calendar-provider]').count(), 0); + assert.equal(await page.locator('[data-calendar-urls]').count(), 1); + recoverDiscovery(); + await page.evaluate(() => window.reopen()); + } + }); +}); diff --git a/test/public/paths.test.js b/test/public/paths.test.js index 58ac256..3c12da1 100644 --- a/test/public/paths.test.js +++ b/test/public/paths.test.js @@ -25,7 +25,8 @@ test('API, preview, and login navigation all use the central path helper', async const login = await readFile(`${root}login.html`, 'utf8'); assert.match(api, /fetch\(appPath\(path\)/); assert.match(api, /location\.href = appPath\('\/login\.html'\)/); - assert.match(panels, /appPath\(`\/api\/devices\/\$\{encodeURIComponent\(device\.id\)\}\/render\.png`\)/); + assert.match(panels, /src="\$\{panelPreviewUrl\(device\.id\)\}"/); + assert.match(panels, /img\.src = panelPreviewUrl\(deviceId\)/); assert.match(panels, /appPath\(`\/api\/devices\/\$\{encodeURIComponent\(deviceId\)\}\/render\.png\?t=/); assert.match(login, /fetch\(appPath\('\/api\/auth\/login'\)/); assert.match(login, /location\.href = appPath\('\/'\)/); From 658b61ea2821454c1c19a0a800a0bed1d795e955 Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Thu, 27 Aug 2026 21:20:45 +0100 Subject: [PATCH 11/14] Version Home Assistant Ingress entry for ha.9 --- .github/workflows/home-assistant-image.yml | 4 +- Dockerfile.home-assistant | 1 + docs/home-assistant-app.md | 31 +++++++---- home-assistant/CHANGELOG.md | 8 +++ home-assistant/README.md | 6 ++- home-assistant/config.yaml | 4 +- src/http/app.ts | 3 ++ src/index.ts | 1 + test/homeAssistant/package.test.ts | 19 ++++++- test/homeAssistant/startup.test.js | 7 +++ test/http/homeAssistantIngress.test.ts | 9 ++-- test/public/panelsReliability.test.js | 62 +++++++++++++++++++--- test/public/paths.test.js | 17 ++++++ 13 files changed, 147 insertions(+), 25 deletions(-) diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 5e12bef..2351f2e 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.8 + VERSION: 0.1.0-ha.9 ARCHITECTURES: '["amd64", "aarch64"]' jobs: @@ -138,6 +138,7 @@ jobs: run: | expected_hash="$(bash scripts/firmware-input-hash.sh)" docker run --rm --entrypoint bash "$IMAGE_REF" /app/scripts/verify-firmware-package.sh /app/firmware/dist "$expected_hash" + docker run --rm --entrypoint node "$IMAGE_REF" -e 'if (process.env.INKPANEL_HA_RELEASE !== process.argv[1]) process.exit(1)' '${{ env.VERSION }}' publish: if: github.event_name == 'push' @@ -173,3 +174,4 @@ jobs: run: | expected_hash="$(bash scripts/firmware-input-hash.sh)" docker run --rm --entrypoint bash "$IMAGE_REF" /app/scripts/verify-firmware-package.sh /app/firmware/dist "$expected_hash" + docker run --rm --entrypoint node "$IMAGE_REF" -e 'if (process.env.INKPANEL_HA_RELEASE !== process.argv[1]) process.exit(1)' '${{ env.VERSION }}' diff --git a/Dockerfile.home-assistant b/Dockerfile.home-assistant index 0bd939a..2d9ad6b 100644 --- a/Dockerfile.home-assistant +++ b/Dockerfile.home-assistant @@ -16,6 +16,7 @@ RUN npm ci --omit=dev COPY . . ENV DATA_DIR=/data \ + INKPANEL_HA_RELEASE=${BUILD_VERSION} \ PORT=8080 \ HTTPS_PORT=8443 \ HOME_ASSISTANT_MODE=1 \ diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 663c03f..062c9ed 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do was implemented in `0.1.0-ha.7`; real-installation testing exposed Studio reliability issues addressed in `0.1.0-ha.8` on the `Home-Assistant` branch. HA-3 is not yet fully validated and requires retesting. +Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do was implemented in `0.1.0-ha.7`. Real-world ha.8 tests confirmed direct LAN Studio worked but Ingress retained an older document. `0.1.0-ha.9` versions the Ingress entry on the `Home-Assistant` branch. HA-3 is not yet fully validated and requires another real-installation Ingress retest. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -150,7 +150,7 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.8` for linux/amd64 and linux/arm64. +The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.9` for linux/amd64 and linux/arm64. #### First-time panel location defaults (ha.6; validated on real hardware) @@ -197,15 +197,26 @@ Studio uses the existing non-secret `/api/runtime-config` `updateMode` as the au One preview-URL helper adds a timestamp plus per-page revision on initial open, save/reopen and explicit refresh (including Push and local-content edits). Even two opens within the same millisecond use distinct URLs. The existing `render.png` route still selects the claimed dashboard or enrolment frame and sends `Cache-Control: no-store`. FrameService memoisation, physical frame hashes/ETags and panel polling are unchanged. -#### Real-installation validation for ha.8 +#### ha.9 Ingress entry freshness -1. Upgrade the App to ha.8 and confirm that version in Home Assistant. Close/reopen Studio through Ingress normally, without clearing caches or using Ctrl+F5. Repeat through authenticated LAN Studio. In browser Network tools verify Studio HTML/JS/CSS responses have `Cache-Control: no-store`. -2. On both full-size and Mini, select To Do and verify **Provider → InkPanel list / Home Assistant** appears. Choose Home Assistant, select a list, save, close/reopen the panel, and verify the saved choice remains. -3. In browser request-blocking tools temporarily block `*/api/home-assistant/todo-lists` and `*/api/home-assistant/calendars`, leaving runtime-config accessible. Reopen the panel: both provider selectors must remain available and saved IDs must remain visible with unavailable messages. Unblock and reopen to recover discovery. -4. Open an already-claimed panel without Push and verify the preview immediately shows its dashboard. On an unclaimed test panel, tick **Claimed**, save and return to Dashboard: the enrolment preview must be replaced. Close/reopen it and verify again. Then verify **Push to display** still refreshes the preview. -5. Verify the first five incomplete To Do items match HA ordering, then complete/add/reorder items in HA and wake the physical panel or reopen its preview. Complete all items and verify ALL DONE. Temporarily make the source unavailable and verify no stale task list is replayed and unrelated widgets remain usable. -6. Switch to InkPanel list and back, save/reopen, and verify both selections survive. Test local add/edit/complete/reorder/delete and Calendar provider choices. Remove a selected HA entity and verify Studio retains its missing selection until explicitly changed. -7. Confirm full-size/Mini physical display behaviour, existing location defaults and direct HTTPS WebFlash remain unchanged. HA-3 remains awaiting validation until these real-installation checks pass. +ha.8's real-installation results isolated the remaining problem: the same running container served correct providers/previews through LAN, while the HA sidebar retained an older Studio document. New response headers cannot replace an already loaded iframe document whose entry URL stays unchanged. + +The App now declares `ingress_entry: "?inkpanel_release=0.1.0-ha.9"`. Home Assistant [documents ingress_entry as a string URL entry point](https://developers.home-assistant.io/docs/apps/configuration/). The current [Supervisor implementation appends it to a trailing-slash Ingress prefix](https://github.com/home-assistant/supervisor/blob/main/supervisor/apps/app.py#L587-L595), and [the proxy forwards query parameters](https://github.com/home-assistant/supervisor/blob/main/supervisor/api/ingress.py#L224-L232). The leading slash is deliberately omitted to avoid a doubled slash. The effective iframe URL is `/api/hassio_ingress//?inkpanel_release=0.1.0-ha.9`. + +Each release must change this query alongside `version`; a package invariant test enforces exact equality and checks the image version matches. The changed URL selects a fresh application document after upgrade, while ha.8's `no-store`, disabled ETag/Last-Modified validators and intentional vendor-font caching remain unchanged. No version subdirectory, redirect or browser base-path change is introduced. `appPath()` still uses `location.pathname`, so APIs, previews, Push, pickers, local CRUD, Flash and static/dynamic module imports retain the Ingress prefix without the release query. + +HA runtime config now includes `release`, sourced from the existing image `BUILD_VERSION` through `INKPANEL_HA_RELEASE` and shared server dependencies. Both LAN and Ingress report the image's actual build value, never the browser query. Standalone omits this HA-only diagnostic; unpackaged HA runs without build metadata report null. No credentials or other environment fields are exposed. PR image checks and post-publication checks verify the embedded release matches the release workflow version. + +#### Real-installation validation for ha.9 + +1. Upgrade the App to ha.9 and confirm that version in Home Assistant. Navigate away from InkPanel, then reopen it from the HA sidebar normally: do not clear caches, use Ctrl+F5 or reinstall. In browser developer tools inspect the InkPanel iframe's document URL (not the outer HA page URL): it must end with `/?inkpanel_release=0.1.0-ha.9` under the existing Ingress prefix. Verify the document and Studio JS/CSS responses have `Cache-Control: no-store`. +2. Inspect `/api/runtime-config` in the iframe's Network requests: `updateMode` must be `home-assistant` and `release` must be `0.1.0-ha.9`. Compare with `http://:8080/api/runtime-config`; both must report the same release. The browser release query is only an entry cache key, not the diagnostic source. +3. On both full-size and Mini, select To Do and verify **Provider → InkPanel list / Home Assistant** appears. Choose Home Assistant, select a list, save, close/reopen the panel, and verify the saved choice remains. Repeat with Calendar's iCal/Home Assistant selector. +4. In browser request-blocking tools temporarily block `*/api/home-assistant/todo-lists` and `*/api/home-assistant/calendars`, leaving runtime-config accessible. Reopen the panel: both provider selectors must remain available and saved IDs must remain visible with unavailable messages. Unblock and reopen to recover discovery. +5. Open an already-claimed panel without Push and verify the preview immediately shows its dashboard. On an unclaimed test panel, tick **Claimed**, save and return to Dashboard: the enrolment preview must be replaced. Close/reopen it and verify again. Then verify **Push to display** still refreshes the preview. +6. Verify the first five incomplete To Do items match HA ordering, then complete/add/reorder items in HA and wake the physical panel or reopen its preview. Complete all items and verify ALL DONE. Temporarily make the source unavailable and verify no stale task list is replayed and unrelated widgets remain usable. +7. Switch to InkPanel list and back, save/reopen, and verify both selections survive. Test local add/edit/complete/reorder/delete and Calendar provider choices. Remove a selected HA entity and verify Studio retains its missing selection until explicitly changed. +8. Confirm pickers, printer APIs, full-size/Mini physical display behaviour, existing location defaults and direct HTTPS WebFlash remain unchanged. HA-3 remains awaiting validation until these real-installation checks pass. Future HA write support (`todo.add_item`, `todo.update_item`, `todo.remove_item`) is a separate milestone. ha.7 exposes none of those actions from Studio. diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index 624165c..2d68872 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.0-ha.9 + +- Real-world ha.8 testing confirmed that direct LAN Studio worked, while Home Assistant Ingress retained an older Studio document at its unchanged iframe entry URL. +- Set a release-specific, query-only `ingress_entry` so an App upgrade changes the iframe URL without changing its pathname or API/module base paths. CI enforces that the entry query and image version match the App version on every release. +- Preserve ha.8's normal Studio `no-store` policy, separate runtime capability/discovery handling and fresh initial/Push previews. +- Expose the image's non-secret build release in HA runtime config, shared by LAN and Ingress, for easy version comparison. +- No e-ink renderer/template/CSS, firmware, framebuffer/protocol, profile or DeviceStore/migration changes. HA-3 still awaits real-installation validation through Ingress after this release. + ## 0.1.0-ha.8 - Fix Studio/cache reliability issues discovered during real-installation HA-3 validation; HA-3 is not yet fully validated and needs retesting. diff --git a/home-assistant/README.md b/home-assistant/README.md index 494c032..4797c6c 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.8` release is experimental. Add +This `0.1.0-ha.9` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -19,4 +19,6 @@ See the **Documentation** tab before starting the App. To Do can now display a Home Assistant `todo.*` list using the existing full-size or Mini layout. Choose **Home Assistant** as the provider, select a list, and click **Save changes**. This milestone is read-only: edit tasks in Home Assistant. Existing InkPanel lists keep their full local editor. Both provider selections are remembered, missing entities remain visible, and HA outages show unavailable data rather than replaying stale tasks. -ha.8 fixes Studio reliability issues found while validating HA-3: upgrade-safe asset caching, HA provider selectors that remain available during discovery failures, and a fresh live preview when opening or saving a claimed panel without requiring Push. HA-3 still needs real-installation retesting; it is not yet fully validated. No firmware or e-ink layout changes are included. +Real-world ha.8 testing confirmed the provider and preview fixes worked over direct LAN, but Ingress retained an older Studio document. ha.9 gives each release a different Ingress entry query, making the iframe load a fresh document after upgrading while preserving all existing base paths. Normal Studio assets remain `no-store`. The server's `/api/runtime-config` reports the image release in HA mode for comparison between Ingress and LAN. + +Upgrade to ha.9 and reopen InkPanel from the Home Assistant sidebar normally; no hard refresh, cache clearing or reinstall should be needed. Confirm the iframe URL contains `inkpanel_release=0.1.0-ha.9`, both HA providers appear, and claimed previews load correctly without Push. HA-3 still needs this real-installation retest; it is not yet fully validated. No firmware or e-ink layout changes are included. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 213b08a..8372e38 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.8 +version: 0.1.0-ha.9 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel @@ -13,6 +13,8 @@ boot: auto init: false ingress: true ingress_port: 8099 +# Supervisor appends this to its trailing-slash Ingress URL. Keep it query-only. +ingress_entry: "?inkpanel_release=0.1.0-ha.9" panel_icon: mdi:tablet-dashboard homeassistant_api: true ports: diff --git a/src/http/app.ts b/src/http/app.ts index 8d858f3..03aa665 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -71,6 +71,8 @@ export interface AppDeps { enrolmentDefaults?: DeviceEnrolmentDefaultsProvider; /** Deployment capability shared by runtime UI and mutation routes. Defaults to standalone. */ updateMode?: UpdateMode; + /** Non-secret image BUILD_VERSION, shared by LAN and Ingress diagnostics. */ + homeAssistantRelease?: string; /** Selects the request trust boundary without duplicating application routes. */ access?: { mode: 'lan' | 'home-assistant-ingress'; @@ -167,6 +169,7 @@ export function createApp(deps: AppDeps): express.Express { updateMode, ...(updateMode === 'home-assistant' ? { + release: deps.homeAssistantRelease ?? null, accessMode: deps.access?.mode === 'home-assistant-ingress' ? 'home-assistant-ingress' : 'lan', diff --git a/src/index.ts b/src/index.ts index 0b264c6..df49f97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -183,6 +183,7 @@ export async function main(): Promise { moonrakerClient, homeAssistantClient, updateMode, + homeAssistantRelease: homeAssistantMode ? process.env.INKPANEL_HA_RELEASE : undefined, enrolmentDefaults: homeAssistantEnrolmentDefaults(homeAssistantMode, homeAssistantClient), }; const app = createApp({ ...sharedDeps, access: { mode: 'lan' } }); diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index bc6157e..96bd200 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.8'); + assert.equal(config.version, '0.1.0-ha.9'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); @@ -25,6 +25,20 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary assert.equal(config.schema.lan_password, 'password'); }); +test('every App version requires a matching query-only Ingress entry and image version', async () => { + const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); + const workflow = parse(await readFile(join(root, '.github', 'workflows', 'home-assistant-image.yml'), 'utf8')); + assert.equal(config.ingress_entry, `?inkpanel_release=${encodeURIComponent(config.version)}`, + 'bumping the App version without changing its iframe entry must fail CI'); + assert.equal(workflow.env.VERSION, config.version); + // Supervisor concatenates ingress_entry after /api/hassio_ingress//. + const prefix = 'https://ha.example/api/hassio_ingress/example-token/'; + const entry = new URL(prefix + config.ingress_entry); + assert.equal(entry.pathname, new URL(prefix).pathname); + assert.equal(entry.searchParams.get('inkpanel_release'), config.version); + assert.equal(entry.hash, ''); +}); + test('the dedicated image preserves the Playwright version and /data startup adapter', async () => { const dockerfile = await readFile(join(root, 'Dockerfile.home-assistant'), 'utf8'); const dockerignore = await readFile(join(root, '.dockerignore'), 'utf8'); @@ -33,6 +47,7 @@ test('the dedicated image preserves the Playwright version and /data startup ada assert.match(dockerfile, /CMD \["node", "scripts\/home-assistant-start\.mjs"\]/); assert.match(dockerfile, /VOLUME \["\/data"\]/); assert.match(dockerfile, /io\.hass\.type="app"/); + assert.match(dockerfile, /INKPANEL_HA_RELEASE=\$\{BUILD_VERSION\}/); assert.doesNotMatch(dockerfile, /io\.hass\.type="addon"/); assert.doesNotMatch(dockerfile, /arduino-cli/, 'firmware is compiled before the runtime image build'); assert.doesNotMatch(dockerignore, /^firmware(?:\/dist)?\/?$/m, @@ -48,7 +63,7 @@ test('the image workflow builds, verifies and embeds production firmware before assert.match(workflow, /build-image@4de35182/); assert.match(workflow, /publish-multi-arch-manifest@4de35182/); assert.match(workflow, /\["amd64", "aarch64"\]/); - assert.match(workflow, /VERSION: 0\.1\.0-ha\.8/); + assert.match(workflow, /process\.env\.INKPANEL_HA_RELEASE !== process\.argv\[1\]/); assert.match(workflow, /matrix: \$\{\{ steps\.prepare\.outputs\.matrix \}\}/); assert.match(workflow, /runs-on: \$\{\{ matrix\.os \}\}/); assert.match(workflow, /registry-prefix: ghcr\.io\/ctrlaltcouk/); diff --git a/test/homeAssistant/startup.test.js b/test/homeAssistant/startup.test.js index 7c6fa99..391898c 100644 --- a/test/homeAssistant/startup.test.js +++ b/test/homeAssistant/startup.test.js @@ -21,3 +21,10 @@ test('panel base URL must be a clean LAN origin and LAN password is required', ( } assert.throws(() => runtimeEnvironment({ panel_base_url: 'http://panel.local', lan_password: ' ' }), /lan_password/); }); + +test('the startup adapter preserves the image release for runtime diagnostics', () => { + const env = runtimeEnvironment({ panel_base_url: 'http://panel.local:8080', lan_password: 'password' }, { + INKPANEL_HA_RELEASE: 'test-image-release', + }); + assert.equal(env.INKPANEL_HA_RELEASE, 'test-image-release'); +}); diff --git a/test/http/homeAssistantIngress.test.ts b/test/http/homeAssistantIngress.test.ts index 15497ac..0640a40 100644 --- a/test/http/homeAssistantIngress.test.ts +++ b/test/http/homeAssistantIngress.test.ts @@ -36,6 +36,7 @@ function app(access: 'lan' | 'trusted-ingress' | 'real-ingress', activeHttps: nu firmwareDir: 'unused', auth: { password: 'lan-password', secret: randomBytes(32) }, updateMode: 'home-assistant', + homeAssistantRelease: 'test-image-release', homeAssistantClient, access: access === 'lan' ? { mode: 'lan' } @@ -93,16 +94,18 @@ test('To Do discovery shares LAN/Ingress auth and projects only identities and n }); test('HA runtime config exposes only the active direct HTTPS root for WebFlash', async () => { + assert.equal((await requestJson(app('trusted-ingress'), '/api/runtime-config?inkpanel_release=not-the-image')).body.release, + 'test-image-release', 'release diagnostics come from the image, never the browser query'); assert.deepEqual((await requestJson(app('trusted-ingress'), '/api/runtime-config')).body, { - httpsPort: null, updateMode: 'home-assistant', + httpsPort: null, updateMode: 'home-assistant', release: 'test-image-release', accessMode: 'home-assistant-ingress', webFlashUrl: null, }); assert.deepEqual((await requestJson(app('trusted-ingress', 8443), '/api/runtime-config')).body, { - httpsPort: 8443, updateMode: 'home-assistant', accessMode: 'home-assistant-ingress', + httpsPort: 8443, updateMode: 'home-assistant', release: 'test-image-release', accessMode: 'home-assistant-ingress', webFlashUrl: 'https://192.168.1.20:8443/#flash', }); assert.deepEqual((await requestJson(app('lan', 8443), '/api/runtime-config')).body, { - httpsPort: 8443, updateMode: 'home-assistant', + httpsPort: 8443, updateMode: 'home-assistant', release: 'test-image-release', accessMode: 'lan', webFlashUrl: 'https://192.168.1.20:8443/#flash', }); assert.equal(directWebFlashUrl('http://panel.local:8080/path', 8443), 'https://panel.local:8443/#flash'); diff --git a/test/public/panelsReliability.test.js b/test/public/panelsReliability.test.js index d7ea37d..db5a624 100644 --- a/test/public/panelsReliability.test.js +++ b/test/public/panelsReliability.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomBytes } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import express from 'express'; @@ -9,16 +9,18 @@ import { chromium } from 'playwright'; import { createApp } from '../../src/http/app.ts'; import { DeviceStore } from '../../src/devices/store.ts'; import { createRuntimeState } from '../../src/runtimeConfig.ts'; +import { parse } from 'yaml'; const MINI = 'ssd1681-200x200-mono'; const FULL = 'wft0583-800x480-mono'; +const appConfig = parse(await readFile(new URL('../../home-assistant/config.yaml', import.meta.url), 'utf8')); -async function withStudio({ ha = true, prefix = '', profile = MINI } = {}, run) { +async function withStudio({ ha = true, prefix = '', profile = MINI, realEntry = false } = {}, run) { const dir = await mkdtemp(join(tmpdir(), 'inkpanel-studio-reliability-')); const store = new DeviceStore(join(dir, 'config.json')); await store.getOrCreate('panel-a', profile); const sections = (first) => profile === MINI ? [first] : [first, ...Array.from({ length: 3 }, () => ({ type: 'weather', version: 1, config: {} }))]; - await store.update('panel-a', { dashboardSections: sections({ type: 'todo', version: 1, config: { listId: '' } }) }); + await store.update('panel-a', { claimed: realEntry, dashboardSections: sections({ type: 'todo', version: 1, config: { listId: '' } }) }); const calls = { enrolment: 0, dashboard: 0, push: 0 }; const frame = (fill) => ({ buffer: Buffer.alloc(profile === MINI ? 5000 : 48000, fill), etag: 'a'.repeat(32), renderedAt: new Date().toISOString() }); const frames = { @@ -48,6 +50,7 @@ async function withStudio({ ha = true, prefix = '', profile = MINI } = {}, run) store, frames, publicBaseUrl: 'http://panel.test:8080', runtimeState: createRuntimeState(), dataDir: dir, firmwareDir: dir, auth: { password: null, secret: randomBytes(32) }, updateMode: ha ? 'home-assistant' : 'self', + homeAssistantRelease: appConfig.version, ...(prefix ? { access: { mode: 'home-assistant-ingress', isTrustedRequest: () => true } } : {}), })); const app = express(); @@ -57,9 +60,12 @@ async function withStudio({ ha = true, prefix = '', profile = MINI } = {}, run) const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); - await page.goto(`http://127.0.0.1:${server.address().port}${prefix}/harness`); - await page.waitForFunction(() => window.ready); - await run({ page, store, calls, sections, recoverDiscovery: () => { discoveryFails = false; } }); + const requests = []; + page.on('request', (request) => requests.push(new URL(request.url()))); + await page.goto(`http://127.0.0.1:${server.address().port}${prefix}/${realEntry ? appConfig.ingress_entry : 'harness'}`); + if (realEntry) await page.locator('[data-widget-type]').waitFor(); + else await page.waitForFunction(() => window.ready); + await run({ page, store, calls, sections, requests, recoverDiscovery: () => { discoveryFails = false; } }); } finally { await browser.close(); await new Promise((resolve) => server.close(resolve)); @@ -68,6 +74,50 @@ async function withStudio({ ha = true, prefix = '', profile = MINI } = {}, run) } for (const profile of [MINI, FULL]) { + test(`fresh release-query Ingress document loads real Studio and claimed preview (${profile})`, async () => { + const prefix = '/api/hassio_ingress/test-token'; + await withStudio({ profile, prefix, realEntry: true }, async ({ page, calls, requests, recoverDiscovery }) => { + assert.equal(new URL(page.url()).pathname, `${prefix}/`); + assert.equal(new URL(page.url()).searchParams.get('inkpanel_release'), appConfig.version); + const runtime = await page.evaluate(async () => { + const { getJson } = await import('./api.js'); + return getJson('/api/runtime-config'); + }); + assert.equal(runtime.updateMode, 'home-assistant'); + assert.equal(runtime.release, appConfig.version); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.match(await page.locator('[data-widget-controls]').textContent(), /To Do lists are unavailable/); + await page.locator('[data-widget-type]').selectOption('calendar'); + await page.locator('[data-calendar-provider]').selectOption('home-assistant'); + assert.match(await page.locator('[data-widget-controls]').textContent(), /calendars are unavailable/); + + await page.waitForFunction(() => document.querySelector('.panel-preview-image').naturalWidth > 0); + const initial = await page.locator('.panel-preview-image').getAttribute('src'); + assert.match(initial, /render\.png\?t=\d+-\d+$/); + assert.equal(calls.enrolment, 0); + assert.equal(calls.dashboard, 1, 'claimed dashboard loads without Push'); + await page.locator('[data-push]').click(); + await page.waitForFunction((previous) => document.querySelector('.panel-preview-image').getAttribute('src') !== previous, initial); + assert.equal(calls.push, 1); + + recoverDiscovery(); + await page.reload(); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-todo-list] option[value="todo.shopping"]').count(), 1); + await page.locator('[data-widget-type]').selectOption('calendar'); + await page.locator('[data-calendar-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-calendar][value="calendar.family"]').count(), 1); + + const paths = new Set(requests.map((url) => url.pathname)); + for (const path of ['/app.js', '/styles.css', '/studio.css', '/cityPicker.js', '/stationPicker.js', '/flash.js', '/api/devices', '/api/runtime-config', '/api/home-assistant/calendars', '/api/home-assistant/todo-lists', '/api/printers', '/api/todo-lists', '/api/devices/panel-a/push']) { + assert.ok(paths.has(prefix + path), path); + } + for (const url of requests.filter((url) => /\.(js|css)$/.test(url.pathname) || url.pathname.includes('/api/'))) { + assert.ok(url.pathname.startsWith(prefix + '/'), url.href); + } + }); + }); + test(`HA provider capability survives failed discovery and retains saved entities (${profile})`, async () => { await withStudio({ profile, prefix: '/api/hassio_ingress/test-token' }, async ({ page, store, sections, recoverDiscovery }) => { const todo = page.locator('[data-todo-provider]'); diff --git a/test/public/paths.test.js b/test/public/paths.test.js index 3c12da1..a8c43c6 100644 --- a/test/public/paths.test.js +++ b/test/public/paths.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { appPath, browserBasePath } from '../../public/paths.js'; +import { parse } from 'yaml'; test('browser paths remain root-relative in standalone InkPanel', () => { assert.equal(browserBasePath('/'), '/'); @@ -18,6 +19,22 @@ test('one helper prefixes APIs, pages and images under arbitrary Ingress paths', assert.equal(appPath('/api/devices/mini/render.png?t=1', ingress), `${ingress}api/devices/mini/render.png?t=1`); }); +test('release-specific Ingress query preserves every Studio API and module base path', async () => { + const config = parse(await readFile(new URL('../../home-assistant/config.yaml', import.meta.url), 'utf8')); + const prefix = 'https://ha.example/api/hassio_ingress/session-token/'; + const entry = new URL(prefix + config.ingress_entry); + assert.equal(browserBasePath(entry.pathname), '/api/hassio_ingress/session-token/'); + for (const path of [ + '/api/devices', '/api/runtime-config', '/api/home-assistant/calendars', '/api/home-assistant/todo-lists', + '/api/devices/panel/render.png?t=123-1', '/api/devices/panel/push', '/api/geocode?q=York', + '/api/stations?q=London', '/api/printers', '/api/todo-lists', '/api/dashboard-editor/panel', + '/api/firmware/manifest', '/api/firmware/mini/manifest', + ]) assert.equal(appPath(path, entry.pathname), `${new URL(prefix).pathname}${path.slice(1)}`, path); + for (const module of ['app.js', 'cityPicker.js', 'stationPicker.js', 'dashboardEditor.js', 'flash.js', 'vendor/esptool-js.js', 'styles.css', 'studio.css']) { + assert.equal(new URL(`./${module}`, entry).href, `${prefix}${module}`); + } +}); + test('API, preview, and login navigation all use the central path helper', async () => { const root = fileURLToPath(new URL('../../public/', import.meta.url)); const api = await readFile(`${root}api.js`, 'utf8'); From 55ce682cdcb8af98788a66c4bdc2ee26943a7898 Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Fri, 28 Aug 2026 18:21:50 +0100 Subject: [PATCH 12/14] Add Home Assistant Sensors widget for ha.10 --- .github/workflows/home-assistant-image.yml | 2 +- docs/home-assistant-app.md | 64 +++++---- docs/widget-setup-and-remembered-settings.md | 3 + home-assistant/CHANGELOG.md | 9 ++ home-assistant/README.md | 8 +- home-assistant/config.yaml | 4 +- public/dashboardEditor.js | 29 ++-- public/entitiesEditor.js | 68 +++++++++ public/panels.js | 8 +- public/studio.css | 7 + src/homeAssistant/client.ts | 23 +++ src/homeAssistant/sensorSchemas.ts | 56 ++++++++ src/http/homeAssistantRoutes.ts | 4 + src/http/manageRoutes.ts | 3 +- src/model/dashboard.ts | 13 ++ src/model/hash.ts | 2 +- src/render/entities.ts | 39 ++++++ src/render/frameService.ts | 7 + src/render/miniTemplate.ts | 5 +- src/render/template.ts | 8 +- src/sources/homeAssistantEntities.ts | 38 +++++ src/widgets/editorPreferences.ts | 1 + src/widgets/registry.ts | 8 ++ test/fixtures/existingWidgets.ts | 18 +++ test/homeAssistant/package.test.ts | 2 +- test/homeAssistant/sensors.test.ts | 108 ++++++++++++++ test/http/homeAssistantIngress.test.ts | 10 +- test/public/entitiesEditorUx.test.js | 140 +++++++++++++++++++ test/render/entitiesFrameService.test.ts | 91 ++++++++++++ test/render/entitiesTemplate.test.ts | 70 ++++++++++ test/render/existingWidgetOutput.test.ts | 31 ++++ test/widgets/entities.test.ts | 46 ++++++ 32 files changed, 879 insertions(+), 46 deletions(-) create mode 100644 public/entitiesEditor.js create mode 100644 src/homeAssistant/sensorSchemas.ts create mode 100644 src/render/entities.ts create mode 100644 src/sources/homeAssistantEntities.ts create mode 100644 test/fixtures/existingWidgets.ts create mode 100644 test/homeAssistant/sensors.test.ts create mode 100644 test/public/entitiesEditorUx.test.js create mode 100644 test/render/entitiesFrameService.test.ts create mode 100644 test/render/entitiesTemplate.test.ts create mode 100644 test/render/existingWidgetOutput.test.ts create mode 100644 test/widgets/entities.test.ts diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 2351f2e..64f623a 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.9 + VERSION: 0.1.0-ha.10 ARCHITECTURES: '["amd64", "aarch64"]' jobs: diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 062c9ed..7c1568c 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do was implemented in `0.1.0-ha.7`. Real-world ha.8 tests confirmed direct LAN Studio worked but Ingress retained an older document. `0.1.0-ha.9` versions the Ingress entry on the `Home-Assistant` branch. HA-3 is not yet fully validated and requires another real-installation Ingress retest. +Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do is implemented; real-world testing now confirms the ha.9 Ingress freshness fix works. `0.1.0-ha.10` adds HA-4 Home Assistant Sensors on the experimental `Home-Assistant` branch. HA-4 is implemented but not yet real-world validated. PR #31 remains draft and unmerged. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -150,7 +150,7 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. Experimental images are published as `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.9` for linux/amd64 and linux/arm64. +The full-size 800×480 and Mini 200×200 Calendar renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. The current experimental image tag is `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.10` for linux/amd64 and linux/arm64 (HA's aarch64). #### First-time panel location defaults (ha.6; validated on real hardware) @@ -162,7 +162,7 @@ Known devices never request installation location and are never automatically up After upgrading to ha.6, validate a genuinely new panel of each size against the installation location in Home Assistant. Existing panels intentionally retain their saved location; update those manually in Studio if necessary. Check that a manual location edit survives subsequent wakes, and that a known panel continues to receive frames during a temporary HA API outage (individual HA-backed widgets retain their existing unavailable/stale semantics). -### Phase HA-3 — Home Assistant To Do (implemented; awaiting real-world validation) +### Phase HA-3 — Home Assistant To Do (implemented; ha.9 Ingress fix confirmed) To Do V2 adds a strict provider choice while existing To Do V1 records remain valid, local, and unchanged on load or unrelated saves: @@ -220,43 +220,59 @@ HA runtime config now includes `release`, sourced from the existing image `BUILD Future HA write support (`todo.add_item`, `todo.update_item`, `todo.remove_item`) is a separate milestone. ha.7 exposes none of those actions from Studio. -### Phase HA-4 — Home Assistant Entities +### Phase HA-4 — Home Assistant Sensors (implemented; awaiting real-world validation) -Add a generic widget type such as `home_assistant` or `ha_entities`. +The first read-only generic entity-display milestone deliberately supports **only `sensor.*`**. Other domains are future work, not enabled by this release. -It should allow selecting useful entities from Home Assistant and display normalized rows such as: +#### Persistence and API boundaries -- entity friendly name; -- state; -- unit of measurement where applicable; -- optional icon/category metadata used only by Studio, not required by the monochrome framebuffer. +```json +{"type":"entities","version":1,"config":{"entityIds":["sensor.living_room_temperature","sensor.house_power"]}} +``` + +The widget registry validates a strict V1 config: ordered, unique IDs matching `^sensor\.[a-z0-9_]+$`, maximum 255 characters per ID and four selections. Empty means not configured. Validation is syntactic, not dependent on current HA discovery; missing entities never make DeviceStore unreadable. No DeviceStore version, frozen schema or migration changes are needed. Both profiles use the existing widget registry and editor-preferences persistence. + +The server-only Supervisor client uses the official [HA REST state endpoints](https://developers.home-assistant.io/docs/api/rest/): `GET states` for discovery and `GET states/` for each selected runtime state. Discovery is exposed by authenticated `GET /api/home-assistant/sensors`, projecting only `entityId`, friendly `name`, `state`, `unit` and `deviceClass`. Names/states are bounded to 255 characters, units to 32 and device classes to 64; malformed optional attributes fall back safely. Other attributes, timestamps, contexts and credentials never reach Studio. Runtime response IDs must match the requested ID. Existing timeouts, aborts, redirect rejection and safe errors apply. + +HA capability continues to come from runtime deployment mode. Discovery failure means supported but unavailable in HA App mode, not unsupported. Standalone discovery is unsupported and does not contact HA. All sensor operations are GET-only; Studio exposes no state mutation. + +#### Live-only data and rendering + +FrameService fetches up to four selected states concurrently. Identical widgets share the existing per-frame request promise. The display model contains only `items: [{name, value, unit, available}]` in selection order: no entity ID, device class or hidden HA metadata. There is no persistent sensor cache or stale replay. A missing/unknown entity keeps its row and shows UNAVAILABLE; valid rows survive other failures. If every request fails, configured remains true, data is null and health is error. Empty selection has configured false and no data. Unrelated widgets retain their own source/health behaviour. + +One formatting helper preserves trimmed HA strings/units, performs no conversions and associates units consistently (`21.4°C`, `89%`, `312 W`). Unknown, unavailable and invalid placeholder strings never appear as numeric values. Ordinary model hashing excludes health and hidden HA metadata; unchanged display state preserves existing physical ETag/304 semantics. No physical refresh special case is added. + +Full-size Sensors uses a dominant value with its friendly name beneath for one sensor, or up to four rows with names left and values right. Mini uses a dedicated 200×200 hero/row layout with the same data. Names and long values are bounded and ellipsized, with explicit unavailable states. Both are monochrome, use existing fonts and inject only widget-scoped CSS when Sensors is present. Existing widget markup/CSS is pinned against ha.9 output; no banner, grid, existing widget, quantisation or framebuffer changes are made. + +#### Studio and release + +In HA App mode choose **Home Assistant Sensors** in Content. Search by name or entity ID (at most 20 search results at once), inspect current values/units, add up to four, remove or reorder, then **Save changes**. Searching does not dirty panel config. Missing selections stay visible as missing/unavailable. Existing per-slot/shared remembered settings retain IDs and order across Sensors → Weather → Sensors and save/reopen. Standalone hides the new option for other widgets but preserves any already-saved Sensors config. -Initial supported domains should prioritize display-oriented state: +ha.10 updates App `version`, `ingress_entry: "?inkpanel_release=0.1.0-ha.10"` and the image workflow version together. Existing checks enforce the same release through `BUILD_VERSION`, `INKPANEL_HA_RELEASE` and runtime diagnostics. Historical ha.9 details/checklist above document the original freshness fix; use ha.10 for the current upgrade. -- `sensor.*` -- `binary_sensor.*` -- `weather.*` -- `climate.*` -- `person.*` -- `lock.*` -- `alarm_control_panel.*` +#### Real-world validation checklist for ha.10 -The data model must be generic enough that additional domains can be enabled later without a DeviceStore migration. +1. Upgrade to `0.1.0-ha.10`, reopen from the HA sidebar normally, and confirm the iframe query and both LAN/Ingress runtime-config releases equal `0.1.0-ha.10`. Do not clear caches or reinstall. +2. On a full-size panel, choose Sensors in one section. Search by friendly name and by ID, select a temperature sensor and save. Open the preview and wake the panel: confirm the hero value/unit/name matches HA without conversion. +3. Add humidity, power and battery (or other real sensors); reorder and save. Verify four rows, matching order/units, on preview and physical display. Try long names/values and an entity without a unit: no overlap or overflow. +4. Repeat single-sensor and four-sensor checks on Mini at physical size, including long text and UNAVAILABLE. Confirm legibility; HA-4 is not validated until these real displays are checked. +5. Switch Sensors → Weather → Sensors, save/reopen, and confirm selections/order. Check a new panel/slot receives the existing shared remembered fallback without overwriting its active config. +6. Remove or disable one selected sensor in HA: retain its configured position/ID, show unavailable, and keep other rows. Temporarily interrupt HA API access: all-failed Sensors shows unavailable, not stale values, while unrelated widgets remain usable. Restore HA and refresh/wake to recover. +7. Change only hidden HA metadata and confirm the next physical frame poll retains its ETag/304; change a displayed value and confirm a new frame. The normal refresh schedule still applies; this milestone adds no live subscription. +8. Recheck HA Calendar, HA/local To Do, ha.6 location defaults, previews without Push, and existing full-size/Mini widgets. Standalone should not offer Sensors for a new widget. Firmware, provisioning and WebFlash remain unchanged. -Example physical content: +Example four-sensor physical content: ``` -HOME +SENSORS ------------------------ Living room 21.4 C -Outside 16 C Solar 2.8 kW House 1.3 kW Battery 78% -Front door LOCKED ``` -Mini should use a reduced single-focus or short-list layout rather than attempting to mirror a large full-size list blindly. +Mini uses the dedicated hero or short-list layout described above. Lock and other non-sensor domains are not supported in HA-4 V1. ### Phase HA-5 — richer Home Assistant capabilities diff --git a/docs/widget-setup-and-remembered-settings.md b/docs/widget-setup-and-remembered-settings.md index e798da1..1a8aae5 100644 --- a/docs/widget-setup-and-remembered-settings.md +++ b/docs/widget-setup-and-remembered-settings.md @@ -57,9 +57,12 @@ Shared fallbacks are promoted only from complete/useful configurations: - Traffic: both From and To - Octopus Agile: a tariff code - Bins: a UPRN +- Home Assistant Sensors: at least one ordered `sensor.*` entity ID (maximum four) Weather and Empty have no reusable configuration. +Home Assistant Sensors uses the existing `entities` V1 draft in both per-slot and shared remembered settings. Switching Sensors → Weather → Sensors restores the selected IDs and order; active configuration still takes precedence over remembered defaults. Missing IDs remain valid configuration and are never silently removed. Sensor search is transient UI state, while add/remove/reorder requires **Save changes**. The Content option is offered only in HA App mode; an already-saved Sensors widget remains visible even if discovery or HA support is unavailable. + ## Save behaviour When **Save** is pressed, InkPanel: diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index 2d68872..19d208f 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.0-ha.10 + +- Add HA-4 Home Assistant Sensors: the first read-only generic entity-display milestone, supporting only `sensor.*` in the strict `entities` V1 widget with up to four ordered, unique selections. +- Discover safe sensor summaries through the authenticated HA client; fetch selected states concurrently through individual state endpoints. Strip unrelated attributes and keep Supervisor credentials server-only. +- Add HA-only searchable Studio selection, current values, ordering and missing-entity preservation using existing saved/per-slot/shared drafts. +- Add isolated full-size and Mini hero/row layouts. Preserve HA units, show honest partial/all-source unavailability and never replay persistent sensor data. Existing widget output remains unchanged. +- Advance App, Ingress entry and published image to the same ha.10 release while retaining BUILD_VERSION/INKPANEL_HA_RELEASE invariants and ha.9 freshness behaviour, now confirmed working in real-world testing. +- No firmware, framebuffer/protocol, profile, DeviceStore version or frozen migration changes. HA-4 awaits real-world validation; the branch remains experimental. + ## 0.1.0-ha.9 - Real-world ha.8 testing confirmed that direct LAN Studio worked, while Home Assistant Ingress retained an older Studio document at its unchanged iframe entry URL. diff --git a/home-assistant/README.md b/home-assistant/README.md index 4797c6c..407f286 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.9` release is experimental. Add +This `0.1.0-ha.10` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -21,4 +21,8 @@ To Do can now display a Home Assistant `todo.*` list using the existing full-siz Real-world ha.8 testing confirmed the provider and preview fixes worked over direct LAN, but Ingress retained an older Studio document. ha.9 gives each release a different Ingress entry query, making the iframe load a fresh document after upgrading while preserving all existing base paths. Normal Studio assets remain `no-store`. The server's `/api/runtime-config` reports the image release in HA mode for comparison between Ingress and LAN. -Upgrade to ha.9 and reopen InkPanel from the Home Assistant sidebar normally; no hard refresh, cache clearing or reinstall should be needed. Confirm the iframe URL contains `inkpanel_release=0.1.0-ha.9`, both HA providers appear, and claimed previews load correctly without Push. HA-3 still needs this real-installation retest; it is not yet fully validated. No firmware or e-ink layout changes are included. +Real-world testing now confirms the ha.9 Ingress freshness fix works. Upgrade to ha.10 and reopen InkPanel from the Home Assistant sidebar normally; no hard refresh, cache clearing or reinstall should be needed. Confirm the iframe URL contains `inkpanel_release=0.1.0-ha.10` and runtime config reports the same release. + +**Home Assistant Sensors** is the first read-only generic entity-display milestone, deliberately supporting only `sensor.*` entities. Choose the new Content option in Studio, search by friendly name or entity ID, add up to four sensors, arrange their order and click **Save changes**. One sensor uses a large-value layout; two to four use compact rows on both full-size and Mini displays. Values and units come directly from HA without conversions. Missing sensors remain selected until explicitly removed; outages show unavailable data, never a persisted stale sensor value. Manage sensors in HA, not InkPanel. + +HA-4 is implemented but awaits real-world Home Assistant and physical-display validation. Existing widgets, firmware, framebuffer/protocol, profiles and DeviceStore migrations are unchanged. See the repository's `docs/home-assistant-app.md` for the architecture and ha.10 validation checklist. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 8372e38..ba2c76b 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.9 +version: 0.1.0-ha.10 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel @@ -14,7 +14,7 @@ init: false ingress: true ingress_port: 8099 # Supervisor appends this to its trailing-slash Ingress URL. Keep it query-only. -ingress_entry: "?inkpanel_release=0.1.0-ha.9" +ingress_entry: "?inkpanel_release=0.1.0-ha.10" panel_icon: mdi:tablet-dashboard homeassistant_api: true ports: diff --git a/public/dashboardEditor.js b/public/dashboardEditor.js index 9efe843..25e0033 100644 --- a/public/dashboardEditor.js +++ b/public/dashboardEditor.js @@ -5,13 +5,15 @@ import { getJson, sendJson } from './api.js'; import { calendarControlsHtml, rememberCalendarConfig, switchCalendarProvider } from './calendarEditor.js'; import { todoProviderHtml, homeAssistantTodoControlsHtml, rememberTodoConfig, switchTodoProvider } from './todoEditor.js'; import { providerDraftState, rememberedProviderDrafts } from './providerDrafts.js'; +import { entitiesControlsHtml, bindEntitiesEditor } from './entitiesEditor.js'; -const TYPES = ['calendar', 'weather', 'trains', 'bus', 'traffic', 'octopus', 'printers', 'todo', 'bins', 'empty']; +const TYPES = ['calendar', 'weather', 'trains', 'bus', 'traffic', 'octopus', 'printers', 'todo', 'bins', 'empty', 'entities']; const POSITIONS = ['Top Left', 'Top Right', 'Bottom Left', 'Bottom Right']; const MINI_PROFILE = 'ssd1681-200x200-mono'; const stateByRoot = new WeakMap(); function typeLabel(type) { + if (type === 'entities') return 'Home Assistant Sensors'; if (type === 'octopus') return 'Octopus Agile'; if (type === 'todo') return 'To Do'; if (type === 'printers') return '3D Printers'; @@ -19,6 +21,7 @@ function typeLabel(type) { } function defaultConfig(type) { + if (type === 'entities') return { entityIds: [] }; if (type === 'calendar') return { calendarUrls: [] }; if (type === 'trains') return { originCrs: '', destinationCrs: '' }; if (type === 'bus') return { stopCode: '', stopLabel: '', routeFilter: '' }; @@ -75,6 +78,8 @@ function rememberCell(cell, slot, type = slot.type) { if (!cell) return; if (type === 'calendar') { slot.drafts[type] = rememberCalendarConfig(cell, slot); + } else if (type === 'entities') { + slot.drafts[type] = { entityIds: [...cell.querySelectorAll('[data-selected-entity]')].map((item) => item.dataset.selectedEntity) }; } else if (type === 'trains') { slot.drafts[type] = { originCrs: cell.querySelector('[data-station="origin"]')?.dataset.crs ?? '', @@ -167,7 +172,8 @@ function printerControlsHtml(config, printers, isMini) {
`; } -function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, busConfigured, busId, busKey, trafficConfigured, trafficKey, todoLists, printers, isMini, haCalendars, haTodos) { +function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, busConfigured, busId, busKey, trafficConfigured, trafficKey, todoLists, printers, isMini, haCalendars, haTodos, haSensors) { + if (type === 'entities') return entitiesControlsHtml(haSensors); if (type === 'calendar') return calendarControlsHtml(config, haCalendars); if (type === 'bins') return `

Milton Keynes only. Find your UPRN at findmyaddress.co.uk.

`; if (type === 'weather') return `

Uses panel location: ${esc(locationLabel || 'current panel location')}.

`; @@ -181,12 +187,14 @@ function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, bu return `

Paste the full electricity tariff code from Octopus. No Octopus API key is required for public Agile prices. See Octopus tariff/API details.

`; } -export function dashboardCellHtml(deviceId, index, slot, locationLabel = '', trainApi = {}, busApi = {}, trafficApi = {}, positionLabel = POSITIONS[index], todoLists = [], printers = [], isMini = false, haCalendars = {}, haTodos = {}) { +export function dashboardCellHtml(deviceId, index, slot, locationLabel = '', trainApi = {}, busApi = {}, trafficApi = {}, positionLabel = POSITIONS[index], todoLists = [], printers = [], isMini = false, haCalendars = {}, haTodos = {}, haSensors = {}) { const config = slot.drafts[slot.type] ?? defaultConfig(slot.type); - return `
${esc(positionLabel)}

${typeLabel(slot.type)}

${controlsHtml(slot.type, config, locationLabel, Boolean(trainApi.configured), trainApi.keyDraft ?? '', Boolean(busApi.configured), busApi.appIdDraft ?? '', busApi.appKeyDraft ?? '', Boolean(trafficApi.configured), trafficApi.keyDraft ?? '', todoLists, printers, isMini, haCalendars, haTodos)}
`; + const types = TYPES.filter((type) => type !== 'entities' || haSensors.supported || slot.type === 'entities'); + return `
${esc(positionLabel)}

${typeLabel(slot.type)}

${controlsHtml(slot.type, config, locationLabel, Boolean(trainApi.configured), trainApi.keyDraft ?? '', Boolean(busApi.configured), busApi.appIdDraft ?? '', busApi.appKeyDraft ?? '', Boolean(trafficApi.configured), trafficApi.keyDraft ?? '', todoLists, printers, isMini, haCalendars, haTodos, haSensors)}
`; } function summary(type, config, locationLabel, todoLists = []) { + if (type === 'entities') return config.entityIds.length ? `${config.entityIds.length} sensor${config.entityIds.length === 1 ? '' : 's'}` : 'Not set up'; if (type === 'calendar') { const count = (config.entityIds ?? config.calendarUrls ?? []).length; return count ? `${count} calendar${count === 1 ? '' : 's'} connected` : 'Not set up'; } if (type === 'weather') return locationLabel || 'Uses panel location'; if (type === 'trains') return config.originCrs && config.destinationCrs ? `${config.originCrs} → ${config.destinationCrs}` : 'Not set up'; @@ -432,7 +440,7 @@ function renderEditor(root) { { configured: state.trainApiConfigured, keyDraft: state.trainApiKeyDraft }, { configured: state.busApiConfigured, appIdDraft: state.busAppIdDraft, appKeyDraft: state.busAppKeyDraft }, { configured: state.trafficApiConfigured, keyDraft: state.trafficApiKeyDraft }, - slotPosition(state, index), state.todoLists, state.printers, state.isMini, state.haCalendars, state.haTodos); + slotPosition(state, index), state.todoLists, state.printers, state.isMini, state.haCalendars, state.haTodos, state.haSensors); panel.querySelector('[data-widget-type]').addEventListener('change', (event) => { const previous = slot.type; @@ -440,7 +448,12 @@ function renderEditor(root) { switchDashboardDraft(state.slots, index, event.target.value, slot.drafts[previous]); renderLayout(root); renderEditor(root); }); - if (slot.type === 'calendar') { + if (slot.type === 'entities') { + bindEntitiesEditor(panel, config, state.haSensors, (nextConfig) => { + slot.drafts.entities = nextConfig; + renderLayout(root); markDashboardChanged(root); + }); + } else if (slot.type === 'calendar') { panel.querySelector('[data-calendar-provider]')?.addEventListener('change', (event) => { rememberCell(panel, slot); switchCalendarProvider(slot, event.target.value); @@ -480,10 +493,10 @@ function renderEditor(root) { } } -export function renderDashboardEditor(root, device, trainApi = { configured: false }, busApi = { configured: false }, trafficApi = { configured: false }, remembered = { shared: [], slots: [[], [], [], []] }, todoLists = [], printers = [], haCalendars = {}, haTodos = {}) { +export function renderDashboardEditor(root, device, trainApi = { configured: false }, busApi = { configured: false }, trafficApi = { configured: false }, remembered = { shared: [], slots: [[], [], [], []] }, todoLists = [], printers = [], haCalendars = {}, haTodos = {}, haSensors = {}) { const slots = createDashboardDraftState(device.dashboardSections, remembered); const isMini = device.panelProfileId === MINI_PROFILE; - stateByRoot.set(root, { deviceId: device.id, locationLabel: device.locationLabel, slots, selectedIndex: 0, isMini, trainApiConfigured: Boolean(trainApi.configured), trainApiKeyDraft: '', busApiConfigured: Boolean(busApi.configured), busAppIdDraft: '', busAppKeyDraft: '', trafficApiConfigured: Boolean(trafficApi.configured), trafficApiKeyDraft: '', todoLists: clone(todoLists), printers: clone(printers), haCalendars, haTodos }); + stateByRoot.set(root, { deviceId: device.id, locationLabel: device.locationLabel, slots, selectedIndex: 0, isMini, trainApiConfigured: Boolean(trainApi.configured), trainApiKeyDraft: '', busApiConfigured: Boolean(busApi.configured), busAppIdDraft: '', busAppKeyDraft: '', trafficApiConfigured: Boolean(trafficApi.configured), trafficApiKeyDraft: '', todoLists: clone(todoLists), printers: clone(printers), haCalendars, haTodos, haSensors }); root.innerHTML = `
`; renderLayout(root); renderEditor(root); } diff --git a/public/entitiesEditor.js b/public/entitiesEditor.js new file mode 100644 index 0000000..43d21ea --- /dev/null +++ b/public/entitiesEditor.js @@ -0,0 +1,68 @@ +import { esc } from './components.js'; + +const MAX_SENSORS = 4; +const MAX_RESULTS = 20; + +function fallbackName(id) { return id.replace(/^sensor\./, '').replaceAll('_', ' '); } +function currentValue(entity) { + const value = entity?.state?.trim(); + if (!value || /^(unknown|unavailable|undefined|null|nan)$/i.test(value)) return 'Unavailable'; + return `${value}${entity.unit ? ` ${entity.unit}` : ''}`; +} + +export function entitiesControlsHtml(discovery = {}) { + return `
+

Choose up to four sensors. Their order controls the display. Click Save changes to apply. Read only: manage sensor states in Home Assistant.

+ ${!discovery.available ? '

Home Assistant sensors are unavailable. Saved selections are retained.

' : ''} +
+ +

+
`; +} + +/** Only selection changes cross the callback boundary; searching is UI state. */ +export function bindEntitiesEditor(panel, config, discovery, onChange) { + let selected = [...config.entityIds]; + const known = discovery.entities ?? []; + const input = panel.querySelector('[data-entity-search]'); + const selectedRoot = panel.querySelector('[data-entities-selected]'); + const resultsRoot = panel.querySelector('[data-entities-results]'); + const changed = () => { render(); onChange({ entityIds: [...selected] }); }; + + function render() { + selectedRoot.innerHTML = selected.map((id, index) => { + const entity = known.find((candidate) => candidate.entityId === id); + const name = entity?.name ?? fallbackName(id); + return `
${esc(name)}${esc(id)}${esc(entity ? currentValue(entity) : 'Missing/unavailable')}
+
`; + }).join('') || '

No sensors selected.

'; + const query = input.value.trim().toLowerCase(); + const matches = known.filter((entity) => !selected.includes(entity.entityId) + && `${entity.name} ${entity.entityId}`.toLowerCase().includes(query)); + panel.querySelector('[data-entities-count]').textContent = `${selected.length} / ${MAX_SENSORS} selected. ${matches.length > MAX_RESULTS ? `Showing first ${MAX_RESULTS} matches — refine your search.` : `${matches.length} matching sensors.`}`; + resultsRoot.innerHTML = matches.slice(0, MAX_RESULTS).map((entity) => ``).join(''); + } + + input.addEventListener('input', (event) => { event.stopPropagation(); render(); }); + input.addEventListener('change', (event) => event.stopPropagation()); + input.addEventListener('keydown', (event) => { if (event.key === 'Enter') event.preventDefault(); }); + resultsRoot.addEventListener('click', (event) => { + const button = event.target.closest('[data-entity-add]'); + const id = button?.dataset.entityAdd; + if (!id || selected.length >= MAX_SENSORS || selected.includes(id) || !known.some((entity) => entity.entityId === id)) return; + selected.push(id); changed(); + }); + selectedRoot.addEventListener('click', (event) => { + const remove = event.target.closest('[data-entity-remove]'); + const move = event.target.closest('[data-entity-move]'); + if (remove) selected.splice(Number(remove.dataset.entityRemove), 1); + else if (move) { + const index = Number(move.dataset.entityIndex); + const next = index + Number(move.dataset.entityMove); + if (next < 0 || next >= selected.length) return; + [selected[index], selected[next]] = [selected[next], selected[index]]; + } else return; + changed(); + }); + render(); +} diff --git a/public/panels.js b/public/panels.js index cada893..368b9e6 100644 --- a/public/panels.js +++ b/public/panels.js @@ -210,23 +210,25 @@ async function renderDetail(root, device, serviceStatus) { const dashboardEditor = detailEl.querySelector('#dashboard-editor'); bindTodoPreviewRefresh(dashboardEditor, root, device.id); bindPrinterPreviewRefresh(dashboardEditor, root, device.id); - renderDashboardEditor(dashboardEditor, device, serviceStatus.trainApi, serviceStatus.busApi, serviceStatus.trafficApi, remembered, serviceStatus.todoLists, serviceStatus.printers, serviceStatus.haCalendars, serviceStatus.haTodos); + renderDashboardEditor(dashboardEditor, device, serviceStatus.trainApi, serviceStatus.busApi, serviceStatus.trafficApi, remembered, serviceStatus.todoLists, serviceStatus.printers, serviceStatus.haCalendars, serviceStatus.haTodos, serviceStatus.haSensors); } export async function renderPanels(root) { - const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, runtime, calendars, todos] = await Promise.all([ + const [{ devices }, trainApi, busApi, trafficApi, { lists: todoLists }, { printers }, runtime, calendars, todos, sensors] = await Promise.all([ getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers'), getJson('/api/runtime-config'), getJson('/api/home-assistant/calendars').catch(() => ({ available: false, calendars: [] })), getJson('/api/home-assistant/todo-lists').catch(() => ({ available: false, lists: [] })), + getJson('/api/home-assistant/sensors').catch(() => ({ available: false, entities: [] })), ]); // Deployment capability is independent of discovery availability. A failed // runtime read surfaces as a page error instead of silently hiding providers. const supported = runtime.updateMode === 'home-assistant'; const haCalendars = { ...calendars, supported }; const haTodos = { ...todos, supported }; + const haSensors = { ...sensors, supported }; if (!devices.length) { root.innerHTML = '

No panels yet

Power one on and it will appear in the sidebar.

'; return; } if (!devices.some((d) => d.id === selectedId)) selectedId = devices[0].id; - await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars, haTodos }); + await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars, haTodos, haSensors }); } diff --git a/public/studio.css b/public/studio.css index 895304a..cee646f 100644 --- a/public/studio.css +++ b/public/studio.css @@ -476,3 +476,10 @@ body { .panel-save-bar .actions { margin-left: 0; width: 100%; } .panel-save-bar .actions button { flex: 1; } } +/* Read-only HA Sensors picker. Scoped to the new editor only. */ +.entities-editor-item{border-bottom:1px solid var(--line,#ddd);padding:12px 0;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px} +.entities-editor-item>div:first-child{min-width:0;flex:1 1 180px} +.entities-editor-item strong,.entities-editor-item small,.entities-editor-item span,.entities-editor-result strong,.entities-editor-result small,.entities-editor-result span{display:block;overflow-wrap:anywhere} +.entities-editor-item small,.entities-editor-result small{font-size:11px;opacity:.7} +.entities-editor-result{width:100%;text-align:left;margin:4px 0;padding:10px} +[data-entities-results]{max-height:300px;overflow:auto} diff --git a/src/homeAssistant/client.ts b/src/homeAssistant/client.ts index f95c529..1c0488a 100644 --- a/src/homeAssistant/client.ts +++ b/src/homeAssistant/client.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { isValidTimezone } from '../devices/schema.ts'; import { todoEntityIdSchema, homeAssistantTodoListsSchema, homeAssistantTodoResponseSchema } from './todoSchemas.ts'; import type { TodoData } from '../model/dashboard.ts'; +import { sensorEntityIdSchema, homeAssistantSensorStateSchema, homeAssistantSensorsSchema, type HomeAssistantSensorState } from './sensorSchemas.ts'; import { calendarEntityIdSchema, homeAssistantCalendarListSchema, homeAssistantCalendarEventsSchema, type HomeAssistantCalendarEvent, @@ -25,6 +26,13 @@ export interface HomeAssistantTodoDiscovery { error: string | null; } +export interface HomeAssistantSensorDiscovery { + supported: boolean; + available: boolean; + entities: Array>; + error: string | null; +} + export interface HomeAssistantStatus { available: boolean; mode: HomeAssistantMode; @@ -222,6 +230,21 @@ export class HomeAssistantClient { return this.request('services/todo/get_items?return_response', homeAssistantTodoResponseSchema(entityId), 'To Do items', signal, { method: 'POST', body: { entity_id: entityId, status: 'needs_action' } }); } + + async listSensors(signal?: AbortSignal): Promise { + const result = await this.request('states', homeAssistantSensorsSchema, 'sensor discovery', signal); + return { supported: this.enabled, available: result.available, + entities: result.available ? result.data.map(({ available: _available, ...sensor }) => sensor) : [], + error: result.available || !this.enabled ? null : result.error }; + } + + async getSensorState(entityId: string, signal?: AbortSignal): Promise> { + if (!sensorEntityIdSchema.safeParse(entityId).success) { + return { available: false, error: 'invalid Home Assistant sensor entity ID' }; + } + return this.request(`states/${encodeURIComponent(entityId)}`, + homeAssistantSensorStateSchema.refine((state) => state.entityId === entityId), 'sensor state', signal); + } } export function isHomeAssistantMode(raw: string | undefined): boolean { diff --git a/src/homeAssistant/sensorSchemas.ts b/src/homeAssistant/sensorSchemas.ts new file mode 100644 index 0000000..30abab7 --- /dev/null +++ b/src/homeAssistant/sensorSchemas.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +/** HA-4 V1 deliberately permits sensors only; future domains need new schemas. */ +export const sensorEntityIdSchema = z.string().max(255).regex(/^sensor\.[a-z0-9_]+$/, 'invalid Home Assistant sensor entity ID'); +export const sensorEntityIdsSchema = z.array(sensorEntityIdSchema).max(4) + .refine((ids) => new Set(ids).size === ids.length, 'sensor entity IDs must be unique'); + +export function sensorFallbackName(entityId: string): string { + return entityId.slice('sensor.'.length).replaceAll('_', ' '); +} + +function text(value: unknown, max: number): string | null { + const parsed = z.string().trim().min(1).max(max).safeParse(value); + return parsed.success ? parsed.data : null; +} + +export interface HomeAssistantSensorState { + entityId: string; + name: string; + state: string; + unit: string | null; + deviceClass: string | null; + available: boolean; +} + +/** Strip all unrelated attributes/timestamps at the server-only API boundary. */ +export const homeAssistantSensorStateSchema = z.object({ + entity_id: sensorEntityIdSchema, + state: z.string().trim().min(1).max(255), + attributes: z.object({ + friendly_name: z.unknown().optional(), + unit_of_measurement: z.unknown().optional(), + device_class: z.unknown().optional(), + }).optional(), +}).transform((state): HomeAssistantSensorState => ({ + entityId: state.entity_id, + name: text(state.attributes?.friendly_name, 255) ?? sensorFallbackName(state.entity_id), + state: state.state, + unit: text(state.attributes?.unit_of_measurement, 32), + deviceClass: text(state.attributes?.device_class, 64), + available: !['unknown', 'unavailable', 'undefined', 'null', 'nan'].includes(state.state.toLowerCase()), +})); + +// Validate the list envelope, then ignore malformed individual sensors. A bad +// entity must not prevent selecting other valid sensors in a large HA install. +export const homeAssistantSensorsSchema = z.array(z.object({ + entity_id: z.string().min(1), state: z.unknown().optional(), attributes: z.unknown().optional(), +})).transform((states) => { + const sensors = states.filter((state) => state.entity_id.startsWith('sensor.')) + .flatMap((state) => { + const parsed = homeAssistantSensorStateSchema.safeParse(state); + return parsed.success ? [parsed.data] : []; + }); + return [...new Map(sensors.map((sensor) => [sensor.entityId, sensor])).values()] + .sort((a, b) => a.name.localeCompare(b.name) || a.entityId.localeCompare(b.entityId)); +}); diff --git a/src/http/homeAssistantRoutes.ts b/src/http/homeAssistantRoutes.ts index b0b2905..37cce2b 100644 --- a/src/http/homeAssistantRoutes.ts +++ b/src/http/homeAssistantRoutes.ts @@ -3,6 +3,10 @@ import type { HomeAssistantClient } from '../homeAssistant/client.ts'; export function homeAssistantRoutes(client: HomeAssistantClient): Router { const router = Router(); + router.get('/home-assistant/sensors', async (_req, res) => { + res.set('cache-control', 'no-store'); + res.json(await client.listSensors()); + }); router.get('/home-assistant/todo-lists', async (_req, res) => { res.set('cache-control', 'no-store'); res.json(await client.listTodoLists()); diff --git a/src/http/manageRoutes.ts b/src/http/manageRoutes.ts index 962f490..2df643d 100644 --- a/src/http/manageRoutes.ts +++ b/src/http/manageRoutes.ts @@ -19,7 +19,7 @@ import { calendarUrlInputSchema } from '../sources/calendarUrl.ts'; import type { TodoStore } from '../todo/store.ts'; import type { PrinterConnectionStore } from '../printers/store.ts'; import { calendarEntityIdsSchema } from '../homeAssistant/calendarSchemas.ts'; -import { todoWidgetV2Schema } from '../widgets/registry.ts'; +import { todoWidgetV2Schema, entitiesWidgetV1Schema } from '../widgets/registry.ts'; const stationCodeInputSchema = z .string() @@ -43,6 +43,7 @@ const octopusTariffCodeInputSchema = z ); const dashboardSectionInputSchema = z.union([ + entitiesWidgetV1Schema, todoWidgetV2Schema, z.strictObject({ type: z.literal('calendar'), version: z.literal(1), diff --git a/src/model/dashboard.ts b/src/model/dashboard.ts index 160de03..6f2e208 100644 --- a/src/model/dashboard.ts +++ b/src/model/dashboard.ts @@ -68,7 +68,20 @@ export interface TodoData { items: string[]; } +/** Only display content; HA IDs, device classes and timestamps stay upstream. */ +export interface EntityDisplayItem { + name: string; + value: string; + unit: string | null; + available: boolean; +} + +export interface EntitiesData { + items: EntityDisplayItem[]; +} + export type DashboardSectionData = + | { type: 'entities'; data: EntitiesData | null; configured: boolean; health: SourceHealth | null } | { type: 'calendar'; data: CalendarData | null; health: SourceHealth } | { type: 'weather'; data: WeatherData | null; health: SourceHealth } | { type: 'trains'; data: TrainData | null; health: SourceHealth | null } diff --git a/src/model/hash.ts b/src/model/hash.ts index 1e97e64..461e77f 100644 --- a/src/model/hash.ts +++ b/src/model/hash.ts @@ -69,7 +69,7 @@ export function contentHash(data: ProfileDashboardData): string { || section.type === 'traffic' || section.type === 'octopus' ? { configured: section.health !== null } - : section.type === 'todo' + : section.type === 'todo' || section.type === 'entities' ? { configured: section.configured } : section.type === 'printers' ? { configured: section.configured } diff --git a/src/render/entities.ts b/src/render/entities.ts new file mode 100644 index 0000000..a961f12 --- /dev/null +++ b/src/render/entities.ts @@ -0,0 +1,39 @@ +import type { EntitiesData, EntityDisplayItem } from '../model/dashboard.ts'; + +function esc(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** HA owns units/conversions; both physical profiles use the same formatting. */ +export function formatEntityValue(item: EntityDisplayItem): string { + const value = item.value.trim(); + if (!item.available || !value || /^(unknown|unavailable|undefined|null|nan)$/i.test(value)) return 'UNAVAILABLE'; + const unit = item.unit?.trim(); + if (!unit) return value; + return `${value}${/^(%|°[CF]?)$/.test(unit) ? '' : ' '}${unit}`; +} + +/** Additive widget markup: no selectors or geometry shared with older widgets. */ +export function renderEntities(data: EntitiesData | null, configured: boolean, mini = false): string { + const items = data?.items.slice(0, 4) ?? []; + let content: string; + if (!configured || !data || !items.length) { + content = `
${configured ? 'Sensors unavailable' : 'Sensors — not set up'}
`; + } else if (items.length === 1) { + const item = items[0]!; + const value = formatEntityValue(item); + content = `
${esc(value)}
${esc(item.name)}
`; + } else { + content = `
${items.map((item) => { + const value = formatEntityValue(item); + return `
${esc(item.name)}${esc(value)}
`; + }).join('')}
`; + } + return `${mini ? '
SENSORS
' : ''}
${content}
`; +} + +const SHARED_CSS = `.entities-full,.entities-mini{overflow:hidden}.entities-full .entities-value,.entities-mini .entities-value{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.entities-full .entities-hero,.entities-mini .entities-hero{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;height:100%;gap:10px}.entities-full .entities-hero>*,.entities-mini .entities-hero>*{max-width:100%}.entities-full .entities-rows,.entities-mini .entities-rows{height:100%;display:grid;grid-template-rows:repeat(4,minmax(0,1fr))}.entities-full .entities-row,.entities-mini .entities-row{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);align-items:center;gap:8px}.entities-full .entities-row .entities-value,.entities-mini .entities-row .entities-value{text-align:right;font-weight:800}.entities-full .entities-name,.entities-mini .entities-name{min-width:0;overflow:hidden;text-overflow:ellipsis}.entities-full .entities-row .entities-name,.entities-mini .entities-row .entities-name{white-space:nowrap}.entities-full .entities-hero .entities-name,.entities-mini .entities-hero .entities-name{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.entities-full .entities-status,.entities-mini .entities-status{height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-weight:700}`; + +export const ENTITIES_CSS = `${SHARED_CSS}.entities-full{height:112px}.entities-full .entities-hero .entities-value{font-size:42px;line-height:1.15}.entities-full .entities-hero .entities-long{font-size:32px}.entities-full .entities-hero .entities-unavailable{font-size:24px}.entities-full .entities-hero .entities-name{font-size:15px;line-height:1.2;max-height:36px;font-weight:650}.entities-full .entities-row{font-size:13px;border-bottom:1px solid #000}.entities-full .entities-row:last-child{border:0}.entities-full .entities-row .entities-value{font-size:16px}.entities-full .entities-row .entities-unavailable{font-size:12px}.entities-full .entities-status{font-size:15px}`; + +export const MINI_ENTITIES_CSS = `${SHARED_CSS}.entities-mini{height:144px;padding-top:5px}.entities-mini .entities-hero .entities-value{font-size:34px;line-height:1.15}.entities-mini .entities-hero .entities-long{font-size:27px}.entities-mini .entities-hero .entities-unavailable{font-size:18px}.entities-mini .entities-hero .entities-name{font-size:13px;font-weight:700;line-height:1.2;max-height:32px}.entities-mini .entities-row{font-size:10px;border-bottom:1px solid #000;gap:5px}.entities-mini .entities-row:last-child{border:0}.entities-mini .entities-row .entities-value{font-size:12px}.entities-mini .entities-row .entities-unavailable{font-size:8px}.entities-mini .entities-status{font-size:15px}`; diff --git a/src/render/frameService.ts b/src/render/frameService.ts index 77fed79..637bd9c 100644 --- a/src/render/frameService.ts +++ b/src/render/frameService.ts @@ -16,6 +16,7 @@ import type { IcalFeedConfig } from '../sources/ical.ts'; import { runCalendars } from '../sources/calendarRunner.ts'; import { runHomeAssistantCalendars } from '../sources/homeAssistantCalendar.ts'; import { runHomeAssistantTodo } from '../sources/homeAssistantTodo.ts'; +import { runHomeAssistantEntities } from '../sources/homeAssistantEntities.ts'; import type { HomeAssistantClient } from '../homeAssistant/client.ts'; import { openMeteoSource } from '../sources/openMeteo.ts'; import { binsSource } from '../sources/bins.ts'; @@ -138,6 +139,12 @@ export class FrameService { let request: Promise; switch (widget.type) { + case 'entities': + request = widget.config.entityIds.length + ? runHomeAssistantEntities(widget.config.entityIds, this.deps.homeAssistantClient, runOptions) + .then((outcome) => ({ type: 'entities', data: outcome.data, configured: true, health: outcome.health })) + : Promise.resolve({ type: 'entities', data: null, configured: false, health: null }); + break; case 'calendar': request = ('entityIds' in widget.config ? runHomeAssistantCalendars(widget.config.entityIds, device.timezone, this.deps.homeAssistantClient, this.deps.cache, runOptions) diff --git a/src/render/miniTemplate.ts b/src/render/miniTemplate.ts index 406f7d1..23a642b 100644 --- a/src/render/miniTemplate.ts +++ b/src/render/miniTemplate.ts @@ -9,6 +9,7 @@ import type { TrainDeparture, } from '../model/dashboard.ts'; import type { PanelProfile } from '../panel/profile.ts'; +import { MINI_ENTITIES_CSS, renderEntities } from './entities.ts'; function esc(value: string): string { return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -221,6 +222,7 @@ const PRINTER_CSS = `.mini-printer-head{height:30px;border-bottom:2px solid #000 function renderWidget(section: DashboardSectionData, data: MiniDashboardData): string { switch (section.type) { + case 'entities': return renderEntities(section.data, section.configured, true); case 'calendar': return calendar(section, data); case 'weather': return weather(section, data); case 'trains': return trains(section, data); @@ -260,5 +262,6 @@ body{font-family:"Inter",Arial,sans-serif;-webkit-font-smoothing:none} export function renderMiniHtml(data: MiniDashboardData, profile: PanelProfile, fontCss: string): string { const todoCss = data.sections[0].type === 'todo' ? TODO_CSS : ''; const printerCss = data.sections[0].type === 'printers' ? PRINTER_CSS : ''; - return `
${renderWidget(data.sections[0], data)}
`; + const entitiesCss = data.sections[0].type === 'entities' ? MINI_ENTITIES_CSS : ''; + return `
${renderWidget(data.sections[0], data)}
`; } diff --git a/src/render/template.ts b/src/render/template.ts index b9cf3ae..d26fcb4 100644 --- a/src/render/template.ts +++ b/src/render/template.ts @@ -17,6 +17,7 @@ import type { import type { BinsData } from '../sources/bins.ts'; import type { PanelProfile } from '../panel/profile.ts'; import { panelCss } from './panel.css.ts'; +import { ENTITIES_CSS, renderEntities } from './entities.ts'; function esc(value: string): string { return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -247,6 +248,10 @@ function renderSection(section: DashboardSectionData, data: DashboardData, posit let label: string; let content: string; switch (section.type) { + case 'entities': + label = 'Sensors'; + content = renderEntities(section.data, section.configured); + break; case 'calendar': label = 'Today'; content = agendaCell(section.data, data.timezone); @@ -291,5 +296,6 @@ export function renderHtml(data: DashboardData, profile: PanelProfile, fontCss: const positions = ['tl', 'tr', 'bl', 'br']; const todoCss = data.sections.some((section) => section.type === 'todo') ? TODO_CSS : ''; const printerCss = data.sections.some((section) => section.type === 'printers') ? PRINTER_CSS : ''; - return `${banner(data)}
${data.sections.map((section, index) => renderSection(section, data, positions[index]!)).join('')}
`; + const entitiesCss = data.sections.some((section) => section.type === 'entities') ? ENTITIES_CSS : ''; + return `${banner(data)}
${data.sections.map((section, index) => renderSection(section, data, positions[index]!)).join('')}
`; } diff --git a/src/sources/homeAssistantEntities.ts b/src/sources/homeAssistantEntities.ts new file mode 100644 index 0000000..e5a477b --- /dev/null +++ b/src/sources/homeAssistantEntities.ts @@ -0,0 +1,38 @@ +import type { HomeAssistantClient } from '../homeAssistant/client.ts'; +import { sensorFallbackName, type HomeAssistantSensorState } from '../homeAssistant/sensorSchemas.ts'; +import type { EntitiesData } from '../model/dashboard.ts'; +import { runLiveSource, type RunOutcome, type RunSourceOptions } from './runner.ts'; +import type { Source } from './types.ts'; + +/** Live-only current state, never persisted/replayed from SourceCache. */ +export async function runHomeAssistantEntities( + entityIds: string[], client: HomeAssistantClient | undefined, options: RunSourceOptions, +): Promise> { + const source: Source = { + id: 'home-assistant-sensors', + async fetch(id, signal) { + try { + const result = await client?.getSensorState(id, signal); + return result?.available + ? { status: 'ok', data: result.data, fetchedAt: new Date().toISOString() } + : { status: 'error', error: result?.error ?? 'Home Assistant sensors are unavailable' }; + } catch { + return { status: 'error', error: 'Home Assistant sensors are unavailable' }; + } + }, + }; + const results = await Promise.all(entityIds.map((id) => runLiveSource(source, id, options))); + const anyResponse = results.some((result) => result.data !== null); + const allAvailable = results.every((result) => result.data?.available); + return { + data: anyResponse ? { items: results.map(({ data }, index) => ({ + name: data?.name ?? sensorFallbackName(entityIds[index]!), + value: data?.available ? data.state : '', + unit: data?.available ? data.unit : null, + available: data?.available ?? false, + })) } : null, + health: { id: source.id, status: allAvailable ? 'ok' : 'error', + fetchedAt: anyResponse ? new Date().toISOString() : null, + error: allAvailable ? null : 'One or more Home Assistant sensors are unavailable' }, + }; +} diff --git a/src/widgets/editorPreferences.ts b/src/widgets/editorPreferences.ts index 87e9d0a..74343d1 100644 --- a/src/widgets/editorPreferences.ts +++ b/src/widgets/editorPreferences.ts @@ -67,6 +67,7 @@ function clone(value: T): T { /** Only complete/useful configs become the shared fallback for other panels. */ function meaningful(widget: DashboardWidget): boolean { switch (widget.type) { + case 'entities': return widget.config.entityIds.length > 0; case 'calendar': return 'entityIds' in widget.config ? widget.config.entityIds.length > 0 : widget.config.calendarUrls.length > 0; case 'trains': return Boolean(widget.config.originCrs && widget.config.destinationCrs); diff --git a/src/widgets/registry.ts b/src/widgets/registry.ts index 98b55c6..da86305 100644 --- a/src/widgets/registry.ts +++ b/src/widgets/registry.ts @@ -1,6 +1,12 @@ import { z } from 'zod'; import { calendarEntityIdsSchema } from '../homeAssistant/calendarSchemas.ts'; import { todoEntityIdSchema } from '../homeAssistant/todoSchemas.ts'; +import { sensorEntityIdsSchema } from '../homeAssistant/sensorSchemas.ts'; + +export const entitiesWidgetConfigV1Schema = z.strictObject({ entityIds: sensorEntityIdsSchema }); +export const entitiesWidgetV1Schema = z.strictObject({ + type: z.literal('entities'), version: z.literal(1), config: entitiesWidgetConfigV1Schema, +}); /** Persisted calendar URLs stay broad so existing private feeds remain readable. */ export const calendarWidgetConfigV1Schema = z.strictObject({ @@ -127,6 +133,7 @@ export const emptyWidgetV1Schema = z.strictObject({ }); export type DashboardWidget = + | z.infer | z.infer | z.infer | z.infer @@ -142,6 +149,7 @@ export type DashboardWidget = /** Current runtime registry, explicitly keyed by widget type and version. */ export const widgetRegistry = { + entities: { 1: entitiesWidgetV1Schema }, calendar: { 1: calendarWidgetV1Schema, 2: calendarWidgetV2Schema }, weather: { 1: weatherWidgetV1Schema }, trains: { 1: trainsWidgetV1Schema }, diff --git a/test/fixtures/existingWidgets.ts b/test/fixtures/existingWidgets.ts new file mode 100644 index 0000000..49caa8b --- /dev/null +++ b/test/fixtures/existingWidgets.ts @@ -0,0 +1,18 @@ +import type { DashboardSectionData } from '../../src/model/dashboard.ts'; +import { offlinePrinter } from '../../src/printers/moonraker.ts'; +import { dashboardData } from './dashboard.ts'; + +/** Fixed pre-HA-4 cases for exact legacy template-output regression checks. */ +export function existingWidgets(): DashboardSectionData[] { + return [ + ...dashboardData().sections.slice(0, 2), + { type: 'trains', data: null, health: null }, + { type: 'bus', data: null, health: null }, + { type: 'traffic', data: null, health: null }, + { type: 'octopus', data: null, health: null }, + { type: 'todo', data: { items: ['Buy milk', 'Take bins out'] }, configured: true, health: null }, + { type: 'bins', data: null, health: null }, + { type: 'printers', data: { printers: [offlinePrinter('Workshop')] }, configured: true, health: null }, + { type: 'empty' }, + ]; +} diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index 96bd200..f6d5600 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.9'); + assert.equal(config.version, '0.1.0-ha.10'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); diff --git a/test/homeAssistant/sensors.test.ts b/test/homeAssistant/sensors.test.ts new file mode 100644 index 0000000..75d10bc --- /dev/null +++ b/test/homeAssistant/sensors.test.ts @@ -0,0 +1,108 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { homeAssistantSensorStateSchema } from '../../src/homeAssistant/sensorSchemas.ts'; + +const SECRET = 'do-not-expose-supervisor-token'; +const state = (entity_id = 'sensor.living_room', overrides = {}) => ({ + entity_id, state: ' 21.40 ', last_changed: 'private-time', + attributes: { friendly_name: ' Living Room ', unit_of_measurement: ' °C ', device_class: ' temperature ', secret: SECRET }, + ...overrides, +}); + +test('sensor discovery projects only bounded safe sensor fields, deduplicates and ignores malformed sensors', async () => { + const client = new HomeAssistantClient({ enabled: true, token: SECRET, fetchImpl: async (url, init) => { + assert.equal(String(url), 'http://supervisor/core/api/states'); + assert.equal(init?.method, 'GET'); + assert.equal((init?.headers as Record).authorization, `Bearer ${SECRET}`); + return Response.json([ + state(), state(), state('light.kitchen'), state('binary_sensor.door'), + state('sensor.battery_level', { state: '89', attributes: {} }), + state('sensor.bad/name'), state('sensor.too_long', { state: 'x'.repeat(256) }), + state('sensor.number', { state: 123 }), state('sensor.bad_attributes', { attributes: [] }), + ]); + } }); + const result = await client.listSensors(); + assert.equal(result.supported, true); + assert.equal(result.available, true); + assert.equal(result.entities.length, 2); + assert.deepEqual(result.entities.find((entry) => entry.entityId === 'sensor.living_room'), { + entityId: 'sensor.living_room', name: 'Living Room', state: '21.40', unit: '°C', deviceClass: 'temperature', + }); + assert.deepEqual(result.entities.find((entry) => entry.entityId === 'sensor.battery_level'), { + entityId: 'sensor.battery_level', name: 'battery level', state: '89', unit: null, deviceClass: null, + }); + assert.doesNotMatch(JSON.stringify(result), /do-not-expose|last_changed|attributes|private-time/); +}); + +test('malformed discovery envelopes and HA errors fail safely without erasing capability', async () => { + for (const payload of [{ states: [] }, [null], [{ entity_id: 1 }]]) { + const result = await new HomeAssistantClient({ enabled: true, token: SECRET, fetchImpl: async () => Response.json(payload) }).listSensors(); + assert.equal(result.supported, true); + assert.equal(result.available, false); + assert.deepEqual(result.entities, []); + assert.match(result.error!, /invalid sensor discovery/); + } + const failed = await new HomeAssistantClient({ enabled: true, token: SECRET, fetchImpl: async () => { throw new Error(SECRET); } }).listSensors(); + assert.deepEqual(failed, { supported: true, available: false, entities: [], error: 'Home Assistant is unavailable' }); + const disabled = await new HomeAssistantClient({ enabled: false, fetchImpl: async () => { throw new Error('must not fetch'); } }).listSensors(); + assert.deepEqual(disabled, { supported: false, available: false, entities: [], error: null }); +}); + +test('individual sensor reads validate identity before a GET to the encoded state endpoint', async () => { + const calls: string[] = []; + const client = new HomeAssistantClient({ enabled: true, token: SECRET, fetchImpl: async (url, init) => { + calls.push(String(url)); + assert.equal(init?.method, 'GET'); + assert.equal(init?.redirect, 'error'); + return Response.json(state()); + } }); + for (const id of ['light.living_room', 'sensor.a/../../config', 'sensor.a?x=1', 'sensor.A', 'sensor.', `sensor.${'a'.repeat(250)}`]) { + assert.equal((await client.getSensorState(id)).available, false); + } + assert.equal(calls.length, 0); + const result = await client.getSensorState('sensor.living_room'); + assert.deepEqual(calls, [`http://supervisor/core/api/states/${encodeURIComponent('sensor.living_room')}`]); + assert.equal(result.available, true); + if (result.available) assert.deepEqual(result.data, { + entityId: 'sensor.living_room', name: 'Living Room', state: '21.40', unit: '°C', deviceClass: 'temperature', available: true, + }); + assert.equal((await client.getSensorState('sensor.other')).available, false, 'a mismatched response cannot supply another sensor'); +}); + +test('sensor strings retain HA units and mark unknown/unavailable/invalid numeric placeholders honestly', () => { + for (const value of ['unknown', 'unavailable', 'NaN', 'undefined', 'null']) { + assert.equal(homeAssistantSensorStateSchema.parse(state(undefined, { state: value })).available, false); + } + for (const value of ['-21.40', '0', '312', 'online']) { + const result = homeAssistantSensorStateSchema.parse(state(undefined, { state: value })); + assert.equal(result.state, value); + assert.equal(result.available, true); + } + const fallback = homeAssistantSensorStateSchema.parse(state(undefined, { + attributes: { friendly_name: 123, unit_of_measurement: 'x'.repeat(33), device_class: ['temperature'] }, + })); + assert.equal(fallback.name, 'living room'); + assert.equal(fallback.unit, null); + assert.equal(fallback.deviceClass, null); +}); + +test('individual sensor errors, timeouts and cancellation never expose upstream secrets', async () => { + for (const status of [404, 401, 500, 503]) { + const result = await new HomeAssistantClient({ enabled: true, token: SECRET, fetchImpl: async () => new Response(SECRET, { status }) }).getSensorState('sensor.living_room'); + assert.equal(result.available, false); + assert.doesNotMatch(JSON.stringify(result), new RegExp(SECRET)); + } + const fetchImpl: typeof fetch = async (_url, init) => new Promise((_resolve, reject) => { + if (init?.signal?.aborted) reject(new Error(SECRET)); + else init?.signal?.addEventListener('abort', () => reject(new Error(SECRET)), { once: true }); + }); + const keepAlive = setTimeout(() => {}, 1000); + try { + const client = new HomeAssistantClient({ enabled: true, token: SECRET, timeoutMs: 10, fetchImpl }); + const timedOut = await client.getSensorState('sensor.living_room'); + assert.deepEqual(timedOut, { available: false, error: 'Home Assistant request timed out' }); + const controller = new AbortController(); controller.abort(); + assert.deepEqual(await client.getSensorState('sensor.living_room', controller.signal), { available: false, error: 'Home Assistant request was cancelled' }); + } finally { clearTimeout(keepAlive); } +}); diff --git a/test/http/homeAssistantIngress.test.ts b/test/http/homeAssistantIngress.test.ts index 0640a40..8bc90fe 100644 --- a/test/http/homeAssistantIngress.test.ts +++ b/test/http/homeAssistantIngress.test.ts @@ -22,7 +22,7 @@ function app(access: 'lan' | 'trusted-ingress' | 'real-ingress', activeHttps: nu token: 'server-only-supervisor-token', fetchImpl: async (url) => String(url).endsWith('/calendars') ? Response.json([{ entity_id: 'calendar.family', name: 'Family', attributes: { secret: 'server-only-supervisor-token' } }]) - : String(url).endsWith('/states') ? Response.json([{ entity_id: 'todo.family', attributes: { friendly_name: 'Family', token: 'server-only-supervisor-token' } }]) + : String(url).endsWith('/states') ? Response.json([{ entity_id: 'todo.family', attributes: { friendly_name: 'Family', token: 'server-only-supervisor-token' } }, { entity_id: 'sensor.temperature', state: '21.4', attributes: { friendly_name: 'Temperature', unit_of_measurement: '°C', token: 'server-only-supervisor-token' } }]) : Response.json({ version: '2026.8.1', location_name: 'Home', time_zone: 'Europe/London', }), @@ -93,6 +93,14 @@ test('To Do discovery shares LAN/Ingress auth and projects only identities and n assert.doesNotMatch(JSON.stringify(result.body), /supervisor|token|attributes|http:/i); }); +test('Sensors discovery shares LAN/Ingress authentication and returns only safe state fields', async () => { + assert.equal((await requestJson(app('lan'), '/api/home-assistant/sensors')).status, 401); + assert.equal((await requestJson(app('real-ingress'), '/api/home-assistant/sensors')).status, 403); + const result = await requestJson(app('trusted-ingress'), '/api/home-assistant/sensors'); + assert.deepEqual(result.body, { supported: true, available: true, entities: [{ entityId: 'sensor.temperature', name: 'Temperature', state: '21.4', unit: '°C', deviceClass: null }], error: null }); + assert.doesNotMatch(JSON.stringify(result.body), /token|attributes|supervisor/i); +}); + test('HA runtime config exposes only the active direct HTTPS root for WebFlash', async () => { assert.equal((await requestJson(app('trusted-ingress'), '/api/runtime-config?inkpanel_release=not-the-image')).body.release, 'test-image-release', 'release diagnostics come from the image, never the browser query'); diff --git a/test/public/entitiesEditorUx.test.js b/test/public/entitiesEditorUx.test.js new file mode 100644 index 0000000..e0bfc71 --- /dev/null +++ b/test/public/entitiesEditorUx.test.js @@ -0,0 +1,140 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import express from 'express'; +import { chromium } from 'playwright'; +import { parse } from 'yaml'; +import { createApp } from '../../src/http/app.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; + +const appConfig = parse(await readFile(new URL('../../home-assistant/config.yaml', import.meta.url), 'utf8')); +const MINI = 'ssd1681-200x200-mono'; +const FULL = 'wft0583-800x480-mono'; +const selectedIds = (page) => page.locator('[data-selected-entity]').evaluateAll((rows) => rows.map((row) => row.dataset.selectedEntity)); + +async function withStudio({ profile = MINI, ha = true, ids = [] } = {}, run) { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-sensors-ui-')); + const store = new DeviceStore(join(dir, 'config.json')); + await store.getOrCreate('panel-a', profile); + const sections = (first) => profile === MINI ? [first] : [first, ...Array.from({ length: 3 }, () => ({ type: 'empty', version: 1, config: {} }))]; + await store.update('panel-a', { claimed: true, dashboardSections: sections({ type: 'entities', version: 1, config: { entityIds: ids } }) }); + let outage = false; + const client = new HomeAssistantClient({ enabled: ha, token: 'server-only-test-token', fetchImpl: async () => { + if (outage) return new Response('', { status: 503 }); + return Response.json(Array.from({ length: 26 }, (_, i) => ({ entity_id: `sensor.room_${i}`, state: String(20 + i), + attributes: { friendly_name: i === 0 ? 'Living Room Temperature' : `Room ${i}`, unit_of_measurement: '°C', secret: 'never-in-studio' } }))); + } }); + const frame = { buffer: Buffer.alloc(profile === MINI ? 5000 : 48000, 255), etag: 'a'.repeat(32), renderedAt: new Date().toISOString() }; + const frames = { warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, + frameFor: async () => frame, renderNow: async () => frame, enrolmentFrame: async () => frame }; + const prefix = ha ? '/api/hassio_ingress/sensor-test' : ''; + const app = express(); + app.use(prefix || '/', createApp({ store, frames, homeAssistantClient: client, publicBaseUrl: 'http://panel.test:8080', + runtimeState: createRuntimeState(), dataDir: dir, firmwareDir: dir, auth: { password: null, secret: randomBytes(32) }, + updateMode: ha ? 'home-assistant' : 'self', homeAssistantRelease: appConfig.version, + ...(ha ? { access: { mode: 'home-assistant-ingress', isTrustedRequest: () => true } } : {}) })); + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const requests = []; + page.on('request', (request) => requests.push({ url: new URL(request.url()), method: request.method() })); + await page.goto(`http://127.0.0.1:${server.address().port}${prefix}/${appConfig.ingress_entry}`); + await page.locator('[data-widget-type]').waitFor(); + await run({ page, store, sections, prefix, requests, setOutage: () => { outage = true; } }); + } finally { + await browser.close(); + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +} + +for (const profile of [MINI, FULL]) { + test(`Sensors picker searches, limits, reorders, remembers and saves through release-query Ingress (${profile})`, async () => { + await withStudio({ profile }, async ({ page, store, prefix, requests, setOutage }) => { + assert.equal(new URL(page.url()).searchParams.get('inkpanel_release'), appConfig.version); + assert.equal(await page.locator('[data-widget-type] option[value="entities"]').textContent(), 'Home Assistant Sensors'); + assert.equal(await page.locator('[data-entity-add]').count(), 20, 'large discovery is bounded and searchable'); + const search = page.locator('[data-entity-search]'); + await search.fill('living room'); + assert.equal(await page.locator('[data-entity-add]').count(), 1); + assert.match(await page.locator('[data-entity-add]').textContent(), /20 °C/); + await search.press('Tab'); + assert.equal(await page.locator('#save-state').textContent(), 'All changes saved', 'search does not dirty configuration'); + await page.locator('[data-entity-add="sensor.room_0"]').click(); + assert.equal(await page.locator('#save-state').textContent(), 'Unsaved changes'); + for (const index of [1, 2, 3]) { + await search.fill(`sensor.room_${index}`); + await page.locator(`[data-entity-add="sensor.room_${index}"]`).click(); + } + await search.fill('sensor.room_4'); + assert.equal(await page.locator('[data-entity-add="sensor.room_4"]').isDisabled(), true); + assert.deepEqual(await selectedIds(page), ['sensor.room_0', 'sensor.room_1', 'sensor.room_2', 'sensor.room_3']); + await page.locator('[data-entity-move="-1"][data-entity-index="3"]').click(); + await page.locator('[data-entity-move="1"][data-entity-index="0"]').click(); + await page.locator('[data-entity-remove="3"]').click(); + const ordered = ['sensor.room_1', 'sensor.room_0', 'sensor.room_3']; + assert.deepEqual(await selectedIds(page), ordered); + await page.locator('[data-widget-type]').selectOption('weather'); + await page.locator('[data-widget-type]').selectOption('entities'); + assert.deepEqual(await selectedIds(page), ordered, 'away/back restores the draft order'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction(() => document.querySelector('#save-state').textContent === 'All changes saved'); + assert.deepEqual((await store.get('panel-a')).dashboardSections[0].config.entityIds, ordered); + await page.reload(); + await page.locator('[data-selected-entity]').first().waitFor(); + assert.deepEqual(await selectedIds(page), ordered, 'save/reopen retains order'); + if (profile === FULL) { + await page.locator('[data-dashboard-select="1"]').click(); + await page.locator('[data-widget-type]').selectOption('entities'); + assert.deepEqual(await selectedIds(page), ordered, 'an unconfigured slot uses the shared saved Sensors draft'); + await page.locator('[data-dashboard-select="0"]').click(); + } + await page.locator('[data-widget-type]').selectOption('weather'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction(() => document.querySelector('#save-state').textContent === 'All changes saved'); + await page.reload(); + await page.locator('[data-widget-type]').selectOption('entities'); + assert.deepEqual(await selectedIds(page), ordered, 'inactive per-slot Sensors draft survives save/reopen'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction(() => document.querySelector('#save-state').textContent === 'All changes saved'); + + // Missing selection survives both successful discovery and a complete outage. + const saved = await store.get('panel-a'); + saved.dashboardSections[0].config.entityIds.push('sensor.removed'); + await store.update('panel-a', { dashboardSections: saved.dashboardSections }); + await page.reload(); + await page.locator('[data-selected-entity="sensor.removed"]').waitFor(); + assert.match(await page.locator('[data-selected-entity="sensor.removed"]').textContent(), /Missing\/unavailable/); + setOutage(); + await page.reload(); + await page.locator('[data-entities-unavailable]').waitFor(); + assert.deepEqual(await selectedIds(page), [...ordered, 'sensor.removed']); + assert.equal(await page.locator('#save-state').textContent(), 'All changes saved'); + assert.equal(await page.locator('[data-widget-type] option[value="entities"]').count(), 1); + assert.ok(requests.some(({ url }) => url.pathname === `${prefix}/api/home-assistant/sensors`)); + for (const { url, method } of requests) { + if (/\.(js|css)$/.test(url.pathname) || url.pathname.includes('/api/')) assert.ok(url.pathname.startsWith(`${prefix}/`), url.href); + if (url.pathname.includes('/api/home-assistant/')) assert.equal(method, 'GET', 'Sensors do not mutate HA'); + } + assert.doesNotMatch(await page.content(), /server-only-test-token|never-in-studio/); + }); + }); +} + +test('standalone preserves saved Sensors but does not offer Sensors for a new widget', async () => { + await withStudio({ ha: false, ids: ['sensor.saved_missing'] }, async ({ page, store, sections }) => { + assert.deepEqual(await selectedIds(page), ['sensor.saved_missing']); + assert.match(await page.locator('[data-widget-controls]').textContent(), /Saved selections are retained/); + await store.update('panel-a', { dashboardSections: sections({ type: 'weather', version: 1, config: {} }) }); + await page.reload(); + await page.locator('[data-widget-type]').waitFor(); + assert.equal(await page.locator('[data-widget-type] option[value="entities"]').count(), 0); + }); +}); diff --git a/test/render/entitiesFrameService.test.ts b/test/render/entitiesFrameService.test.ts new file mode 100644 index 0000000..7b2ce6c --- /dev/null +++ b/test/render/entitiesFrameService.test.ts @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { runHomeAssistantEntities } from '../../src/sources/homeAssistantEntities.ts'; +import { FrameService } from '../../src/render/frameService.ts'; +import { Renderer } from '../../src/render/browser.ts'; +import { SourceCache } from '../../src/sources/cache.ts'; +import { defaultDevice } from '../../src/devices/types.ts'; +import { WEATHER } from '../fixtures/dashboard.ts'; + +const options = { deviceId: 'panel', timeoutMs: 1000 }; +const weatherSource = { id: 'weather', async fetch() { return { status: 'ok' as const, data: WEATHER, fetchedAt: new Date().toISOString() }; } }; + +test('live sensor fetches are concurrent and ordered with honest partial/total failure states', async () => { + const ids = ['sensor.power', 'sensor.missing', 'sensor.unknown', 'sensor.temperature']; + let active = 0; let peak = 0; let calls = 0; + const client = new HomeAssistantClient({ enabled: true, token: 'secret', fetchImpl: async (url) => { + calls++; active++; peak = Math.max(active, peak); + await new Promise((resolve) => setImmediate(resolve)); active--; + const id = String(url).split('/').at(-1)!; + assert.ok(String(url).includes('/states/sensor.'), 'rendering never calls the bulk states endpoint'); + if (id === 'sensor.missing') return new Response('', { status: 404 }); + return Response.json({ entity_id: id, state: id === 'sensor.unknown' ? 'unknown' : '21.40', attributes: { friendly_name: id.slice(7), unit_of_measurement: '°C', device_class: 'temperature', secret: 'hidden' } }); + } }); + const result = await runHomeAssistantEntities(ids, client, options); + assert.equal(calls, 4); assert.equal(peak, 4); + assert.deepEqual(result.data?.items, [ + { name: 'power', value: '21.40', unit: '°C', available: true }, + { name: 'missing', value: '', unit: null, available: false }, + { name: 'unknown', value: '', unit: null, available: false }, + { name: 'temperature', value: '21.40', unit: '°C', available: true }, + ]); + assert.equal(result.health.status, 'error'); + assert.doesNotMatch(JSON.stringify(result.data), /entityId|sensor\.|deviceClass|hidden|secret/); + const disabled = await runHomeAssistantEntities(ids, undefined, options); + assert.equal(disabled.data, null); + assert.equal(disabled.health.status, 'error'); + const unknown = await runHomeAssistantEntities(['sensor.unknown'], client, options); + assert.equal(unknown.data?.items[0]?.available, false, 'a valid unavailable state retains its row'); +}); + +test('Sensors integrate with both frame profiles, deduplicate, hash visible content and never replay stale state', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-sensor-frame-')); + const renderer = new Renderer(); + let calls = 0; let failed = false; let value = '21.4'; let hidden = 'old'; + const client = new HomeAssistantClient({ enabled: true, token: 'secret-token', fetchImpl: async (url) => { + if (String(url).includes('/calendars/')) return Response.json([]); + if (String(url).includes('/services/todo/')) return Response.json({ changed_states: [], service_response: { 'todo.home': { items: [{ summary: 'Buy milk', status: 'needs_action' }] } } }); + calls++; + if (failed) return new Response('secret-token', { status: 503 }); + return Response.json({ entity_id: String(url).split('/').at(-1), state: value, + attributes: { friendly_name: 'Living Room', unit_of_measurement: '°C', device_class: hidden, private: hidden }, last_updated: hidden }); + } }); + const cachePath = join(dir, 'cache'); + const service = new FrameService({ renderer, cache: new SourceCache(cachePath), weatherSource, homeAssistantClient: client }); + try { + for (const profile of ['wft0583-800x480-mono', 'ssd1681-200x200-mono'] as const) { + failed = false; value = '21.4'; + const device = defaultDevice(profile, profile); + const sensors = { type: 'entities' as const, version: 1 as const, config: { entityIds: ['sensor.living_room'] } }; + device.dashboardSections = profile === 'ssd1681-200x200-mono' ? [sensors] : [sensors, sensors, + { type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.home' } }, + { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.home'] } }]; + const before = calls; + const first = await service.frameFor(device, null); + assert.equal(calls - before, 1, 'identical widgets share the per-frame request'); + assert.equal(first.buffer.length, profile === 'ssd1681-200x200-mono' ? 5000 : 48000); + hidden = 'changed-metadata'; + assert.equal((await service.frameFor(device, null)).etag, first.etag); + value = '22.4'; + assert.notEqual((await service.frameFor(device, null)).etag, first.etag); + failed = true; + const html = await service.previewHtml(device); + assert.match(html, /Sensors unavailable/); + assert.doesNotMatch(html, /21\.4|22\.4|secret-token|sensor\.living_room/); + if (profile !== 'ssd1681-200x200-mono') assert.match(html, /Buy milk/); + await service.frameFor(device, null); + assert.ok(service.sourceIssues().some((issue) => issue.deviceId === device.id && issue.sourceId.includes('home-assistant-sensors'))); + failed = false; + device.dashboardSections[0] = { ...sensors, config: { entityIds: [] } }; + if (device.dashboardSections.length === 4) device.dashboardSections[1] = { type: 'empty', version: 1, config: {} }; + const beforeEmpty = calls; + assert.match(await service.previewHtml(device), /Sensors — not set up/); + assert.equal(calls, beforeEmpty); + } + for (const file of await readdir(cachePath)) assert.doesNotMatch(await readFile(join(cachePath, file), 'utf8'), /Living Room|sensor\.living_room|secret-token|changed-metadata/); + } finally { await renderer.close(); await rm(dir, { recursive: true, force: true }); } +}); diff --git a/test/render/entitiesTemplate.test.ts b/test/render/entitiesTemplate.test.ts new file mode 100644 index 0000000..881a3d7 --- /dev/null +++ b/test/render/entitiesTemplate.test.ts @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; +import type { DashboardSectionData, EntityDisplayItem } from '../../src/model/dashboard.ts'; +import { renderHtml } from '../../src/render/template.ts'; +import { renderMiniHtml } from '../../src/render/miniTemplate.ts'; +import { formatEntityValue } from '../../src/render/entities.ts'; +import { WFT0583, SSD1681_200X200 } from '../../src/panel/profile.ts'; +import { loadFontCss } from '../../src/render/fonts.ts'; +import { dashboardData } from '../fixtures/dashboard.ts'; + +const item = (name = 'Living Room Temperature', value = '21.4', unit: string | null = '°C', available = true): EntityDisplayItem => ({ name, value, unit, available }); +const section = (items: EntityDisplayItem[] | null, configured = true): DashboardSectionData => ({ type: 'entities', configured, data: items ? { items } : null, health: null }); + +test('one formatting helper preserves states/units and avoids invalid placeholders', () => { + for (const [value, unit, expected] of [['21.4', '°C', '21.4°C'], ['89', '%', '89%'], ['312', 'W', '312 W'], ['0.312', 'kW', '0.312 kW'], ['-2.50', null, '-2.50']] as const) { + assert.equal(formatEntityValue(item('Name', value, unit)), expected); + } + for (const value of ['unknown', 'unavailable', 'NaN', 'undefined', 'null', '']) assert.equal(formatEntityValue(item('Name', value)), 'UNAVAILABLE'); + assert.equal(formatEntityValue(item('Name', '21.4', '°C', false)), 'UNAVAILABLE'); +}); + +test('Sensors distinguish hero, rows, not configured and unavailable on both profiles', () => { + for (const mini of [false, true]) { + const render = (s: DashboardSectionData) => { + const full = dashboardData(); full.sections[0] = s; + return mini ? renderMiniHtml({ ...full, sections: [s] }, SSD1681_200X200, '') : renderHtml(full, WFT0583, ''); + }; + assert.match(render(section([item('')])), /entities-hero/); + assert.match(render(section([item('')])), /<Room>/); + assert.equal((render(section([item(), item(), item(), item()])).match(/class="entities-row"/g) ?? []).length, 4); + assert.match(render(section(null, false)), /Sensors — not set up/); + assert.match(render(section(null)), /Sensors unavailable/); + assert.match(render(section([item('Garden', '', null, false)])), /UNAVAILABLE/); + } +}); + +test('deterministic Sensors layouts stay inside full-size/Mini bounds with production fonts', async () => { + const cases = [ + [item()], [item(), item('Humidity', '46', '%')], + [item(), item('Humidity', '46', '%'), item('House Power', '312', 'W'), item('Battery', '89', '%')], + [item('An extremely long friendly name that must never escape the screen', '123456789012345678901234567890', 'Mbps')], + [item('Long '.repeat(30)), item('Long value', '1234567890'.repeat(10), 'kWh'), item('No unit', 'online', null), item('Garden Temperature', '', null, false)], + [item('Garden', '', null, false)], + ]; + const browser = await chromium.launch({ headless: true }); + const fontCss = await loadFontCss(); + try { + for (const mini of [false, true]) { + const profile = mini ? SSD1681_200X200 : WFT0583; + const page = await browser.newPage({ viewport: { width: profile.width, height: profile.height } }); + for (const items of cases) { + const full = dashboardData(); full.sections[0] = section(items); + const html = mini ? renderMiniHtml({ ...full, sections: [section(items)] }, profile, fontCss) : renderHtml(full, profile, fontCss); + await page.setContent(html); await page.evaluate(() => document.fonts.ready); + const bounds = await page.locator(mini ? '.mini' : '.cell--tl').boundingBox(); assert.ok(bounds); + for (const element of await page.locator('[class^="entities-"] .entities-name, [class^="entities-"] .entities-value, .entities-row').all()) { + const box = await element.boundingBox(); assert.ok(box); + assert.ok(box.x >= bounds.x && box.y >= bounds.y, 'starts inside widget'); + assert.ok(box.x + box.width <= bounds.x + bounds.width + 0.1, 'no horizontal overflow'); + assert.ok(box.y + box.height <= bounds.y + bounds.height + 0.1, 'no vertical overflow'); + } + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), profile.width); + const first = await page.screenshot(); + assert.deepEqual(await page.screenshot(), first, 'fixed sensor input produces deterministic pixels'); + } + await page.close(); + } + } finally { await browser.close(); } +}); diff --git a/test/render/existingWidgetOutput.test.ts b/test/render/existingWidgetOutput.test.ts new file mode 100644 index 0000000..cfc3374 --- /dev/null +++ b/test/render/existingWidgetOutput.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { renderHtml } from '../../src/render/template.ts'; +import { renderMiniHtml } from '../../src/render/miniTemplate.ts'; +import { WFT0583, SSD1681_200X200 } from '../../src/panel/profile.ts'; +import { dashboardData } from '../fixtures/dashboard.ts'; +import { existingWidgets } from '../fixtures/existingWidgets.ts'; + +// Captured from ha.9 (658b61e) before the additive Sensors renderer dispatch. +const baseline: Record = { + calendar: ['797dd3d52721d0339ca3a89f766510780bbd42cd849483679e1064d970657165', '5db253046d99ba455e0c9a1b4c5b424fbce9cfa8d880d0edc368b2a7dfb91ea1'], + weather: ['210f3d6dd5f8a07112521a18a830a0e9ae86b35bed56a5e1b9ecf169ad1fcdb3', '938d3be5361245559d4fd5deefafadfaeb839fa2579bd34333541d5159fdcf9d'], + trains: ['1850e92d8cd1742b9f702c4af8abb40927fbcdc491c70805f68f640622284ed9', 'f540d61683b5ad27bf80927ca592bc88294cb4c0aa4df96b9f575890aa0d7ad4'], + bus: ['18e667d239cafba3011299a6f7a01a2b6374d4215541b1370e3fab68b50a082e', '56fd331982f894ce6b80d81730e62d4b947005ca190da180283b7b5f6d0e6bff'], + traffic: ['594ea293190a6cf853211588eb48080ae5a1e8753ee8cd2fe0c9afe9cd939664', 'd3072e29ad1ea9ae2c4e628b1b0c3d7c72dda4c49a7693dd60308dcb0d70d0c5'], + octopus: ['6c1e9bce3056c3de4677892a2757af2077e4818ab4f179a26feae05dfe301bad', 'aa9c4a7032d08ac950fee269241a594b9ef579fe7f356dc16a2366686aeb042f'], + todo: ['cf5bd60ab3d5346a133d2aff3ffaffd4d62de4452d130fa8d0ab3b7cfb4a38f3', 'f5da3f99024cf0798a5fa0c88929009dd0a74fe3c9fb1089b94f5d050f757e0b'], + bins: ['85c7e584887c4fc29b0306333bd98f7a74c9d91e2bf92c2fde357e0b0c628d74', 'e529b1840b2ebec1b9a14e24e2a57ed6e1b501154b354b2ec990d4ec0266d8b2'], + printers: ['2b9740d7898c729aa47a7c52feb4e3707bbb5a72990612b6eff4fa2f2c3c75b6', 'f1766e0237e35684b3ff539ad2aa68e89dca9f16accb73241420c562c142a3e0'], + empty: ['7456ebe7918351c8bf4ab243311a2d1d704fdd522bbcdb4a282b9af43f6ee09e', '69fec23d167fd243f7198c117c3f550dce8e1d0811905e82312a7b7af1a6970f'], +}; + +test('every existing full-size/Mini widget retains byte-identical ha.9 HTML/CSS', () => { + for (const section of existingWidgets()) { + const full = dashboardData(); full.sections[0] = section; + const output = [renderHtml(full, WFT0583, ''), renderMiniHtml({ ...full, sections: [section] }, SSD1681_200X200, '')]; + assert.deepEqual(output.map((html) => createHash('sha256').update(html).digest('hex')), baseline[section.type], section.type); + for (const html of output) assert.doesNotMatch(html, /entities-(full|mini)/); + } +}); diff --git a/test/widgets/entities.test.ts b/test/widgets/entities.test.ts new file mode 100644 index 0000000..1d28a9f --- /dev/null +++ b/test/widgets/entities.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { dashboardWidgetSchema } from '../../src/widgets/registry.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import { CURRENT_DEVICE_STORE_SCHEMA_VERSION } from '../../src/devices/schema.ts'; +import { DashboardEditorPreferencesStore } from '../../src/widgets/editorPreferences.ts'; + +const widget = (entityIds: string[]) => ({ type: 'entities' as const, version: 1 as const, config: { entityIds } }); + +test('entities V1 validates zero to four ordered, unique sensor IDs without existence checks', () => { + for (const ids of [[], ['sensor.missing'], ['sensor.d', 'sensor.a', 'sensor.b', 'sensor.c']]) { + assert.deepEqual(dashboardWidgetSchema.parse(widget(ids)), widget(ids)); + } + for (const ids of [['sensor.a', 'sensor.a'], ['sensor.a', 'sensor.b', 'sensor.c', 'sensor.d', 'sensor.e'], ['binary_sensor.a'], ['sensor.A'], ['sensor.a/path'], ['sensor.'], [`sensor.${'a'.repeat(250)}`]]) { + assert.equal(dashboardWidgetSchema.safeParse(widget(ids)).success, false); + } + assert.equal(dashboardWidgetSchema.safeParse({ ...widget([]), version: 2 }).success, false); + assert.equal(dashboardWidgetSchema.safeParse({ ...widget([]), config: { entityIds: [], extra: true } }).success, false); +}); + +test('Sensors persist on both profiles without migration and participate in slot/shared remembered settings', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-entities-store-')); + try { + const file = join(dir, 'config.json'); + const store = new DeviceStore(file); + const sensors = widget(['sensor.removed_b', 'sensor.removed_a']); + for (const profile of ['wft0583-800x480-mono', 'ssd1681-200x200-mono'] as const) { + const device = await store.getOrCreate(profile, profile); + device.dashboardSections[0] = sensors; + await store.update(device.id, { dashboardSections: device.dashboardSections }); + const reopened = await new DeviceStore(file).get(device.id); + assert.deepEqual(reopened!.dashboardSections[0], sensors); + assert.equal(JSON.parse(await readFile(file, 'utf8')).schemaVersion, CURRENT_DEVICE_STORE_SCHEMA_VERSION); + } + const preferencesFile = join(dir, 'preferences.json'); + const preferences = new DashboardEditorPreferencesStore(preferencesFile); + await preferences.set('panel', [[sensors, { type: 'weather', version: 1, config: {} }], [], [], []]); + const reopened = new DashboardEditorPreferencesStore(preferencesFile); + await reopened.load(); + assert.deepEqual(reopened.get('panel').slots[0]![0], sensors); + assert.deepEqual(reopened.get('other-panel').shared.find((item) => item.type === 'entities'), sensors); + } finally { await rm(dir, { recursive: true, force: true }); } +}); From 6a49c9dc4ad7baa7ad0292b5a70dcec847c945ea Mon Sep 17 00:00:00 2001 From: CtrlAltcouk Date: Fri, 28 Aug 2026 19:25:42 +0100 Subject: [PATCH 13/14] Version Studio assets for Home Assistant Ingress ha.11 --- .github/workflows/home-assistant-image.yml | 2 +- docs/home-assistant-app.md | 22 ++- home-assistant/CHANGELOG.md | 8 ++ home-assistant/README.md | 6 +- home-assistant/config.yaml | 4 +- public/login.html | 25 +--- public/login.js | 22 +++ public/styles.css | 6 +- src/http/app.ts | 27 +--- src/http/studioAssets.ts | 58 ++++++++ test/homeAssistant/package.test.ts | 2 +- test/public/panelsReliability.test.js | 3 +- test/public/paths.test.js | 2 +- test/public/studioAssets.test.js | 159 +++++++++++++++++++++ 14 files changed, 281 insertions(+), 65 deletions(-) create mode 100644 public/login.js create mode 100644 src/http/studioAssets.ts create mode 100644 test/public/studioAssets.test.js diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml index 64f623a..649a621 100644 --- a/.github/workflows/home-assistant-image.yml +++ b/.github/workflows/home-assistant-image.yml @@ -44,7 +44,7 @@ permissions: env: IMAGE_NAME: inkpanel-home-assistant - VERSION: 0.1.0-ha.10 + VERSION: 0.1.0-ha.11 ARCHITECTURES: '["amd64", "aarch64"]' jobs: diff --git a/docs/home-assistant-app.md b/docs/home-assistant-app.md index 7c1568c..7de2adc 100644 --- a/docs/home-assistant-app.md +++ b/docs/home-assistant-app.md @@ -1,6 +1,6 @@ # Home Assistant App architecture -Status: HA-1, HA-2 and ha.6 installation-location defaults are implemented and validated on real Home Assistant hardware. HA-3 read-only Home Assistant To Do is implemented; real-world testing now confirms the ha.9 Ingress freshness fix works. `0.1.0-ha.10` adds HA-4 Home Assistant Sensors on the experimental `Home-Assistant` branch. HA-4 is implemented but not yet real-world validated. PR #31 remains draft and unmerged. +Status: HA-1, HA-2 and ha.6 installation-location defaults are validated. HA-3 and HA-4 are implemented. Real-world ha.10 Sensors worked through direct LAN Studio, but Ingress still loaded older nested frontend modules: ha.9's document query was not sufficient to version the module graph. `0.1.0-ha.11` versions the complete Studio asset namespace on the experimental `Home-Assistant` branch. HA-4 awaits final real-world Ingress/physical validation. PR #31 remains draft and unmerged. InkPanel remains a standalone product. Home Assistant support is an additional deployment and data-provider layer; it must not make the normal Docker/Proxmox/Raspberry Pi installation depend on Home Assistant. @@ -150,7 +150,7 @@ Selected calendars are fetched concurrently and independently through `SourceCac Within the same window, temporary failures reuse validated last-good raw events and report stale health. Crossing the date window intentionally does not replay incomplete previous-day data. Partial failures retain other calendars' events and report an aggregate count; all unavailable means Calendar unavailable. Other widget sources continue independently. -The full-size 800×480 and Mini 200×200 Calendar renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. The current experimental image tag is `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.10` for linux/amd64 and linux/arm64 (HA's aarch64). +The full-size 800×480 and Mini 200×200 Calendar renderers are unchanged. Only their source of `CalendarData` changes. Firmware, provisioning, schedules and panel protocol are unchanged. The current experimental image tag is `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.11` for linux/amd64 and linux/arm64 (HA's aarch64). #### First-time panel location defaults (ha.6; validated on real hardware) @@ -248,11 +248,23 @@ Full-size Sensors uses a dominant value with its friendly name beneath for one s In HA App mode choose **Home Assistant Sensors** in Content. Search by name or entity ID (at most 20 search results at once), inspect current values/units, add up to four, remove or reorder, then **Save changes**. Searching does not dirty panel config. Missing selections stay visible as missing/unavailable. Existing per-slot/shared remembered settings retain IDs and order across Sensors → Weather → Sensors and save/reopen. Standalone hides the new option for other widgets but preserves any already-saved Sensors config. -ha.10 updates App `version`, `ingress_entry: "?inkpanel_release=0.1.0-ha.10"` and the image workflow version together. Existing checks enforce the same release through `BUILD_VERSION`, `INKPANEL_HA_RELEASE` and runtime diagnostics. Historical ha.9 details/checklist above document the original freshness fix; use ha.10 for the current upgrade. +ha.10 introduced Sensors without changing the asset namespace. ha.11 keeps that implementation unchanged and updates App `version`, `ingress_entry: "?inkpanel_release=0.1.0-ha.11"` and the image workflow version together. Existing checks enforce the same release through `BUILD_VERSION`, `INKPANEL_HA_RELEASE` and runtime diagnostics. Use ha.11 for the current upgrade. -#### Real-world validation checklist for ha.10 +#### ha.11 complete frontend asset namespace -1. Upgrade to `0.1.0-ha.10`, reopen from the HA sidebar normally, and confirm the iframe query and both LAN/Ingress runtime-config releases equal `0.1.0-ha.10`. Do not clear caches or reinstall. +The real-world distinction was the same container serving current Sensors UI over LAN but older nested modules through Ingress. The entry query changes only the document URL; `./app.js` and its relative imports otherwise retain their previous URLs. `no-store` headers alone do not prevent a stale intermediary from replaying those URLs. + +`mountStudioAssets` serves the same public directory under `/assets//` and the legacy root. In HA mode the release comes from the existing image `BUILD_VERSION` → `INKPANEL_HA_RELEASE` → `homeAssistantRelease` dependency, never a request query. It must be a bounded alphanumeric/dot/hyphen/underscore identifier with no leading dot or path separators. Invalid supplied metadata is rejected; unpackaged HA embedders without metadata retain root assets, while production images always supply the checked release. + +Known index, login and legal documents are read once per app construction and their local script/style/favicon references receive the release prefix. The public HTML remains usable in standalone without placeholders or HA environment variables. Login's unchanged inline behaviour is moved into a relative module so it and `paths.js` are versioned too. The actual document stays at `/` or its normal `.html` path; no `` element, document relocation or `appPath()` change is introduced. + +Normal relative ES imports resolve from their importing module: `/assets/0.1.0-ha.11/app.js` → `panels.js` → `dashboardEditor.js` → `entitiesEditor.js`, all beneath the same namespace. Dynamic city-picker/WebFlash imports and CSS imports follow the same rule. Font references are relative and retain immutable caching. Terms/privacy navigation stays at the document root. Normal assets/documents retain `no-store` without ETag/Last-Modified; release URL separation now provides freshness independently of those headers. Old namespace URLs are not mapped to new bytes; document entrypoints are not served from the asset namespace, and Express static serving bounds access to the public/font roots. + +Standalone ignores HA release metadata and continues to load root modules and APIs. HA LAN and Ingress share the versioned asset implementation, but API calls and previews remain under the original page/Ingress prefix. Regression tests emulate a cached old `panels.js` at the stable URL, navigate normally to the new release in the same browser context, and assert the complete new module graph and Sensors/Calendar/To Do controls load without using the stale module. + +#### Real-world validation checklist for ha.11 + +1. Upgrade to `0.1.0-ha.11`, reopen from the HA sidebar normally, and confirm the iframe query and both LAN/Ingress runtime-config releases equal `0.1.0-ha.11`. Do not hard refresh, clear caches or reinstall. In Network, confirm JS/CSS—including `app.js`, `panels.js`, `dashboardEditor.js`, `entitiesEditor.js` and dynamic imports—load beneath `/api/hassio_ingress//assets/0.1.0-ha.11/`. API requests must remain beneath `/api/hassio_ingress//api/`, never `/assets/`. 2. On a full-size panel, choose Sensors in one section. Search by friendly name and by ID, select a temperature sensor and save. Open the preview and wake the panel: confirm the hero value/unit/name matches HA without conversion. 3. Add humidity, power and battery (or other real sensors); reorder and save. Verify four rows, matching order/units, on preview and physical display. Try long names/values and an entity without a unit: no overlap or overflow. 4. Repeat single-sensor and four-sensor checks on Mini at physical size, including long text and UNAVAILABLE. Confirm legibility; HA-4 is not validated until these real displays are checked. diff --git a/home-assistant/CHANGELOG.md b/home-assistant/CHANGELOG.md index 19d208f..528c4b3 100644 --- a/home-assistant/CHANGELOG.md +++ b/home-assistant/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.0-ha.11 + +- Real-world ha.10 Sensors worked over direct LAN, while Ingress retained older nested frontend modules. A release query on the document alone did not version stable JS/CSS/import URLs. +- Serve the complete Studio asset graph under `/assets//` without copying the public directory. HA index/login/legal documents reference this namespace; ordinary relative imports, dynamic imports and CSS dependencies inherit it. +- Keep document URLs and API/Ingress prefix resolution unchanged. Preserve root assets for standalone and legacy callers, normal `no-store` headers and immutable vendor fonts. Validate release metadata and never alias older release namespaces to current assets. +- Advance App, Ingress entry query and image release to ha.11. Keep Sensors architecture and physical layouts unchanged; HA-4 awaits final real-world Ingress/physical validation. +- No firmware, framebuffer, protocol, profile, DeviceStore version or frozen migration changes. + ## 0.1.0-ha.10 - Add HA-4 Home Assistant Sensors: the first read-only generic entity-display milestone, supporting only `sensor.*` in the strict `entities` V1 widget with up to four ordered, unique selections. diff --git a/home-assistant/README.md b/home-assistant/README.md index 407f286..e6ff2b3 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -2,7 +2,7 @@ InkPanel turns an ESP32-S3 e-paper display into a configurable dashboard. This Home Assistant App runs the same InkPanel server and Studio as the standalone installation. -This `0.1.0-ha.10` release is experimental. Add +This `0.1.0-ha.11` release is experimental. Add `https://github.com/CtrlAltcouk/inkpanel#Home-Assistant` as a Home Assistant App repository to test it. The Studio opens through Home Assistant Ingress. Physical panels use the separately configured LAN address; they cannot use an Ingress URL. @@ -21,8 +21,8 @@ To Do can now display a Home Assistant `todo.*` list using the existing full-siz Real-world ha.8 testing confirmed the provider and preview fixes worked over direct LAN, but Ingress retained an older Studio document. ha.9 gives each release a different Ingress entry query, making the iframe load a fresh document after upgrading while preserving all existing base paths. Normal Studio assets remain `no-store`. The server's `/api/runtime-config` reports the image release in HA mode for comparison between Ingress and LAN. -Real-world testing now confirms the ha.9 Ingress freshness fix works. Upgrade to ha.10 and reopen InkPanel from the Home Assistant sidebar normally; no hard refresh, cache clearing or reinstall should be needed. Confirm the iframe URL contains `inkpanel_release=0.1.0-ha.10` and runtime config reports the same release. +Real-world ha.10 testing confirmed Sensors worked through direct LAN Studio, but Ingress still loaded older nested frontend modules. Versioning the document alone was insufficient because JS/CSS URLs remained stable. ha.11 versions the entire Studio asset namespace using the image release, so relative module imports also receive new URLs. Upgrade to ha.11 and reopen normally from the HA sidebar, without hard refresh, cache clearing or reinstall. Confirm the iframe query is `inkpanel_release=0.1.0-ha.11`, runtime config reports that release, and Studio JS/CSS requests include `/assets/0.1.0-ha.11/` beneath the existing Ingress prefix. **Home Assistant Sensors** is the first read-only generic entity-display milestone, deliberately supporting only `sensor.*` entities. Choose the new Content option in Studio, search by friendly name or entity ID, add up to four sensors, arrange their order and click **Save changes**. One sensor uses a large-value layout; two to four use compact rows on both full-size and Mini displays. Values and units come directly from HA without conversions. Missing sensors remain selected until explicitly removed; outages show unavailable data, never a persisted stale sensor value. Manage sensors in HA, not InkPanel. -HA-4 is implemented but awaits real-world Home Assistant and physical-display validation. Existing widgets, firmware, framebuffer/protocol, profiles and DeviceStore migrations are unchanged. See the repository's `docs/home-assistant-app.md` for the architecture and ha.10 validation checklist. +HA-4 is implemented but awaits final real-world Ingress and physical-display validation. Sensors and existing widgets, firmware, framebuffer/protocol, profiles and DeviceStore migrations are unchanged. See the repository's `docs/home-assistant-app.md` for the architecture and ha.11 validation checklist. diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index ba2c76b..2a2c752 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,5 +1,5 @@ name: InkPanel -version: 0.1.0-ha.10 +version: 0.1.0-ha.11 slug: inkpanel description: Self-hosted e-paper dashboard server and Studio url: https://github.com/CtrlAltcouk/inkpanel @@ -14,7 +14,7 @@ init: false ingress: true ingress_port: 8099 # Supervisor appends this to its trailing-slash Ingress URL. Keep it query-only. -ingress_entry: "?inkpanel_release=0.1.0-ha.10" +ingress_entry: "?inkpanel_release=0.1.0-ha.11" panel_icon: mdi:tablet-dashboard homeassistant_api: true ports: diff --git a/public/login.html b/public/login.html index 656b514..7bc2b29 100644 --- a/public/login.html +++ b/public/login.html @@ -15,29 +15,6 @@

inkpanel

- + diff --git a/public/login.js b/public/login.js new file mode 100644 index 0000000..843399c --- /dev/null +++ b/public/login.js @@ -0,0 +1,22 @@ +import { appPath } from './paths.js'; +const form = document.getElementById('form'); +const error = document.getElementById('error'); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + error.hidden = true; + + const res = await fetch(appPath('/api/auth/login'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: document.getElementById('password').value }), + }); + + if (res.ok) { + location.href = appPath('/'); + return; + } + const body = await res.json().catch(() => ({ error: res.statusText })); + error.textContent = body.error ?? 'Sign in failed'; + error.hidden = false; +}); diff --git a/public/styles.css b/public/styles.css index ca811d6..4e4cfeb 100644 --- a/public/styles.css +++ b/public/styles.css @@ -8,21 +8,21 @@ */ @font-face { font-family: "Dela Gothic One"; - src: url("/vendor/fonts/dela-gothic-one-latin-400-normal.woff2") format("woff2"); + src: url("./vendor/fonts/dela-gothic-one-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "Inter"; - src: url("/vendor/fonts/inter-latin-400-normal.woff2") format("woff2"); + src: url("./vendor/fonts/inter-latin-400-normal.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "Inter"; - src: url("/vendor/fonts/inter-latin-700-normal.woff2") format("woff2"); + src: url("./vendor/fonts/inter-latin-700-normal.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; diff --git a/src/http/app.ts b/src/http/app.ts index 03aa665..49b8138 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -1,7 +1,5 @@ import express, { type NextFunction, type Request, type Response } from 'express'; -import { createRequire } from 'node:module'; -import { basename, dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { basename, join } from 'node:path'; import { DeviceStoreError, type DeviceStore } from '../devices/store.ts'; import type { FrameService } from '../render/frameService.ts'; import type { NationalRailCredentialStore } from '../sources/nationalRailCredentials.ts'; @@ -23,14 +21,7 @@ import { PrinterConnectionStore, PrinterStoreError } from '../printers/store.ts' import { HomeAssistantClient } from '../homeAssistant/client.ts'; import { homeAssistantRoutes } from './homeAssistantRoutes.ts'; import type { UpdateMode } from '../system/updateOwnership.ts'; - -const require = createRequire(import.meta.url); -const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public'); - -/** Directory holding a @fontsource package's font files. */ -function fontDir(pkg: string): string { - return join(dirname(require.resolve(`${pkg}/package.json`)), 'files'); -} +import { mountStudioAssets } from './studioAssets.ts'; function deviceStoreErrorBody(err: DeviceStoreError) { return { @@ -248,19 +239,7 @@ export function createApp(deps: AppDeps): express.Express { next(err); }); - // Serve the latin-subset woff2 straight from @fontsource rather than - // committing a 2.7 MB TTF for one heading in the admin UI. - const fontOptions = { immutable: true, maxAge: '30d' }; - app.use('/vendor/fonts', express.static(fontDir('@fontsource/dela-gothic-one'), fontOptions)); - app.use('/vendor/fonts', express.static(fontDir('@fontsource/inter'), fontOptions)); - - // Studio modules keep stable URLs across App upgrades, including Ingress. - // Do not let old HTML/JS/CSS (or their validators) survive a release change. - app.use(express.static(publicDir, { - etag: false, - lastModified: false, - setHeaders: (res) => { res.setHeader('Cache-Control', 'no-store'); }, - })); + mountStudioAssets(app, updateMode === 'home-assistant' ? deps.homeAssistantRelease : undefined); return app; } diff --git a/src/http/studioAssets.ts b/src/http/studioAssets.ts new file mode 100644 index 0000000..eca24ee --- /dev/null +++ b/src/http/studioAssets.ts @@ -0,0 +1,58 @@ +import express from 'express'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public'); +const staticOptions = { + index: false as const, etag: false, lastModified: false, + setHeaders: (res: { setHeader(name: string, value: string): unknown }) => { res.setHeader('Cache-Control', 'no-store'); }, +}; + +/** Build metadata only: never accept a request/query value as a filesystem path. */ +export function studioAssetBase(release?: string): string { + if (release === undefined) return './'; // Standalone and unpackaged HA embedders. + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(release)) throw new Error('Invalid Studio asset release'); + return `./assets/${release}/`; +} + +function mountFiles(router: express.Router) { + for (const pkg of ['@fontsource/dela-gothic-one', '@fontsource/inter']) { + const fonts = join(dirname(require.resolve(`${pkg}/package.json`)), 'files'); + router.use('/vendor/fonts', express.static(fonts, { immutable: true, maxAge: '30d' })); + } + router.use(express.static(publicDir, staticOptions)); +} + +export function mountStudioAssets(app: express.Express, release?: string): void { + const assetBase = studioAssetBase(release); + // Only known document entrypoints; no element or change to API roots. + for (const name of ['index', 'login', 'terms', 'privacy']) { + const original = readFileSync(join(publicDir, `${name}.html`), 'utf8'); + const html = original.replace(/((?:src|href)=")\.\/([^"<>]+\.(?:js|css|svg))"/g, + (_match, attribute: string, asset: string) => `${attribute}${assetBase}${asset}"`); + app.get(name === 'index' ? ['/', '/index.html'] : `/${name}.html`, (_req, res) => { + res.set('Cache-Control', 'no-store'); + // res.send would generate an ETag for HTML; keep the existing no-validator policy. + res.type('html').end(html); + }); + } + if (release !== undefined) { + const assets = express.Router(); + // Documents must stay outside the asset namespace so appPath keeps its root. + assets.use((req, res, next) => { + let path: string; + try { path = decodeURIComponent(req.path).toLowerCase(); } + catch { res.sendStatus(400); return; } + if (path.endsWith('.html') || path.endsWith('/')) { res.sendStatus(404); return; } + next(); + }); + mountFiles(assets); + app.use(`/assets/${release}`, assets); + // Never alias old release URLs to current files. + app.use('/assets', (_req, res) => { res.sendStatus(404); }); + } + mountFiles(app); // Legacy root paths and standalone remain available. +} diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts index f6d5600..c00a6e1 100644 --- a/test/homeAssistant/package.test.ts +++ b/test/homeAssistant/package.test.ts @@ -11,7 +11,7 @@ test('repository and immediate App metadata parse and describe the HA-1 boundary const repository = parse(await readFile(join(root, 'repository.yaml'), 'utf8')); const config = parse(await readFile(join(root, 'home-assistant', 'config.yaml'), 'utf8')); assert.equal(repository.url, 'https://github.com/CtrlAltcouk/inkpanel'); - assert.equal(config.version, '0.1.0-ha.10'); + assert.equal(config.version, '0.1.0-ha.11'); assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); assert.deepEqual(config.arch, ['amd64', 'aarch64']); assert.equal(config.ingress, true); diff --git a/test/public/panelsReliability.test.js b/test/public/panelsReliability.test.js index db5a624..3a02e2c 100644 --- a/test/public/panelsReliability.test.js +++ b/test/public/panelsReliability.test.js @@ -110,7 +110,8 @@ for (const profile of [MINI, FULL]) { const paths = new Set(requests.map((url) => url.pathname)); for (const path of ['/app.js', '/styles.css', '/studio.css', '/cityPicker.js', '/stationPicker.js', '/flash.js', '/api/devices', '/api/runtime-config', '/api/home-assistant/calendars', '/api/home-assistant/todo-lists', '/api/printers', '/api/todo-lists', '/api/devices/panel-a/push']) { - assert.ok(paths.has(prefix + path), path); + const asset = /\.(js|css)$/.test(path) ? `/assets/${appConfig.version}${path}` : path; + assert.ok(paths.has(prefix + asset), path); } for (const url of requests.filter((url) => /\.(js|css)$/.test(url.pathname) || url.pathname.includes('/api/'))) { assert.ok(url.pathname.startsWith(prefix + '/'), url.href); diff --git a/test/public/paths.test.js b/test/public/paths.test.js index a8c43c6..70382ae 100644 --- a/test/public/paths.test.js +++ b/test/public/paths.test.js @@ -39,7 +39,7 @@ test('API, preview, and login navigation all use the central path helper', async const root = fileURLToPath(new URL('../../public/', import.meta.url)); const api = await readFile(`${root}api.js`, 'utf8'); const panels = await readFile(`${root}panels.js`, 'utf8'); - const login = await readFile(`${root}login.html`, 'utf8'); + const login = await readFile(`${root}login.js`, 'utf8'); assert.match(api, /fetch\(appPath\(path\)/); assert.match(api, /location\.href = appPath\('\/login\.html'\)/); assert.match(panels, /src="\$\{panelPreviewUrl\(device\.id\)\}"/); diff --git a/test/public/studioAssets.test.js b/test/public/studioAssets.test.js new file mode 100644 index 0000000..33bd295 --- /dev/null +++ b/test/public/studioAssets.test.js @@ -0,0 +1,159 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import express from 'express'; +import { chromium } from 'playwright'; +import { parse } from 'yaml'; +import { createApp } from '../../src/http/app.ts'; +import { studioAssetBase } from '../../src/http/studioAssets.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; + +const config = parse(await readFile(new URL('../../home-assistant/config.yaml', import.meta.url), 'utf8')); +const release = config.version; +const previous = '0.1.0-ha.10'; + +async function withStudio({ ha = true, prefix = '', mini = true, password = null } = {}, run) { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-assets-')); + const store = new DeviceStore(join(dir, 'config.json')); + const profile = mini ? 'ssd1681-200x200-mono' : 'wft0583-800x480-mono'; + const device = await store.getOrCreate('panel', profile); + await store.update('panel', { claimed: true, dashboardSections: device.dashboardSections.map(() => ({ type: 'weather', version: 1, config: {} })) }); + const frame = { buffer: Buffer.alloc(mini ? 5000 : 48000, 255), etag: 'a'.repeat(32), renderedAt: new Date().toISOString() }; + const deps = { store, frames: { warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, + frameFor: async () => frame, renderNow: async () => frame, enrolmentFrame: async () => frame }, + publicBaseUrl: 'http://panel.test:8080', runtimeState: createRuntimeState(), dataDir: dir, firmwareDir: dir, + auth: { password, secret: randomBytes(32) }, updateMode: ha ? 'home-assistant' : 'self', + homeAssistantClient: new HomeAssistantClient({ enabled: ha, token: 'not-for-browser', fetchImpl: async () => Response.json([]) }), + ...(prefix ? { access: { mode: 'home-assistant-ingress', isTrustedRequest: () => true } } : {}) }; + const current = createApp({ ...deps, homeAssistantRelease: release }); + // The pre-ha.11 document used stable root assets even with a release query. + const old = createApp(deps); + let upgraded = true; + let staleHits = 0; + const router = express.Router(); + // Emulate a cache/proxy retaining old modules despite fresh document queries. + router.get(['/panels.js', `/assets/${previous}/panels.js`], (_req, res, next) => { + if (!ha) return next(); + staleHits++; + res.set('Cache-Control', 'public, max-age=86400').type('js').send('export function setSelectedPanel(){}; export async function renderPanels(root){root.innerHTML="

Old frontend without Sensors

"}'); + }); + router.use((req, res, next) => (upgraded ? current : old)(req, res, next)); + const serverApp = express(); serverApp.use(prefix || '/', router); + const server = serverApp.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const base = `http://127.0.0.1:${server.address().port}${prefix}`; + try { await run({ base, store, setUpgraded: (value) => { upgraded = value; }, staleHits: () => staleHits }); } + finally { await new Promise((resolve) => server.close(resolve)); await rm(dir, { recursive: true, force: true }); } +} + +test('asset release is validated build metadata, not a request-controlled path', () => { + assert.equal(studioAssetBase(), './'); + assert.equal(studioAssetBase(release), `./assets/${release}/`); + assert.notEqual(studioAssetBase(previous), studioAssetBase(release)); + for (const bad of ['', '../secret', 'a/b', 'a\\b', 'a?x=1', 'a#x', '