diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 867c117d..ce570294 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Shell syntax run: | - for script in scripts/proxmox/inkpanel-lxc.sh scripts/proxmox/files/inkpanel-update scripts/build-firmware.sh scripts/compile-mini-firmware.sh scripts/firmware-input-hash.sh; do + for script in scripts/proxmox/inkpanel-lxc.sh scripts/proxmox/files/inkpanel-update scripts/build-firmware.sh scripts/compile-mini-firmware.sh scripts/firmware-input-hash.sh scripts/verify-firmware-package.sh; do echo "checking $script" bash -n "$script" done @@ -86,15 +86,7 @@ jobs: run: bash scripts/compile-mini-firmware.sh - name: Check published full-size and Mini firmware outputs - run: | - test -s firmware/dist/manifest.json - test -s firmware/dist/input.sha256 - test -n "$(find firmware/dist -maxdepth 1 -name '*.bin' -type f -size +0c -print -quit)" - test -s firmware/dist/mini/manifest.json - test -n "$(find firmware/dist/mini -maxdepth 1 -name '*.bin' -type f -size +0c -print -quit)" - test "$(node -e "const m=require('./firmware/dist/manifest.json'); process.stdout.write(m.target)")" = "full" - test "$(node -e "const m=require('./firmware/dist/mini/manifest.json'); process.stdout.write(m.target)")" = "mini" - test "$(cat firmware/dist/input.sha256)" = "$(bash scripts/firmware-input-hash.sh)" + run: bash scripts/verify-firmware-package.sh - name: Check Mini validation package run: | diff --git a/.github/workflows/home-assistant-image.yml b/.github/workflows/home-assistant-image.yml new file mode 100644 index 00000000..a8126c0d --- /dev/null +++ b/.github/workflows/home-assistant-image.yml @@ -0,0 +1,177 @@ +name: Home Assistant App image + +on: + push: + branches: [Home-Assistant] + paths: + - Dockerfile.home-assistant + - home-assistant/** + - repository.yaml + - package*.json + - public/** + - arduino/** + - firmware/** + - scripts/build-firmware.sh + - scripts/compile-mini-firmware.sh + - scripts/firmware-input-hash.sh + - scripts/verify-firmware-package.sh + - 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/** + - arduino/** + - firmware/** + - scripts/build-firmware.sh + - scripts/compile-mini-firmware.sh + - scripts/firmware-input-hash.sh + - scripts/verify-firmware-package.sh + - 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.12 + 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 + + firmware: + name: Build production firmware packages + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Install Arduino CLI + uses: arduino/setup-arduino-cli@v2 + + - name: Cache Arduino ESP32 core + uses: actions/cache@v4 + with: + path: ~/.arduino15 + key: arduino-esp32-${{ runner.os }}-v1 + + - name: Install ESP32 board core + run: | + arduino-cli config init --overwrite --additional-urls https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + arduino-cli core update-index + arduino-cli core install esp32:esp32 + + - name: Build production firmware packages + run: ./scripts/build-firmware.sh + + - name: Verify full-size and Mini packages + run: bash scripts/verify-firmware-package.sh + + - name: Upload production firmware packages + uses: actions/upload-artifact@v4 + with: + name: inkpanel-production-firmware-${{ github.sha }} + path: firmware/dist/ + if-no-files-found: error + retention-days: 1 + + build: + needs: [init, firmware] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.init.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Download production firmware packages + uses: actions/download-artifact@v4 + with: + name: inkpanel-production-firmware-${{ github.sha }} + path: firmware/dist + + - name: Verify firmware build context + run: bash scripts/verify-firmware-package.sh + + - 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' }} + load: ${{ github.event_name == 'pull_request' }} + cosign: ${{ github.event_name == 'push' }} + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify firmware packages in pull-request image + if: github.event_name == 'pull_request' + env: + IMAGE_REF: ${{ matrix.image }}:${{ env.VERSION }} + 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' + 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 }} + + inspect-published: + name: Inspect published Home Assistant App image + if: github.event_name == 'push' + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Pull generic multi-architecture tag anonymously + env: + IMAGE_REF: ghcr.io/ctrlaltcouk/${{ env.IMAGE_NAME }}:${{ env.VERSION }} + run: docker pull "$IMAGE_REF" + + - name: Verify production firmware packages in published image + env: + IMAGE_REF: ghcr.io/ctrlaltcouk/${{ env.IMAGE_NAME }}:${{ env.VERSION }} + 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 new file mode 100644 index 00000000..2d9ad6b6 --- /dev/null +++ b/Dockerfile.home-assistant @@ -0,0 +1,32 @@ +# 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="app" + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY . . + +ENV DATA_DIR=/data \ + INKPANEL_HA_RELEASE=${BUILD_VERSION} \ + 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 new file mode 100644 index 00000000..a1dfc9e7 --- /dev/null +++ b/docs/home-assistant-app.md @@ -0,0 +1,405 @@ +# Home Assistant App architecture + +Status: HA-1/HA-2 are validated, HA-3 is working, and HA-4 Sensors work in the real installation with longer soak/physical testing continuing. ha.11's complete Studio asset versioning is validated through Ingress and LAN. `0.1.0-ha.12` adds HA-5 personal To Do ownership on the experimental `Home-Assistant` branch. PR #31 remains draft and unmerged. + +## HA-5 ownership and administration + +Only Home Assistant To Do becomes user-aware. Calendar, Sensors, local To Do, other sources, device configuration, location and schedules remain shared. Studio stays HA-admin-only with explicit `panel_admin: true`; authenticated LAN Studio is also an administrative surface. A restricted non-admin personal view is a future authorization/redaction milestone, not part of ha.12. + +The trusted Supervisor listener centrally parses validated `X-Remote-User-Id`, `X-Remote-User-Name` and `X-Remote-User-Display-Name`. LAN listeners ignore these headers. User ID is the sole ownership key. Valid observed identities are registered in `/data/.home-assistant-users.json`, a strict atomic mode-0600 version-1 store of safe metadata and unique `todo.*` assignments. Name changes preserve assignments; reused names do not inherit them. Corrupt data remains untouched and personal operations fail closed. + +`GET /api/home-assistant/current-user` distinguishes trusted Ingress from LAN. `GET /api/home-assistant/my-todo-lists` returns only the current Ingress user's assigned identities/names, retaining missing IDs. Admin discovery remains `/api/home-assistant/todo-lists`. Admin mapping management uses `GET /api/home-assistant/users`, `PUT /api/home-assistant/users/:id` with `{todoEntityIds: string[]}`, and `DELETE /api/home-assistant/users/:id` (removes the local mapping, never the HA account). No endpoint here returns task contents or credentials, and no HA user enumeration API is used. + +To Do V3 stores either `{provider:"local",listId}` or `{provider:"home-assistant",ownerUserId,entityId}`. V1/V2 remain unchanged; V2 HA is explicitly legacy shared. New personal selections default to the observed Ingress user; administrators can explicitly choose another observed owner. Only assigned lists appear, and removed IDs remain selected as unavailable rather than being substituted. Personal preferences stay per-panel and do not become household defaults. + +The physical frame uses its fixed V3 owner/entity, validates ownership before live fetch and again after fetch, and fails closed on revocation, reassignment or storage failure. It retains the existing `{items:string[]}` contract and renderer/hash behavior. No firmware, device schema, frozen migrations or display profiles change. See [security boundaries and validation](home-assistant-todo-security.md). + +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 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 + +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 (complete) + +- 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. + +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 and validated on real hardware) + +Extend Calendar configuration with a provider choice: + +- existing iCal URLs; +- Home Assistant calendar entity/entities. + +Studio discovers `calendar.*` entities using authenticated `/api/home-assistant/calendars`. Only entity IDs, friendly names, support/availability and safe errors reach the browser. Select up to ten calendars in the Calendar card and click Save changes. Missing saved IDs remain selected and are labeled missing/unavailable. Standalone exposes only the iCal choice; no long-lived token is required in the App. + +Existing iCal behaviour remains unchanged. + +#### Versioning and remembered drafts + +Calendar V1 remains exactly `{ type: "calendar", version: 1, config: { calendarUrls: [...] } }`. No DeviceStore migration runs. V1 and V2 iCal both use the existing `runCalendars()` path, including its SSRF protections and recurrence expansion. + +Calendar V2 uses a strict provider union: + +```json +{ "type": "calendar", "version": 2, "config": { "provider": "ical", "calendarUrls": ["https://example.com/feed.ics"] } } +{ "type": "calendar", "version": 2, "config": { "provider": "home-assistant", "entityIds": ["calendar.family", "calendar.work"] } } +``` + +Provider fields cannot be mixed. IDs must match `calendar.[a-z0-9_]+`, duplicates are rejected and both lists have a maximum of ten. Studio preserves each widget's version with its configuration: loading V1 or saving unrelated settings never upgrades it. Explicit provider changes produce V2 and keep V2 thereafter. Active > slot > shared > default precedence is unchanged; provider-specific drafts are retained while switching in the editor. Empty provider selections do not replace a useful shared preference. Saved preferences contain IDs/URLs, never Supervisor credentials. + +#### Client, dates and normalization + +The shared server-only `HomeAssistantClient` calls relative `calendars` and `calendars/` paths under the existing `/core/api/` base, using its existing bearer token and timeout handling. IDs are revalidated before requests; redirects are refused and API JSON is runtime-validated. Unknown event attributes, description and location are discarded. Home Assistant owns recurrence expansion; InkPanel does not expand RRULEs for this provider. The endpoints follow the [official HA REST calendar API](https://developers.home-assistant.io/docs/api/rest/#get-apicalendars). + +A bounded four-UTC-day envelope brackets today and tomorrow in the panel's timezone, including extreme UTC offsets and DST transitions. Timed events are classified by their panel-local start date, matching the existing iCal display semantics. Date-only events use their authored dates directly and span each selected date before their exclusive end; they never shift through UTC conversion. The provider returns the existing `CalendarData`/`CalendarEvent` contract. Titles are trimmed with `(no title)` fallback; missing/invalid UIDs get a deterministic entity/start/end/title digest. Stable sorting removes API-order changes. UIDs and unrelated HA metadata do not affect frame hashes. + +#### Cache and failure isolation + +Selected calendars are fetched concurrently and independently through `SourceCache`, source ID `home-assistant-calendar`. Each key includes the device, a non-secret digest of the normalized HA API base (instance), entity ID and bounded query window. App `/data` separates installations. No token is included in the key or data. Changing instance endpoint, entity, device or date window cannot reuse a different logical source's data. A disabled/unconfigured HA client cannot replay HA cache. + +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 ha.12 release image tag is `ghcr.io/ctrlaltcouk/inkpanel-home-assistant:0.1.0-ha.12` for linux/amd64 and linux/arm64 (HA's aarch64). + +#### 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. + +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 (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: + +- `{"type":"todo","version":2,"config":{"provider":"local","listId":"..."}}` +- `{"type":"todo","version":2,"config":{"provider":"home-assistant","entityId":"todo.shopping_list"}}` + +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. + +#### 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. + +#### ha.8 Studio reliability + +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. + +#### ha.9 Ingress entry freshness + +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. + +### Phase HA-4 — Home Assistant Sensors (implemented; awaiting real-world validation) + +The first read-only generic entity-display milestone deliberately supports **only `sensor.*`**. Other domains are future work, not enabled by this release. + +#### Persistence and API boundaries + +```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. + +ha.10 introduced Sensors without changing the asset namespace. ha.11 kept that implementation unchanged and updated 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. ha.12 retains these invariants with its own release namespace. + +#### ha.11 complete frontend asset namespace + +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. +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 four-sensor physical content: + +``` +SENSORS +------------------------ +Living room 21.4 C +Solar 2.8 kW +House 1.3 kW +Battery 78% +``` + +Mini uses the dedicated hero or short-list layout described above. Lock and other non-sensor domains are not supported in HA-4 V1. + +### Future phases — 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; +- `.home-assistant-users.json` observed identities and personal To Do assignments (ha.12); +- 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. + +Repository shape: + +``` +repository.yaml +home-assistant/ + config.yaml + README.md + DOCS.md + CHANGELOG.md + +Dockerfile.home-assistant +src/ +public/ +firmware/ +... +``` + +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 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 + +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. diff --git a/docs/home-assistant-todo-security.md b/docs/home-assistant-todo-security.md new file mode 100644 index 00000000..28a1bc35 --- /dev/null +++ b/docs/home-assistant-todo-security.md @@ -0,0 +1,23 @@ +# HA-5 trust and ownership boundary + +The Supervisor-only listener rejects peers other than `172.30.32.2` (including its IPv4-mapped form) before identity parsing. A single parser reads the documented Ingress user headers, validates bounded/control-free values and returns safe metadata. LAN HTTP/HTTPS ignore these headers. Forwarded addresses cannot bypass the socket-address check. The documented contract is [Home Assistant App security](https://developers.home-assistant.io/docs/apps/security/). + +User IDs, not names, authorize personal To Do. The strict version-1 ownership file contains only `userId`, nullable `username`/`displayName`, and unique `todoEntityIds`. A list cannot belong to two users. Mutations are serialized, validated, written to a fresh mode-0600 temporary file and atomically renamed. Invalid files are retained with a restrictive diagnostic copy; they are never silently reset. Missing users/entities revoke access rather than selecting another list. The process shares one store instance across both listeners and FrameService. + +Administrative discovery/mapping endpoints remain distinct from current-user/scoped-list endpoints. The latter require a valid trusted Ingress ID and never return another user's mapping or task contents. Mapping mutations on LAN require the existing Studio authentication policy. Missing/malformed Ingress identity cannot read personal V3 panel configuration, remembered drafts or previews, push personal frames, or mutate personal configuration/ownership. Ordinary firmware frame requests on LAN do not acquire user context. + +Ingress has no is-admin header. ha.12 therefore explicitly keeps `panel_admin: true`; it does not request Supervisor admin/full-access/docker/auth privileges or enumerate users. All Studio functionality—including previews and device edits—remains administrative/shared. This is an ownership foundation, not a restricted personal portal. Non-admin access needs a later route authorization, preview redaction and mutation-permission design. + +V3 physical frames validate their saved owner/list before requesting tasks and recheck after the live response. Failure returns configured/unavailable with no tasks, no alternative selection and no persistent stale replay. Existing V2 HA widgets deliberately retain their historical shared behavior. Ownership metadata does not enter the pixel hash, so unchanged tasks do not cause unnecessary e-paper refreshes. + +## Real installation validation + +1. Upgrade to ha.12 and open the sidebar normally. Verify the iframe query and assets show `0.1.0-ha.12`; no cache clearing should be required. +2. Confirm Settings identifies the signed-in HA admin and lists only accounts previously observed through trusted Ingress. Assign one distinct personal list to each observed account. Verify another account cannot claim an already-assigned list. +3. Create a new To Do widget: Home Assistant defaults to the current owner and offers only assigned lists. Explicitly choose another known owner and check that the choices change. Save on Mini and full-size panels. +4. Open another browser/account: the saved physical owner/list must not change. Check five-task truncation, ALL DONE and unchanged appearance on both displays. +5. Load an old V2 HA widget, save an unrelated setting and verify it remains legacy shared. Use Make personal, select owner/list and save; only this explicit action creates V3. +6. Rename the same HA account and reopen Ingress: assignments persist. A different account with the same name must start with no assignments. Remove a stale mapping in Settings; this must not delete the HA account. +7. Remove/reassign an ownership mapping or remove the HA entity. The original panel must show unavailable, never another list or old tasks. Restore the assignment/entity to recover. +8. Test authenticated direct LAN Studio: current-user reports LAN with no user, admin mapping management still works, and forged user headers cannot register an account. Firmware continues polling the LAN frame endpoint normally. +9. Verify Calendar, Sensors and other widgets remain shared. Keep the sidebar admin-only; do not enable normal-user Studio access in this release. diff --git a/docs/todo.md b/docs/todo.md index 32529a2a..68d41d20 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -13,3 +13,13 @@ DATA_DIR/.todo-lists.json ``` Include that file in backups alongside `DATA_DIR/config.json`. Restoring only the device configuration without `.todo-lists.json` preserves panel widget selections but not their referenced local task data. + +## Home Assistant personal lists (ha.12) + +The Home Assistant provider is read-only in InkPanel: tasks are edited in HA. Open Studio through HA Ingress to register a user, then assign their `todo.*` lists under **Settings → Home Assistant To Do users → Manage**. Names are labels; the stable HA user ID owns assignments. Administrators can remove stale mappings without deleting HA users. Missing entities remain visible as missing/unavailable until explicitly removed or replaced. + +New HA To Do widgets use V3, with `config: {provider: "home-assistant", ownerUserId: "", entityId: "todo.personal"}`. Choose the owner and an assigned list, then Save changes. The physical panel always uses this saved pair, independently of the browser user. Removing/reassigning ownership stops task fetches and shows the existing unavailable state. No stale task contents are persisted. + +V1 local and V2 local/HA widgets remain readable and unchanged on unrelated saves. Existing V2 HA widgets are labeled **Legacy shared Home Assistant To Do**. **Make personal** explicitly starts conversion; choose an owner/list and save to persist V3. Local V3 uses `{provider: "local", listId: "..."}` and does not introduce ownership for built-in InkPanel lists. + +Back up `DATA_DIR/.home-assistant-users.json` as well as panel configuration. Only HA To Do is user-scoped; Calendar, Sensors and other InkPanel data remain shared. ha.12 Studio remains HA-admin-only. Non-admin access requires a separate future permissions/redaction design. diff --git a/docs/widget-setup-and-remembered-settings.md b/docs/widget-setup-and-remembered-settings.md index e798da1f..1a8aae54 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 new file mode 100644 index 00000000..aea07b20 --- /dev/null +++ b/home-assistant/CHANGELOG.md @@ -0,0 +1,89 @@ +# Changelog + +## 0.1.0-ha.12 + +- Add HA-5 personal Home Assistant To Do ownership keyed only by validated user IDs from the trusted Supervisor Ingress listener. LAN headers cannot forge an identity. +- Persist observed users and unique personal-list assignments in a separate version-1, atomic mode-0600 ownership store; retain corrupt originals and fail closed. +- Add current-user/scoped-list APIs and administrative Settings assignment management without task contents, user enumeration or broader Supervisor privileges. Explicitly retain `panel_admin: true`. +- Add To Do V3 fixed owner/entity configuration. Keep V1/V2 unchanged, label V2 HA as legacy shared, and require explicit Make personal/save conversion. Preserve provider drafts without sharing personal defaults across panels. +- Check ownership before and after live fetch; revoked/reassigned/unavailable ownership never fetches another list or replays stale tasks. Keep existing full-size/Mini To Do pixels and ETags. +- Only HA To Do is user-scoped. Calendar, Sensors, other sources, local To Do and panel configuration remain shared. Non-admin Studio is deferred to a dedicated permissions/redaction milestone. +- Synchronize App, Ingress query and image release at ha.12; retain complete release-versioned assets. No firmware, framebuffer, protocol, profile, DeviceStore version or frozen migration changes. + +## 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- Add Calendar widget V2 provider selection while preserving existing V1/iCal configurations and renderers. +- Preserve widget versions in Studio drafts and remembered settings. +- Normalize panel-local dates and use isolated per-calendar stale caches with deterministic event ordering. +- HA-1 is validated; HA-2 awaits real-world validation. No firmware changes. + +## 0.1.0-ha.4 + +- Let Home Assistant own App updates: remove the standalone updater UI and reject its mutation endpoint. +- Show update ownership in Settings without changing standalone deployments. + +## 0.1.0-ha.3 + +- Include verified full-size and Mini production firmware packages in the App image. +- Make the Home Assistant Ingress WebFlash handoff clearer. + +## 0.1.0-ha.2 + +- Use the current Home Assistant App image label. +- Always move WebFlash from Ingress to the direct secure InkPanel Studio. + +## 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 00000000..045e031f --- /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 00000000..c9a3f792 --- /dev/null +++ b/home-assistant/README.md @@ -0,0 +1,36 @@ +# 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 `0.1.0-ha.12` 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. + +### Personal Home Assistant To Do (HA-5) + +Studio remains Home Assistant-admin-only (`panel_admin: true`). Open it through Ingress to register your HA user ID, then use **Settings → Home Assistant To Do users → Manage** to assign personal lists. In a To Do widget, choose Home Assistant, an owner and one of their assigned lists, then Save changes. Existing V2 HA widgets remain **Legacy shared Home Assistant To Do** until you explicitly choose **Make personal** and save. + +Only HA To Do has ownership. Calendar, Sensors, local InkPanel lists, other data and panel configuration remain household/shared. Physical panels use the owner/list saved in their widget, never the user currently browsing Studio. Revoked or unavailable ownership fails closed without fetching tasks. Back up `/data/.home-assistant-users.json` with device configuration. + +ha.12 keeps the complete release-versioned Studio assets introduced in ha.11 (`/assets/0.1.0-ha.12/`). Normal non-admin personal Studio access requires a future permission/redaction milestone; this release does not broaden Supervisor privileges or enumerate HA accounts. + +The App image includes the verified production WebFlash packages for both the full-size InkPanel and InkPanel Mini. Firmware is built during release CI, never when the App starts. + +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 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. + +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. + +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 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 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 new file mode 100644 index 00000000..947915bf --- /dev/null +++ b/home-assistant/config.yaml @@ -0,0 +1,33 @@ +name: InkPanel +version: 0.1.0-ha.12 +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 +# Supervisor appends this to its trailing-slash Ingress URL. Keep it query-only. +ingress_entry: "?inkpanel_release=0.1.0-ha.12" +panel_admin: true +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 f0105eaa..eefc0107 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 317a0ea9..fa0f4e3b 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 a8961fa7..69d8eb28 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/app.js b/public/app.js index 62fda45a..76b3332f 100644 --- a/public/app.js +++ b/public/app.js @@ -3,7 +3,12 @@ import { renderPanels, setSelectedPanel } from './panels.js'; import { renderSettings, renderUpdates } from './settings.js'; import { renderFlash } from './flash.js'; import { esc } from './components.js'; -import { resolveRouteName } from './router.js'; +import { + fallbackRouteForUpdateMode, + removeManagedUpdateNavigation, + resolveRouteName, + routesForUpdateMode, +} from './router.js'; const view = document.getElementById('view'); const sidebarPanels = document.getElementById('sidebar-panels'); @@ -22,6 +27,8 @@ const SIDEBAR_REFRESH_INTERVAL_MS = 5000; let generation = 0; let shellSelectedPanelId = null; +let updateMode = 'self'; +let availableRoutes = ROUTES; function panelButton(device) { const selected = device.id === shellSelectedPanelId ? ' on' : ''; @@ -96,8 +103,9 @@ window.setInterval(() => { async function route() { const myGeneration = ++generation; - const name = resolveRouteName(location.hash, ROUTES, FALLBACK_ROUTE); - const render = ROUTES[name]; + const fallback = fallbackRouteForUpdateMode(location.hash, updateMode, FALLBACK_ROUTE); + const name = resolveRouteName(location.hash, availableRoutes, fallback); + const render = availableRoutes[name]; document.querySelectorAll('[data-tab]').forEach((tab) => { tab.classList.toggle('on', tab.dataset.tab === name); @@ -125,6 +133,16 @@ window.addEventListener('hashchange', () => { void route(); }); +try { + const runtime = await getJson('/api/runtime-config'); + updateMode = runtime?.updateMode === 'home-assistant' ? 'home-assistant' : 'self'; + availableRoutes = routesForUpdateMode(ROUTES, updateMode); + removeManagedUpdateNavigation(document, updateMode); +} catch { + // A failed capability read must not stop the standalone Studio loading. + // Server-side ownership still prevents a managed update mutation. +} + try { await refreshSidebarPanels(); } catch (err) { diff --git a/public/calendarEditor.js b/public/calendarEditor.js new file mode 100644 index 00000000..80b148e1 --- /dev/null +++ b/public/calendarEditor.js @@ -0,0 +1,31 @@ +import { esc } from './components.js'; +import { switchProviderDraft } from './providerDrafts.js'; + +export function calendarControlsHtml(config, discovery = {}) { + const provider = config.provider ?? 'ical'; + const selector = discovery.supported || provider === 'home-assistant' + ? `` : ''; + if (provider === 'ical') return `${selector}

For Google Calendar, find your Secret address in iCal format. Treat that URL like a password.

`; + const selected = config.entityIds ?? []; + const known = discovery.calendars ?? []; + const missing = selected.filter((id) => !known.some((calendar) => calendar.entityId === id)); + const calendars = [...known, ...missing.map((entityId) => ({ entityId, name: `${entityId} (missing/unavailable)` }))]; + const status = !discovery.available ? '

Home Assistant calendars are unavailable. Saved selections are retained.

' + : known.length === 0 ? '

No Home Assistant calendars found.

' : ''; + return `${selector}${status}
${calendars.map(({ entityId, name }) => ``).join('')}

Select up to 10 calendars, then click Save changes.

`; +} + +export function rememberCalendarConfig(panel, slot) { + const current = slot.drafts.calendar; + if (current.provider === 'home-assistant') { + return { provider: 'home-assistant', entityIds: [...panel.querySelectorAll('[data-ha-calendar]:checked')].map((input) => input.value) }; + } + const calendarUrls = panel.querySelector('[data-calendar-urls]').value.split('\n').map((value) => value.trim()).filter(Boolean); + return slot.versions?.calendar === 2 ? { provider: 'ical', calendarUrls } : { calendarUrls }; +} + +/** Provider switches upgrade explicitly; loading a V1 widget never does. */ +export function switchCalendarProvider(slot, provider) { + if (!['ical', 'home-assistant'].includes(provider)) return; + switchProviderDraft(slot, 'calendar', provider, provider === 'ical' ? { calendarUrls: [] } : { entityIds: [] }); +} diff --git a/public/dashboardEditor.js b/public/dashboardEditor.js index 5675b62a..51a0ae67 100644 --- a/public/dashboardEditor.js +++ b/public/dashboardEditor.js @@ -2,13 +2,18 @@ import { esc } from './components.js'; 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, makeTodoPersonal } 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'; @@ -16,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: '' }; @@ -28,7 +34,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 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(); @@ -39,11 +46,15 @@ export function normalizePrinterUrlValue(value) { } export function createDashboardDraftState(sections, remembered = {}) { + // Apply identical precedence to config and version. Controls edit config; + // generic serializers retain the version belonging to that draft. const shared = widgetsByType(remembered.shared ?? []); const rememberedSlots = remembered.slots ?? [[], [], [], []]; return sections.map((widget, index) => ({ 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 }, + providerDrafts: providerDraftState([...(remembered.shared ?? []), ...(rememberedSlots[index] ?? []), widget]), })); } @@ -52,10 +63,11 @@ export function switchDashboardDraft(slots, index, nextType, currentConfig) { slot.drafts[slot.type] = clone(currentConfig); slot.type = nextType; slot.drafts[nextType] ??= defaultConfig(nextType); + slot.versions[nextType] ??= 1; } export function serialiseDashboardDraftState(slots) { - return slots.map((slot) => ({ type: slot.type, version: 1, config: clone(slot.drafts[slot.type]) })); + return slots.map((slot) => ({ type: slot.type, version: slot.versions[slot.type], config: clone(slot.drafts[slot.type]) })); } export function stationPickerOptions(deviceId, sectionIndex, endpoint, label, value) { @@ -65,7 +77,9 @@ export function stationPickerOptions(deviceId, sectionIndex, endpoint, label, va function rememberCell(cell, slot, type = slot.type) { if (!cell) return; if (type === 'calendar') { - slot.drafts[type] = { calendarUrls: cell.querySelector('[data-calendar-urls]').value.split('\n').map((v) => v.trim()).filter(Boolean) }; + 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 ?? '', @@ -85,7 +99,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] = { @@ -158,32 +172,37 @@ function printerControlsHtml(config, printers, isMini) { `; } -function controlsHtml(type, config, locationLabel, trainConfigured, trainKey, busConfigured, busId, busKey, trafficConfigured, trafficKey, todoLists, printers, isMini) { - if (type === 'calendar') return `

For Google Calendar, find your Secret address in iCal format. Treat that URL like a password.

`; +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')}.

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

This dashboard section will be blank.

'; 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) { +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)}
`; + 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 === 'calendar') return config.calendarUrls?.length ? `${config.calendarUrls.length} calendar${config.calendarUrls.length === 1 ? '' : 's'} connected` : 'Not set up'; + 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'; 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'; @@ -421,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); + 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; @@ -429,7 +448,26 @@ function renderEditor(root) { switchDashboardDraft(state.slots, index, event.target.value, slot.drafts[previous]); renderLayout(root); renderEditor(root); }); - if (slot.type === 'trains') { + 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); + renderLayout(root); renderEditor(root); markDashboardChanged(root); + }); + panel.querySelectorAll('[data-ha-calendar]').forEach((input) => input.addEventListener('change', () => { + const tooMany = panel.querySelectorAll('[data-ha-calendar]:checked').length > 10; + if (tooMany) input.checked = false; + const error = panel.querySelector('[data-calendar-error]'); + error.textContent = tooMany ? 'Select at most 10 calendars.' : ''; + error.hidden = !tooMany; + rememberCell(panel, slot); renderLayout(root); + })); + } else if (slot.type === 'trains') { panel.querySelector('[data-train-api-key]').addEventListener('input', (e) => { state.trainApiKeyDraft = e.currentTarget.value; }); renderStationPicker(panel.querySelector('[data-station="origin"]'), stationPickerOptions(state.deviceId, index, 'origin', 'From', config.originCrs ?? '')); renderStationPicker(panel.querySelector('[data-station="destination"]'), stationPickerOptions(state.deviceId, index, 'destination', 'To', config.destinationCrs ?? '')); @@ -440,16 +478,33 @@ 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, state.haTodos); + renderLayout(root); renderEditor(root); markDashboardChanged(root); + }); + if (config.provider === 'home-assistant') { + panel.querySelector('[data-todo-make-personal]')?.addEventListener('click', () => { + makeTodoPersonal(slot, state.haTodos); + renderLayout(root); renderEditor(root); markDashboardChanged(root); + }); + panel.querySelector('[data-ha-todo-owner]')?.addEventListener('change', (event) => { + slot.drafts.todo = { provider: 'home-assistant', ownerUserId: event.target.value, entityId: '' }; + renderLayout(root); renderEditor(root); markDashboardChanged(root); + }); + 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 = []) { +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) }); + 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); } @@ -465,7 +520,11 @@ export function collectRememberedDashboardSettings(root) { const state = stateByRoot.get(root); if (!state) throw new Error('dashboard editor is not initialised'); syncCurrent(root, state); - return { slots: state.slots.map((slot) => Object.entries(slot.drafts).map(([type, config]) => ({ type, version: 1, config: clone(config) }))) }; + return serialiseRememberedDashboardDrafts(state.slots); +} + +export function serialiseRememberedDashboardDrafts(slots) { + return { slots: slots.map(rememberedProviderDrafts) }; } export function collectTrainApiKey(root) { diff --git a/public/entitiesEditor.js b/public/entitiesEditor.js new file mode 100644 index 00000000..43d21ead --- /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/flash.js b/public/flash.js index 81b4ccc4..b0cf7576 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,19 +28,48 @@ 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) { +export function directWebFlashNotice(webFlashUrl) { + const directUrl = safeWebFlashUrl(webFlashUrl); + const link = directUrl + ? `

Open WebFlash

` + : `

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

`; + return `
+

WebFlash opens in a secure window

+

USB flashing needs a direct secure InkPanel connection outside Home Assistant.

+ ${link} +

Your browser may show the local certificate warning the first time.

+
`; +} + +export function unsupportedNotice(httpsPort, webFlashUrl = null) { + const directUrl = safeWebFlashUrl(webFlashUrl); if (window.isSecureContext === false && looksLikeChromiumFamily()) { 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.

`; return `

Flashing needs a secure connection

@@ -73,7 +103,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()); @@ -428,20 +458,28 @@ function newBoardConfigFromUi(root) { } export async function renderFlash(root) { - if (!serialSupported()) { + let runtime = null; + try { + runtime = await getJson('/api/runtime-config'); + } catch { + // Standalone flashing can continue when already on direct HTTPS. On HTTP, + // the notice below explains that no secure URL could be determined. + } + + if (runtime?.accessMode === 'home-assistant-ingress') { + root.innerHTML = directWebFlashNotice(runtime.webFlashUrl); + return; + } + + const insecureContext = globalThis.window?.isSecureContext === false; + if (!serialSupported() || insecureContext) { let httpsPort; - if (window.isSecureContext === false && looksLikeChromiumFamily()) { - try { - const runtime = await getJson('/api/runtime-config'); - if (Number.isInteger(runtime?.httpsPort) && - runtime.httpsPort >= 1 && runtime.httpsPort <= 65535) { - httpsPort = runtime.httpsPort; - } - } catch { - // The notice below explains that no secure URL could be determined. - } + if (Number.isInteger(runtime?.httpsPort) && + runtime.httpsPort >= 1 && runtime.httpsPort <= 65535) { + httpsPort = runtime.httpsPort; } - root.innerHTML = unsupportedNotice(httpsPort); + const webFlashUrl = insecureContext ? safeWebFlashUrl(runtime?.webFlashUrl) : null; + root.innerHTML = unsupportedNotice(httpsPort, webFlashUrl); return; } diff --git a/public/homeAssistantUsers.js b/public/homeAssistantUsers.js new file mode 100644 index 00000000..852b88da --- /dev/null +++ b/public/homeAssistantUsers.js @@ -0,0 +1,44 @@ +import { getJson, sendJson } from './api.js'; +import { esc } from './components.js'; + +export function homeAssistantUsersHtml(users, lists, currentUser) { + return `

Home Assistant To Do users

+

Personal To Do lists can be assigned to Home Assistant users. Other InkPanel data remains shared.

+ ${currentUser ? `

Signed in through Home Assistant as ${esc(currentUser.displayName || currentUser.username || currentUser.id)}.

` : ''} + ${!users.length ? '

Open InkPanel through Home Assistant Ingress using the account you want to register.

' : ''} + ${users.map((user) => { + const choices = [...lists]; + for (const entityId of user.todoEntityIds) if (!choices.some((list) => list.entityId === entityId)) choices.push({ entityId, name: 'Missing/unavailable' }); + return `
${esc(user.displayName || user.username || user.userId)} — Manage +

Home Assistant user · ${esc(user.userId)}

${user.todoEntityIds.length ? `${user.todoEntityIds.length} To Do list(s) assigned` : 'No personal To Do lists assigned.'}

+

Personal To Do lists for ${esc(user.displayName || user.username || user.userId)}

+ ${choices.map((list) => { + const elsewhere = users.some((other) => other.userId !== user.userId && other.todoEntityIds.includes(list.entityId)); + return ``; + }).join('')} +
+
`; + }).join('')}
`; +} + +export async function renderHomeAssistantUsers(root) { + try { + // Registration must finish before the admin list is read. + const identity = await getJson('/api/home-assistant/current-user'); + const [{ users }, discovery] = await Promise.all([getJson('/api/home-assistant/users'), getJson('/api/home-assistant/todo-lists')]); + root.innerHTML = homeAssistantUsersHtml(users, discovery.lists, identity.user); + for (const card of root.querySelectorAll('[data-ha-user]')) { + const act = async (button, action) => { + button.disabled = true; + try { await action(); await renderHomeAssistantUsers(root); } + catch (error) { const message = card.querySelector('[data-user-error]'); message.textContent = error.message; message.hidden = false; button.disabled = false; } + }; + card.querySelector('[data-save-assignments]').addEventListener('click', (event) => act(event.currentTarget, () => + sendJson('PUT', `/api/home-assistant/users/${encodeURIComponent(card.dataset.haUser)}`, { todoEntityIds: [...card.querySelectorAll('input:checked')].map((input) => input.value) }))); + card.querySelector('[data-remove-user]').addEventListener('click', (event) => { + if (!confirm('Remove this InkPanel mapping and its assignments? This does not delete the Home Assistant user.')) return; + act(event.currentTarget, () => sendJson('DELETE', `/api/home-assistant/users/${encodeURIComponent(card.dataset.haUser)}`)); + }); + } + } catch { root.innerHTML = '

Home Assistant To Do users

Home Assistant ownership is unavailable. Existing mappings have not been changed.

'; } +} diff --git a/public/index.html b/public/index.html index 14fba4fe..823f2c32 100644 --- a/public/index.html +++ b/public/index.html @@ -32,7 +32,7 @@ diff --git a/public/login.html b/public/login.html index 5d624cb9..7bc2b293 100644 --- a/public/login.html +++ b/public/login.html @@ -15,28 +15,6 @@

inkpanel

- + diff --git a/public/login.js b/public/login.js new file mode 100644 index 00000000..843399c8 --- /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/panels.js b/public/panels.js index c0cf9bc8..ea7bb3ae 100644 --- a/public/panels.js +++ b/public/panels.js @@ -1,4 +1,5 @@ import { getJson, sendJson } from './api.js'; +import { appPath } from './paths.js'; import { esc, formatRelative, formatVolts, field, pill } from './components.js'; import { collectBusApiCredentials, @@ -12,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; } @@ -53,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
@@ -136,7 +143,7 @@ function pushMessage(result) { export function refreshPanelPreview(root, deviceId) { const img = root.querySelector('.panel-preview-image'); - if (img) img.src = `/api/devices/${encodeURIComponent(deviceId)}/render.png?t=${Date.now()}`; + if (img) img.src = panelPreviewUrl(deviceId); } export function bindTodoPreviewRefresh(editor, root, deviceId) { @@ -203,12 +210,33 @@ 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); + 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 }] = await Promise.all([getJson('/api/devices'), getJson('/api/national-rail'), getJson('/api/transportapi'), getJson('/api/google-maps'), getJson('/api/todo-lists'), getJson('/api/printers')]); + 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 }; + if (supported) { + const identity = await getJson('/api/home-assistant/current-user').catch(() => ({ available: false, user: null })); + const [mappings, personal] = await Promise.all([ + getJson('/api/home-assistant/users').catch(() => ({ users: [] })), + identity.available ? getJson('/api/home-assistant/my-todo-lists').catch(() => ({ lists: [] })) : Promise.resolve({ lists: [] }), + ]); + Object.assign(haTodos, { personalSupported: true, currentUser: identity.user, users: mappings.users, personalLists: personal.lists }); + } + 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 }); + await renderDetail(root, devices.find((d) => d.id === selectedId), { trainApi, busApi, trafficApi, todoLists, printers, haCalendars, haTodos, haSensors }); } diff --git a/public/paths.js b/public/paths.js new file mode 100644 index 00000000..85de77a6 --- /dev/null +++ b/public/paths.js @@ -0,0 +1,17 @@ +/** Browser base path for standalone `/` or a Home Assistant Ingress prefix. */ +function currentPathname() { + return globalThis.location?.pathname ?? globalThis.window?.location?.pathname ?? '/'; +} + +export function browserBasePath(pathname = currentPathname()) { + if (!pathname.startsWith('/')) return '/'; + if (pathname.endsWith('/')) return pathname; + const slash = pathname.lastIndexOf('/'); + return `${pathname.slice(0, slash + 1)}`; +} + +/** Resolve an application-root path without escaping an Ingress prefix. */ +export function appPath(path, pathname = currentPathname()) { + const relative = String(path).replace(/^\/+/, ''); + return `${browserBasePath(pathname)}${relative}`; +} diff --git a/public/privacy.html b/public/privacy.html index b32c8dae..3fb574d4 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -21,7 +21,7 @@

Traffic widget

Other optional data sources

When you configure other widgets, InkPanel sends only the information needed to request those services, such as a location for weather, a bus stop code for bus departures, station codes for rail departures, or calendar requests to the calendar URLs you provide.

-

Back to InkPanel · Terms of Use

+

Back to InkPanel · Terms of Use

diff --git a/public/providerDrafts.js b/public/providerDrafts.js new file mode 100644 index 00000000..e22ba80a --- /dev/null +++ b/public/providerDrafts.js @@ -0,0 +1,33 @@ +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)), + ]).filter((widget) => widget.type !== 'todo' || widget.version !== 3 + || widget.config.provider !== 'home-assistant' || (widget.config.ownerUserId && widget.config.entityId)); +} diff --git a/public/router.js b/public/router.js index 10550740..2e8df146 100644 --- a/public/router.js +++ b/public/router.js @@ -11,3 +11,17 @@ export function resolveRouteName(hash, routes, fallbackName) { const requested = (hash || '').replace(/^#/, '') || fallbackName; return Object.prototype.hasOwnProperty.call(routes, requested) ? requested : fallbackName; } + +export function routesForUpdateMode(routes, updateMode) { + if (updateMode !== 'home-assistant') return routes; + const { updates: _updates, ...availableRoutes } = routes; + return availableRoutes; +} + +export function fallbackRouteForUpdateMode(hash, updateMode, fallbackName = 'panels') { + return updateMode === 'home-assistant' && hash === '#updates' ? 'settings' : fallbackName; +} + +export function removeManagedUpdateNavigation(root, updateMode) { + if (updateMode === 'home-assistant') root.querySelector('[data-tab="updates"]')?.remove(); +} diff --git a/public/settings.js b/public/settings.js index 86cb6586..525a6bfa 100644 --- a/public/settings.js +++ b/public/settings.js @@ -1,5 +1,6 @@ import { getJson, sendJson } from './api.js'; import { esc } from './components.js'; +import { renderHomeAssistantUsers } from './homeAssistantUsers.js'; const POLL_MS = 2000; const GIVE_UP_MS = 3 * 60 * 1000; @@ -36,7 +37,25 @@ function sourcesSection(sources) { return `

${sourcesLine(sources)}

${items ? `
    ${items}
` : ''}`; } -function settingsView(info) { +function homeAssistantSection(status) { + if (status?.mode !== 'home-assistant-app') { + return `

Home Assistant

Not running as a Home Assistant App.

`; + } + if (!status.available) { + return `

Home Assistant

Unavailable
+

${esc(status.error || 'Could not reach the Home Assistant Core API.')}

+

Updates are managed by Home Assistant.

`; + } + return `

Home Assistant

+ Connected + Core ${esc(status.version)} + ${esc(status.locationName)} + ${esc(status.timeZone)} +
+

Updates are managed by Home Assistant.

`; +} + +export function settingsView(info, homeAssistantStatus) { return `

Server

InkPanel runtime and source health.

@@ -47,6 +66,7 @@ function settingsView(info) {

Sources

${sourcesSection(info.sources)} + ${homeAssistantSection(homeAssistantStatus)}
`; } @@ -100,8 +120,16 @@ async function pollUntilDone(log, requestedAt) { } export async function renderSettings(root, { refresh = false } = {}) { - const info = await getJson(`/api/system/info${refresh ? '?refresh=1' : ''}`); - root.innerHTML = settingsView(info); + const [info, homeAssistantStatus] = await Promise.all([ + getJson(`/api/system/info${refresh ? '?refresh=1' : ''}`), + getJson('/api/home-assistant/status'), + ]); + root.innerHTML = settingsView(info, homeAssistantStatus); + if (homeAssistantStatus?.mode === 'home-assistant-app') { + const ownership = document.createElement('div'); + root.append(ownership); + await renderHomeAssistantUsers(ownership); + } root.querySelector('#recheck').addEventListener('click', async () => { root.innerHTML = '

Checking…

'; try { diff --git a/public/studio.css b/public/studio.css index 895304a0..cee646f6 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/public/styles.css b/public/styles.css index deba0f23..4e4cfeb0 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; @@ -138,7 +138,9 @@ textarea:focus { accent-color: var(--brand-pink); } -button { +button, +.button-link { + display: inline-block; background: var(--brand-pink); color: var(--bg-0); border: 0; @@ -152,8 +154,11 @@ button { margin-top: var(--sp-5); } -button:hover { background: var(--brand-pink-hover); } -button:active { background: var(--brand-pink-press); } +button:hover, +.button-link:hover { background: var(--brand-pink-hover); } +button:active, +.button-link:active { background: var(--brand-pink-press); } +.button-link { text-decoration: none; } .meta { min-width: 0; diff --git a/public/terms.html b/public/terms.html index 866cde21..fb0fb33c 100644 --- a/public/terms.html +++ b/public/terms.html @@ -21,7 +21,7 @@

Google Maps Platform

Other providers

Other optional InkPanel widgets may use third-party services such as TransportAPI, Rail Data Marketplace/National Rail, Open-Meteo and calendar providers. Their data and services remain subject to their own terms and licences.

-

Back to InkPanel · Privacy Policy

+

Back to InkPanel · Privacy Policy

diff --git a/public/todoEditor.js b/public/todoEditor.js new file mode 100644 index 00000000..33343778 --- /dev/null +++ b/public/todoEditor.js @@ -0,0 +1,56 @@ +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 personal = 'ownerUserId' in config; + const users = discovery.users ?? []; + const owner = users.find((user) => user.userId === config.ownerUserId); + const candidates = personal && config.ownerUserId === discovery.currentUser?.id && discovery.personalLists + ? discovery.personalLists : discovery.lists ?? []; + const known = personal ? candidates.filter((list) => owner?.todoEntityIds.includes(list.entityId)) : candidates; + const lists = [...known]; + if (personal) for (const entityId of owner?.todoEntityIds ?? []) { + if (!lists.some((list) => list.entityId === entityId)) lists.push({ entityId, name: `${entityId} (missing/unavailable)` }); + } + if (config.entityId && !lists.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.' + : !personal && known.length === 0 ? 'No Home Assistant To Do lists found.' : ''; + const ownerChoices = [...users]; + if (personal && config.ownerUserId && !owner) ownerChoices.push({ userId: config.ownerUserId, displayName: 'Missing/unavailable owner' }); + const ownership = personal + ? `

This panel always displays the saved owner's list, regardless of who opens Studio.

${!owner?.todoEntityIds.length ? '

No personal To Do lists assigned. Manage assignments in Settings.

' : ''}` + : `

Legacy shared Home Assistant To Do

${discovery.personalSupported ? '' : ''}`; + return `${ownership}${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', ...('ownerUserId' in slot.drafts.todo + ? { ownerUserId: panel.querySelector('[data-ha-todo-owner]')?.value ?? slot.drafts.todo.ownerUserId } : {}), + 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 makeTodoPersonal(slot, discovery = {}) { + slot.versions.todo = 3; + // Explicit conversion never guesses an assignment from the legacy entity/name. + slot.drafts.todo = { provider: 'home-assistant', ownerUserId: discovery.currentUser?.id ?? '', entityId: '' }; +} + +export function switchTodoProvider(slot, provider, discovery = {}) { + if (!['local', 'home-assistant'].includes(provider)) return; + const remembered = slot.providerDrafts?.todo?.[provider]; + switchProviderDraft(slot, 'todo', provider, provider === 'local' ? { listId: '' } : { entityId: '' }); + if (remembered?.version === 3) slot.versions.todo = 3; + else if (provider === 'home-assistant' && !remembered && discovery.personalSupported) makeTodoPersonal(slot, discovery); +} diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 00000000..5683117b --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: InkPanel Apps +url: https://github.com/CtrlAltcouk/inkpanel +maintainer: CtrlAlt diff --git a/scripts/home-assistant-start.mjs b/scripts/home-assistant-start.mjs new file mode 100644 index 00000000..83f69bae --- /dev/null +++ b/scripts/home-assistant-start.mjs @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function normalizePanelBaseUrl(value) { + const raw = typeof value === 'string' ? value.trim() : ''; + let url; + try { url = new URL(raw); } catch { throw new Error('panel_base_url must be a valid HTTP or HTTPS URL'); } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('panel_base_url must use HTTP or HTTPS'); + } + if (url.username || url.password) throw new Error('panel_base_url must not contain credentials'); + if (!url.hostname || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) { + throw new Error('panel_base_url must be a LAN origin without a path, query, or fragment'); + } + return url.toString().replace(/\/$/, ''); +} + +export function runtimeEnvironment(options, inherited = process.env) { + const lanPassword = typeof options?.lan_password === 'string' ? options.lan_password.trim() : ''; + if (!lanPassword) throw new Error('lan_password is required'); + return { + ...inherited, + DATA_DIR: '/data', + PORT: '8080', + PUBLIC_BASE_URL: normalizePanelBaseUrl(options?.panel_base_url), + INKPANEL_PASSWORD: lanPassword, + HTTPS_PORT: '8443', + HOME_ASSISTANT_MODE: '1', + HOME_ASSISTANT_INGRESS_PORT: '8099', + HOME_ASSISTANT_BASE_URL: 'http://supervisor/core/api', + }; +} + +export async function main() { + const options = JSON.parse(await readFile('/data/options.json', 'utf8')); + const child = spawn(process.execPath, ['--import', 'tsx', 'src/index.ts'], { + cwd: '/app', + env: runtimeEnvironment(options), + stdio: 'inherit', + }); + for (const signal of ['SIGTERM', 'SIGINT']) { + process.on(signal, () => child.kill(signal)); + } + child.on('exit', (code, signal) => process.exitCode = signal ? 1 : (code ?? 1)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/verify-firmware-package.sh b/scripts/verify-firmware-package.sh new file mode 100644 index 00000000..7d24a235 --- /dev/null +++ b/scripts/verify-firmware-package.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +DIST_DIR="${1:-$REPO_DIR/firmware/dist}" +EXPECTED_INPUT_HASH="${2:-}" + +test -s "$DIST_DIR/manifest.json" +test -s "$DIST_DIR/input.sha256" +test -n "$(find "$DIST_DIR" -maxdepth 1 -name '*.bin' -type f -size +0c -print -quit)" +test -s "$DIST_DIR/mini/manifest.json" +test -n "$(find "$DIST_DIR/mini" -maxdepth 1 -name '*.bin' -type f -size +0c -print -quit)" +test "$(node -e "const fs=require('node:fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(m.target)" "$DIST_DIR/manifest.json")" = "full" +test "$(node -e "const fs=require('node:fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(m.target)" "$DIST_DIR/mini/manifest.json")" = "mini" + +if [[ -z "$EXPECTED_INPUT_HASH" ]]; then + EXPECTED_INPUT_HASH="$(bash "$SCRIPT_DIR/firmware-input-hash.sh")" +fi +test "$(cat "$DIST_DIR/input.sha256")" = "$EXPECTED_INPUT_HASH" diff --git a/src/devices/store.ts b/src/devices/store.ts index 9efc448b..3d9c0c93 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/calendarSchemas.ts b/src/homeAssistant/calendarSchemas.ts new file mode 100644 index 00000000..b337a630 --- /dev/null +++ b/src/homeAssistant/calendarSchemas.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +/** A calendar entity ID is data, never a URL or a relative REST path. */ +export const calendarEntityIdSchema = z.string().max(255).regex(/^calendar\.[a-z0-9_]+$/, 'invalid Home Assistant calendar entity ID'); +export const calendarEntityIdsSchema = z.array(calendarEntityIdSchema).max(10) + .refine((ids) => new Set(ids).size === ids.length, 'calendar entity IDs must be unique'); + +export const homeAssistantCalendarListSchema = z.array(z.object({ + entity_id: calendarEntityIdSchema, + name: z.string().trim().min(1).max(255), +})); + +const date = z.iso.date(); +const dateTime = z.iso.datetime({ offset: true }); +const details = { + summary: z.string().optional(), + uid: z.preprocess((value) => typeof value === 'string' && value.trim().length <= 1024 + ? value.trim() || undefined : undefined, z.string().optional()), +}; +// Unknown optional HA metadata is stripped, never cached or sent to a renderer. +export const homeAssistantCalendarEventSchema = z.union([ + z.object({ ...details, start: z.object({ date, dateTime: z.never().optional() }), end: z.object({ date, dateTime: z.never().optional() }) }) + .refine((event) => event.end.date > event.start.date, 'invalid all-day event range'), + z.object({ ...details, start: z.object({ dateTime, date: z.never().optional() }), end: z.object({ dateTime, date: z.never().optional() }) }) + .refine((event) => Date.parse(event.end.dateTime) >= Date.parse(event.start.dateTime), 'invalid timed event range'), +]); +export const homeAssistantCalendarEventsSchema = z.array(homeAssistantCalendarEventSchema); +export type HomeAssistantCalendarEvent = z.infer; diff --git a/src/homeAssistant/client.ts b/src/homeAssistant/client.ts new file mode 100644 index 00000000..1c0488a8 --- /dev/null +++ b/src/homeAssistant/client.ts @@ -0,0 +1,252 @@ +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 { sensorEntityIdSchema, homeAssistantSensorStateSchema, homeAssistantSensorsSchema, type HomeAssistantSensorState } from './sensorSchemas.ts'; +import { + calendarEntityIdSchema, homeAssistantCalendarListSchema, homeAssistantCalendarEventsSchema, + type HomeAssistantCalendarEvent, +} from './calendarSchemas.ts'; + +export type HomeAssistantResult = { available: true; data: T } | { available: false; error: string }; +export interface HomeAssistantCalendarDiscovery { + supported: boolean; + available: boolean; + calendars: Array<{ entityId: string; name: string }>; + error: string | null; +} + +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 HomeAssistantSensorDiscovery { + supported: boolean; + available: boolean; + entities: Array>; + error: string | null; +} + +export interface HomeAssistantStatus { + available: boolean; + mode: HomeAssistantMode; + version: string | null; + locationName: string | null; + timeZone: string | null; + error: string | null; +} + +export interface HomeAssistantClientOptions { + enabled: boolean; + baseUrl?: string; + token?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +const configSchema = z.object({ + version: z.string().min(1), + location_name: z.string().min(1), + 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, + mode: 'standalone', + version: null, + locationName: null, + timeZone: null, + error: null, + }; +} + +function unavailable(error: string): HomeAssistantStatus { + return { + available: false, + mode: 'home-assistant-app', + version: null, + locationName: null, + timeZone: null, + error, + }; +} + +function normalizeBaseUrl(value: string): URL { + const url = new URL(value.endsWith('/') ? value : `${value}/`); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Home Assistant base URL must use HTTP or HTTPS'); + } + if (url.username || url.password) { + throw new Error('Home Assistant base URL must not contain credentials'); + } + return url; +} + +/** Shared, server-only client for the Supervisor-proxied Home Assistant API. */ +export class HomeAssistantClient { + private readonly enabled: boolean; + private readonly baseUrl: URL | null; + private readonly configurationError: string | null; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + + constructor(options: HomeAssistantClientOptions) { + this.enabled = options.enabled; + this.configurationError = null; + try { + this.baseUrl = normalizeBaseUrl( + options.enabled ? (options.baseUrl ?? 'http://supervisor/core/api/') : 'http://supervisor/core/api/', + ); + } catch { + this.baseUrl = null; + this.configurationError = 'Home Assistant base URL is invalid'; + } + this.token = options.token?.trim() ?? ''; + this.fetchImpl = options.fetchImpl ?? fetch; + this.timeoutMs = options.timeoutMs ?? 5000; + } + + /** Instance identity only; never credentials. A disabled/unconfigured client cannot replay HA cache. */ + get calendarCacheScope(): string | null { + return this.enabled && this.baseUrl && this.token + ? createHash('sha256').update(this.baseUrl.href).digest('hex') : null; + } + + 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' }; + const timeout = AbortSignal.timeout(this.timeoutMs); + const signal = externalSignal ? AbortSignal.any([externalSignal, timeout]) : timeout; + try { + const response = await this.fetchImpl(new URL(path, this.baseUrl), { + 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})` }; + const parsed = schema.safeParse(await response.json()); + if (!parsed.success) return { available: false, error: `Home Assistant returned an invalid ${label} response` }; + return { available: true, data: parsed.data }; + } catch { + if (externalSignal?.aborted) return { available: false, error: 'Home Assistant request was cancelled' }; + if (timeout.aborted) return { available: false, error: 'Home Assistant request timed out' }; + return { available: false, error: 'Home Assistant is unavailable' }; + } + } + + async status(externalSignal?: AbortSignal): Promise { + if (!this.enabled) return standaloneStatus(); + const result = await this.request('config', configSchema, 'config', externalSignal); + if (!result.available) return unavailable(result.error); + return { + available: true, mode: 'home-assistant-app', version: result.data.version, + locationName: result.data.location_name, timeZone: result.data.time_zone, error: null, + }; + } + + 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 { + supported: this.enabled, available: result.available, + calendars: result.available + ? [...new Map(result.data.map((calendar) => [calendar.entity_id, { + entityId: calendar.entity_id, name: calendar.name, + }])).values()].sort((a, b) => a.name.localeCompare(b.name) || a.entityId.localeCompare(b.entityId)) + : [], + error: result.available || !this.enabled ? null : result.error, + }; + } + + async getCalendarEvents(entityId: string, start: string, end: string, signal?: AbortSignal): Promise> { + if (!calendarEntityIdSchema.safeParse(entityId).success) { + return { available: false, error: 'invalid Home Assistant calendar entity ID' }; + } + const timestamps = z.iso.datetime({ offset: true }); + if (!timestamps.safeParse(start).success || !timestamps.safeParse(end).success || Date.parse(end) <= Date.parse(start)) { + return { available: false, error: 'invalid Home Assistant calendar time range' }; + } + 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' } }); + } + + 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 { + return raw?.trim() === '1'; +} diff --git a/src/homeAssistant/enrolment.ts b/src/homeAssistant/enrolment.ts new file mode 100644 index 00000000..a6854a40 --- /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/homeAssistant/ingressUser.ts b/src/homeAssistant/ingressUser.ts new file mode 100644 index 00000000..1ff05c0a --- /dev/null +++ b/src/homeAssistant/ingressUser.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from 'express'; +import type { DashboardWidget } from '../widgets/registry.ts'; +import { z } from 'zod'; + +const noControls = /^[^\x00-\x1f\x7f-\x9f]*$/; +export const homeAssistantUserIdSchema = z.string().min(1).max(128).regex(noControls) + .refine((value) => value === value.trim(), 'invalid user ID'); +const nameSchema = z.string().max(256).regex(noControls).transform((value) => value.trim() || null); +export const homeAssistantUserSchema = z.strictObject({ + id: homeAssistantUserIdSchema, + username: nameSchema.nullable(), + displayName: nameSchema.nullable(), +}); +export type HomeAssistantIngressUser = z.infer; + +/** Call only with the result of the listener's Supervisor-address gate. LAN + * headers are deliberately never parsed, including on firmware requests. */ +export function parseIngressUser(req: Pick, trustedIngress: boolean): HomeAssistantIngressUser | null { + if (!trustedIngress) return null; + const parsed = homeAssistantUserSchema.safeParse({ + id: req.headers['x-remote-user-id'], + username: req.headers['x-remote-user-name'] ?? null, + displayName: req.headers['x-remote-user-display-name'] ?? null, + }); + return parsed.success ? parsed.data : null; +} + +/** LAN remains the existing admin/firmware surface. Personal Ingress reads and + * writes must have an identity, even when the proxy address is trusted. */ +export function authorizePersonalTodoAccess(widgets: readonly DashboardWidget[], res: Response): boolean { + if (res.locals.homeAssistantIngress && !res.locals.homeAssistantUser + && widgets.some((widget) => widget.type === 'todo' && 'ownerUserId' in widget.config)) { + res.status(403).json({ error: 'Valid trusted Home Assistant user identity required' }); + return false; + } + return true; +} diff --git a/src/homeAssistant/sensorSchemas.ts b/src/homeAssistant/sensorSchemas.ts new file mode 100644 index 00000000..30abab71 --- /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/homeAssistant/todoSchemas.ts b/src/homeAssistant/todoSchemas.ts new file mode 100644 index 00000000..03868267 --- /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/homeAssistant/userStore.ts b/src/homeAssistant/userStore.ts new file mode 100644 index 00000000..1667c218 --- /dev/null +++ b/src/homeAssistant/userStore.ts @@ -0,0 +1,94 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { z } from 'zod'; +import { homeAssistantUserSchema, homeAssistantUserIdSchema, type HomeAssistantIngressUser } from './ingressUser.ts'; +import { todoEntityIdSchema } from './todoSchemas.ts'; + +export const todoAssignmentsSchema = z.array(todoEntityIdSchema).max(100) + .refine((ids) => new Set(ids).size === ids.length, 'duplicate assignments'); +const userSchema = z.strictObject({ + userId: homeAssistantUserIdSchema, + username: homeAssistantUserSchema.shape.username, + displayName: homeAssistantUserSchema.shape.displayName, + todoEntityIds: todoAssignmentsSchema, +}); +export const homeAssistantUsersV1Schema = z.strictObject({ version: z.literal(1), users: z.array(userSchema).max(500) }) + .refine(({ users }) => new Set(users.map((user) => user.userId)).size === users.length, 'duplicate user IDs') + .refine(({ users }) => { + const ids = users.flatMap((user) => user.todoEntityIds); + return new Set(ids).size === ids.length; + }, 'a personal list can have only one owner'); +type UsersFile = z.infer; +export class HomeAssistantUserStoreError extends Error { + constructor(readonly code: 'users_corrupt' | 'users_io' | 'users_invalid' | 'users_not_found', message: string) { super(message); } +} +const errno = (error: unknown) => (error as NodeJS.ErrnoException)?.code; + +/** Deployment-local mappings only: no task contents, credentials or user-name authorization. */ +export class HomeAssistantUserStore { + private queue: Promise = Promise.resolve(); + constructor(private readonly path: string) {} + private async read(): Promise { + let raw: Buffer; + try { raw = await readFile(this.path); } + catch (error) { + if (errno(error) === 'ENOENT') return { version: 1, users: [] }; + throw new HomeAssistantUserStoreError('users_io', 'Home Assistant ownership storage is unavailable'); + } + try { return homeAssistantUsersV1Schema.parse(JSON.parse(raw.toString('utf8'))); } + catch { + const backup = `${this.path}.corrupt-${createHash('sha256').update(raw).digest('hex').slice(0, 16)}`; + await writeFile(backup, raw, { mode: 0o600, flag: 'wx' }).catch(() => undefined); + throw new HomeAssistantUserStoreError('users_corrupt', 'Home Assistant ownership storage is invalid; original left untouched'); + } + } + private mutate(fn: (file: UsersFile) => void): Promise { + const result = this.queue.then(async () => { + const file = await this.read(); + const before = JSON.stringify(file); + fn(file); + const parsed = homeAssistantUsersV1Schema.safeParse(file); + if (!parsed.success) throw new HomeAssistantUserStoreError('users_invalid', 'Invalid or duplicate Home Assistant ownership assignment'); + if (before === JSON.stringify(parsed.data)) return; + const temporary = `${this.path}.${randomUUID()}.tmp`; + try { + await mkdir(dirname(this.path), { recursive: true }); + await writeFile(temporary, `${JSON.stringify(parsed.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + await rename(temporary, this.path); + } catch { + await unlink(temporary).catch(() => undefined); + throw new HomeAssistantUserStoreError('users_io', 'Could not commit Home Assistant ownership changes'); + } + }); + this.queue = result.catch(() => undefined); + return result; + } + async list() { return structuredClone((await this.read()).users); } + async assigned(userId: string, entityId: string): Promise { + homeAssistantUserIdSchema.parse(userId); + todoEntityIdSchema.parse(entityId); + return (await this.read()).users.some((user) => user.userId === userId && user.todoEntityIds.includes(entityId)); + } + async observe(identity: HomeAssistantIngressUser): Promise { + const user = homeAssistantUserSchema.parse(identity); + await this.mutate((file) => { + const existing = file.users.find((entry) => entry.userId === user.id); + if (existing) { existing.username = user.username; existing.displayName = user.displayName; } + else file.users.push({ userId: user.id, username: user.username, displayName: user.displayName, todoEntityIds: [] }); + }); + } + async assign(userId: string, todoEntityIds: string[]): Promise { + homeAssistantUserIdSchema.parse(userId); + const ids = todoAssignmentsSchema.parse(todoEntityIds); + await this.mutate((file) => { + const user = file.users.find((entry) => entry.userId === userId); + if (!user) throw new HomeAssistantUserStoreError('users_not_found', 'Unknown observed Home Assistant user'); + user.todoEntityIds = ids; + }); + } + async remove(userId: string): Promise { + homeAssistantUserIdSchema.parse(userId); + await this.mutate((file) => { file.users = file.users.filter((user) => user.userId !== userId); }); + } +} diff --git a/src/http/app.ts b/src/http/app.ts index ffce51d0..43bebd9b 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'; @@ -10,7 +8,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'; @@ -20,14 +18,12 @@ import { TodoStore, TodoStoreError } from '../todo/store.ts'; import { printerRoutes } from './printerRoutes.ts'; import { MoonrakerClient } from '../printers/moonraker.ts'; import { PrinterConnectionStore, PrinterStoreError } from '../printers/store.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 { HomeAssistantClient } from '../homeAssistant/client.ts'; +import { homeAssistantRoutes } from './homeAssistantRoutes.ts'; +import type { UpdateMode } from '../system/updateOwnership.ts'; +import { mountStudioAssets } from './studioAssets.ts'; +import { parseIngressUser } from '../homeAssistant/ingressUser.ts'; +import { HomeAssistantUserStore, HomeAssistantUserStoreError } from '../homeAssistant/userStore.ts'; function deviceStoreErrorBody(err: DeviceStoreError) { return { @@ -62,6 +58,20 @@ export interface AppDeps { /** Shared with FrameService in production; optional for existing test embedders. */ printerStore?: PrinterConnectionStore; moonrakerClient?: MoonrakerClient; + /** Shared Supervisor API client. Standalone tests/embedders may omit it. */ + homeAssistantClient?: HomeAssistantClient; + homeAssistantUserStore?: HomeAssistantUserStore; + /** 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; + /** 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'; + isTrustedRequest?: (req: Request) => boolean; + }; /** * Required, not optional. A default would mean inventing a fallback HMAC key * that is never used — the kind of line every future reader has to re-derive @@ -80,11 +90,54 @@ export interface AppDeps { enrolmentLimiter?: DeviceEnrolmentLimiter; } +export function isSupervisorIngressRequest(req: Request): boolean { + const remote = req.socket.remoteAddress ?? ''; + return remote === '172.30.32.2' || remote === '::ffff:172.30.32.2'; +} + +export function directWebFlashUrl(publicBaseUrl: string, httpsPort: number | null): string | null { + if (httpsPort === null) return null; + try { + const url = new URL(publicBaseUrl); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null; + url.protocol = 'https:'; + url.port = String(httpsPort); + url.pathname = '/'; + url.search = ''; + url.hash = '#flash'; + return url.toString(); + } catch { + return null; + } +} + export function createApp(deps: AppDeps): express.Express { const app = express(); + const updateMode = deps.updateMode ?? 'self'; app.disable('x-powered-by'); if (deps.trustProxy !== undefined) app.set('trust proxy', deps.trustProxy); + if (deps.access?.mode === 'home-assistant-ingress') { + const trusted = deps.access.isTrustedRequest ?? isSupervisorIngressRequest; + app.use((req, res, next) => { + if (trusted(req)) return next(); + res.status(403).json({ error: 'Home Assistant Ingress proxy required' }); + }); + } app.use(express.json()); + const homeAssistantUserStore = deps.homeAssistantUserStore ?? new HomeAssistantUserStore(join(deps.dataDir, '.home-assistant-users.json')); + app.use(async (req, res, next) => { + const ingress = deps.access?.mode === 'home-assistant-ingress'; + res.locals.homeAssistantIngress = ingress; + res.locals.homeAssistantUser = parseIngressUser(req, ingress); + if (res.locals.homeAssistantUser && ['/', '/index.html', '/api/home-assistant/current-user'].includes(req.path)) { + try { await homeAssistantUserStore.observe(res.locals.homeAssistantUser); } + catch (error) { + if (req.path.startsWith('/api/')) return next(error); + // Keep the shared Studio shell accessible; personal API requests fail closed. + } + } + next(); + }); app.get('/health', async (_req, res) => { const uptimeSeconds = Math.round(process.uptime()); @@ -119,21 +172,36 @@ export function createApp(deps: AppDeps): express.Express { // is intentionally public and mounted before the authenticated API gate. app.get('/api/runtime-config', (_req, res) => { res.set('cache-control', 'no-store'); - res.json({ httpsPort: deps.runtimeState.httpsPort }); + res.json({ + httpsPort: deps.runtimeState.httpsPort, + updateMode, + ...(updateMode === 'home-assistant' + ? { + release: deps.homeAssistantRelease ?? null, + accessMode: deps.access?.mode === 'home-assistant-ingress' + ? 'home-assistant-ingress' + : 'lan', + webFlashUrl: directWebFlashUrl(deps.publicBaseUrl, deps.runtimeState.httpsPort), + } + : {}), + }); }); - const auth = createAuth(deps.auth); const todoStore = deps.todoStore ?? new TodoStore(join(deps.dataDir, '.todo-lists.json')); const printerStore = deps.printerStore ?? new PrinterConnectionStore(join(deps.dataDir, '.printer-connections.json')); const moonrakerClient = deps.moonrakerClient ?? new MoonrakerClient(); + const homeAssistantClient = deps.homeAssistantClient ?? new HomeAssistantClient({ enabled: false }); // Login must be reachable before the gate. auth.ts's isExempt() does NOT // match it — only the device frame route is exempt — so this reachability // depends entirely on auth.router being mounted before auth.middleware // here. This ordering is load-bearing: reverse it and login starts // requiring a session to reach the endpoint that creates one. - app.use('/api', auth.router); - app.use('/api', auth.middleware); + if (deps.access?.mode !== 'home-assistant-ingress') { + const auth = createAuth(deps.auth); + app.use('/api', auth.router); + app.use('/api', auth.middleware); + } // Device routes first: both mount under /api and :id/frame must win. app.use('/api', deviceRoutes( @@ -141,6 +209,7 @@ export function createApp(deps: AppDeps): express.Express { deps.frames, deps.publicBaseUrl, deps.enrolmentLimiter, + deps.enrolmentDefaults, )); app.use('/api', manageRoutes( deps.store, @@ -156,7 +225,8 @@ export function createApp(deps: AppDeps): express.Express { app.use('/api', editorPreferencesRoutes(deps.store, deps.dataDir)); app.use('/api', todoRoutes(deps.store, todoStore)); app.use('/api', printerRoutes(deps.store, printerStore, moonrakerClient)); - app.use('/api', systemRoutes(deps.store, deps.frames, deps.dataDir)); + app.use('/api', homeAssistantRoutes(homeAssistantClient, homeAssistantUserStore)); + app.use('/api', systemRoutes(deps.store, deps.frames, deps.dataDir, { updateMode })); app.use('/api', firmwareRoutes(deps.firmwareDir, deps.publicBaseUrl)); // A corrupt/unreadable device store is a deliberate fail-closed condition, @@ -165,6 +235,11 @@ export function createApp(deps: AppDeps): express.Express { // fetch failure and retain its existing e-paper image rather than enrolling // into a freshly invented empty configuration. app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => { + if (err instanceof HomeAssistantUserStoreError) { + res.status(err.code === 'users_invalid' ? 400 : err.code === 'users_not_found' ? 404 : 503) + .json({ component: 'home-assistant-users', code: err.code, error: err.message }); + return; + } if (err instanceof DeviceStoreError) { res.status(503).json(deviceStoreErrorBody(err)); return; @@ -186,13 +261,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)); - - app.use(express.static(publicDir)); + mountStudioAssets(app, updateMode === 'home-assistant' ? deps.homeAssistantRelease : undefined); return app; } diff --git a/src/http/deviceEnrolment.ts b/src/http/deviceEnrolment.ts index 6ed4e3b1..91a4a93b 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 6e54fce5..c1cc2d2d 100644 --- a/src/http/deviceRoutes.ts +++ b/src/http/deviceRoutes.ts @@ -4,10 +4,12 @@ import type { FrameService } from '../render/frameService.ts'; import { nextWakeSeconds } from '../schedule/nextWake.ts'; import { deviceIdSchema } from '../devices/schema.ts'; import type { PanelProfileId } from '../devices/types.ts'; +import { authorizePersonalTodoAccess } from '../homeAssistant/ingressUser.ts'; import { panelProfile, WFT0583 } from '../panel/profile.ts'; import { DeviceEnrolmentLimiter, firmwareAutoEnrolmentIdSchema, + type DeviceEnrolmentDefaultsProvider, } from './deviceEnrolment.ts'; const ERROR_RETRY_SECONDS = 300; @@ -30,6 +32,7 @@ export function deviceRoutes( frames: FrameService, publicBaseUrl: string, enrolmentLimiter = new DeviceEnrolmentLimiter(), + enrolmentDefaults?: DeviceEnrolmentDefaultsProvider, ): Router { const router = Router(); @@ -63,11 +66,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; @@ -86,6 +98,7 @@ export function deviceRoutes( }); return; } + if (!authorizePersonalTodoAccess(device.dashboardSections, res)) return; const batteryVolts = parseVolts(req.get('x-battery-voltage')); const wake = nextWakeSeconds({ now: new Date(), device, batteryVolts }); diff --git a/src/http/editorPreferencesRoutes.ts b/src/http/editorPreferencesRoutes.ts index 97ceb55f..f9cf9a0d 100644 --- a/src/http/editorPreferencesRoutes.ts +++ b/src/http/editorPreferencesRoutes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { join } from 'node:path'; import { z } from 'zod'; import type { DeviceStore } from '../devices/store.ts'; +import { authorizePersonalTodoAccess } from '../homeAssistant/ingressUser.ts'; import { DashboardEditorPreferencesStore, dashboardEditorSlotSchema, @@ -34,6 +35,14 @@ export function editorPreferencesRoutes(store: DeviceStore, dataDir: string): Ro ); const ready = preferences.load(); + router.use('/dashboard-editor/:id', async (req, res, next) => { + if (res.locals.homeAssistantIngress && !res.locals.homeAssistantUser) { + const device = await store.get(req.params.id); + if (device && !authorizePersonalTodoAccess(device.dashboardSections, res)) return; + } + next(); + }); + router.get('/dashboard-editor/:id', async (req, res) => { if (!(await store.get(req.params.id))) { res.status(404).json({ error: 'unknown device' }); @@ -41,7 +50,9 @@ export function editorPreferencesRoutes(store: DeviceStore, dataDir: string): Ro } await ready; res.set('cache-control', 'no-store'); - res.json(preferences.get(req.params.id)); + const result = preferences.get(req.params.id); + if (!authorizePersonalTodoAccess([...result.shared, ...result.slots.flat()], res)) return; + res.json(result); }); router.put('/dashboard-editor/:id', async (req, res) => { @@ -54,6 +65,7 @@ export function editorPreferencesRoutes(store: DeviceStore, dataDir: string): Ro res.status(400).json({ error: 'invalid remembered widget settings', issues: parsed.error.issues }); return; } + if (!authorizePersonalTodoAccess(parsed.data.slots.flat(), res)) return; await ready; await preferences.set(req.params.id, persistedSlots(parsed.data.slots)); res.set('cache-control', 'no-store'); diff --git a/src/http/homeAssistantRoutes.ts b/src/http/homeAssistantRoutes.ts new file mode 100644 index 00000000..9312c22c --- /dev/null +++ b/src/http/homeAssistantRoutes.ts @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import type { HomeAssistantClient } from '../homeAssistant/client.ts'; +import { homeAssistantUserIdSchema } from '../homeAssistant/ingressUser.ts'; +import { todoAssignmentsSchema, type HomeAssistantUserStore } from '../homeAssistant/userStore.ts'; +import { z } from 'zod'; + +export function homeAssistantRoutes(client: HomeAssistantClient, users: HomeAssistantUserStore): Router { + const router = Router(); + const personalPaths = ['/home-assistant/current-user', '/home-assistant/my-todo-lists', '/home-assistant/users']; + router.use(personalPaths, (_req, res, next) => { + res.set('cache-control', 'no-store'); + if (res.locals.homeAssistantIngress && !res.locals.homeAssistantUser) { + res.status(403).json({ error: 'Valid trusted Home Assistant user identity required' }); + return; + } + next(); + }); + router.get('/home-assistant/current-user', (_req, res) => { + res.json(res.locals.homeAssistantUser + ? { available: true, user: res.locals.homeAssistantUser } + : { available: false, user: null, accessMode: 'lan' }); + }); + router.get('/home-assistant/my-todo-lists', async (_req, res) => { + const identity = res.locals.homeAssistantUser; + if (!identity) { res.status(403).json({ error: 'Trusted Home Assistant Ingress identity required' }); return; } + const assigned = (await users.list()).find((user) => user.userId === identity.id)?.todoEntityIds ?? []; + const discovery = await client.listTodoLists(); + res.json({ available: discovery.available, lists: assigned.map((entityId) => + discovery.lists.find((list) => list.entityId === entityId) ?? { entityId, name: `${entityId} (missing/unavailable)` }) }); + }); + // These are administrative routes: LAN auth or the admin-only Ingress sidebar. + router.get('/home-assistant/users', async (_req, res) => { res.json({ users: await users.list() }); }); + router.put('/home-assistant/users/:id', async (req, res) => { + const id = homeAssistantUserIdSchema.safeParse(req.params.id); + const body = z.strictObject({ todoEntityIds: todoAssignmentsSchema }).safeParse(req.body); + if (!id.success || !body.success) { res.status(400).json({ error: 'Invalid ownership assignment' }); return; } + await users.assign(id.data, body.data.todoEntityIds); + res.json({ ok: true }); + }); + router.delete('/home-assistant/users/:id', async (req, res) => { + const id = homeAssistantUserIdSchema.safeParse(req.params.id); + if (!id.success) { res.status(400).json({ error: 'Invalid user ID' }); return; } + await users.remove(id.data); + res.json({ ok: true }); + }); + 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()); + }); + router.get('/home-assistant/calendars', async (_req, res) => { + res.set('cache-control', 'no-store'); + res.json(await client.listCalendars()); + }); + router.get('/home-assistant/status', async (_req, res) => { + res.set('cache-control', 'no-store'); + res.json(await client.status()); + }); + return router; +} diff --git a/src/http/manageRoutes.ts b/src/http/manageRoutes.ts index 329021a9..8324908b 100644 --- a/src/http/manageRoutes.ts +++ b/src/http/manageRoutes.ts @@ -18,6 +18,9 @@ import { panelProfileIdV3Schema, timezoneSchema } from '../devices/schema.ts'; 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, todoWidgetV3Schema, entitiesWidgetV1Schema } from '../widgets/registry.ts'; +import { authorizePersonalTodoAccess } from '../homeAssistant/ingressUser.ts'; const stationCodeInputSchema = z .string() @@ -40,11 +43,21 @@ const octopusTariffCodeInputSchema = z 'Octopus Agile tariff code must look like E-1R-AGILE-24-10-01-C', ); -const dashboardSectionInputSchema = z.discriminatedUnion('type', [ +const dashboardSectionInputSchema = z.union([ + entitiesWidgetV1Schema, + todoWidgetV2Schema, + todoWidgetV3Schema, z.strictObject({ type: z.literal('calendar'), version: z.literal(1), config: z.strictObject({ calendarUrls: z.array(calendarUrlInputSchema).max(10) }), }), + z.strictObject({ + type: z.literal('calendar'), version: z.literal(2), + config: z.discriminatedUnion('provider', [ + z.strictObject({ provider: z.literal('ical'), calendarUrls: z.array(calendarUrlInputSchema).max(10) }), + z.strictObject({ provider: z.literal('home-assistant'), entityIds: calendarEntityIdsSchema }), + ]), + }), z.strictObject({ type: z.literal('weather'), version: z.literal(1), config: z.strictObject({}) }), z.strictObject({ type: z.literal('trains'), version: z.literal(1), @@ -243,7 +256,17 @@ export function manageRoutes( }); router.get('/devices', async (_req, res) => { - res.json({ devices: await store.list() }); + const devices = await store.list(); + if (!authorizePersonalTodoAccess(devices.flatMap((device) => device.dashboardSections), res)) return; + res.json({ devices }); + }); + + router.use('/devices/:id', async (req, res, next) => { + if (res.locals.homeAssistantIngress && !res.locals.homeAssistantUser) { + const device = await store.get(req.params.id); + if (device && !authorizePersonalTodoAccess(device.dashboardSections, res)) return; + } + next(); }); router.get('/devices/:id', async (req, res) => { @@ -279,8 +302,9 @@ export function manageRoutes( return; } + if (!authorizePersonalTodoAccess(sections, res)) return; 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/studioAssets.ts b/src/http/studioAssets.ts new file mode 100644 index 00000000..eca24eed --- /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/src/http/systemRoutes.ts b/src/http/systemRoutes.ts index 6e76d035..c76eca44 100644 --- a/src/http/systemRoutes.ts +++ b/src/http/systemRoutes.ts @@ -5,14 +5,34 @@ import type { FrameService } from '../render/frameService.ts'; import { readVersion } from '../system/version.ts'; import { checkForUpdate } from '../system/updateCheck.ts'; import { readUpdateStatus, requestUpdate } from '../system/updateStatus.ts'; +import { + HOME_ASSISTANT_UPDATE_ERROR, + managedUpdateInfo, + type UpdateMode, +} from '../system/updateOwnership.ts'; -export function systemRoutes(store: DeviceStore, frames: FrameService, dataDir: string): Router { +export interface SystemRouteOptions { + updateMode?: UpdateMode; + /** Injectable so ownership tests can prove managed deployments never invoke Git. */ + updateChecker?: typeof checkForUpdate; +} + +export function systemRoutes( + store: DeviceStore, + frames: FrameService, + dataDir: string, + options: SystemRouteOptions = {}, +): Router { const router = Router(); + const updateMode = options.updateMode ?? 'self'; + const updateChecker = options.updateChecker ?? checkForUpdate; router.get('/system/info', async (req, res) => { const [version, update, devices] = await Promise.all([ readVersion(), - checkForUpdate(req.query.refresh === '1'), + updateMode === 'self' + ? updateChecker(req.query.refresh === '1') + : Promise.resolve(managedUpdateInfo()), store.list(), ]); @@ -46,6 +66,11 @@ export function systemRoutes(store: DeviceStore, frames: FrameService, dataDir: }); router.post('/system/update', async (_req, res) => { + if (updateMode === 'home-assistant') { + res.status(409).json({ error: HOME_ASSISTANT_UPDATE_ERROR }); + return; + } + const running = await readUpdateStatus(dataDir); if (running.state === 'running') { res.status(409).json({ error: 'an update is already running' }); @@ -61,7 +86,11 @@ export function systemRoutes(store: DeviceStore, frames: FrameService, dataDir: }); router.get('/system/update/status', async (_req, res) => { - res.set('Cache-Control', 'no-store').json(await readUpdateStatus(dataDir)); + res.set('Cache-Control', 'no-store').json( + updateMode === 'home-assistant' + ? managedUpdateInfo() + : await readUpdateStatus(dataDir), + ); }); return router; diff --git a/src/http/todoRoutes.ts b/src/http/todoRoutes.ts index 78951d69..c7bdf537 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/index.ts b/src/index.ts index fd958982..98d30098 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import 'dotenv/config'; import { hostname, networkInterfaces } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { createApp } from './http/app.ts'; +import { createApp, type AppDeps } from './http/app.ts'; import { loadOrCreateSecret } from './http/auth.ts'; import { activateHttpsListener } from './https.ts'; import { DeviceStore } from './devices/store.ts'; @@ -27,9 +27,33 @@ import { createRuntimeState, resolveHttpsPort } from './runtimeConfig.ts'; 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 { HomeAssistantUserStore } from './homeAssistant/userStore.ts'; +import { homeAssistantEnrolmentDefaults } from './homeAssistant/enrolment.ts'; +import { updateModeForDeployment } from './system/updateOwnership.ts'; export const version = '0.1.0'; +export function resolveHomeAssistantIngressPort(raw: string | undefined): number { + if (raw === undefined || raw.trim() === '') return 8099; + if (!/^\d+$/.test(raw.trim())) throw new Error('HOME_ASSISTANT_INGRESS_PORT must be an integer'); + const port = Number(raw); + if (port < 1 || port > 65535) throw new Error('HOME_ASSISTANT_INGRESS_PORT must be between 1 and 65535'); + return port; +} + +async function waitUntilListening(server: ReturnType['listen']>): Promise { + await new Promise((resolveListening, rejectListening) => { + if (server.listening) return resolveListening(); + const onError = (err: Error) => rejectListening(err); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolveListening(); + }); + }); +} + export function parseTrustProxy(raw: string | undefined): boolean | number | string | undefined { if (raw === undefined) return undefined; const value = raw.trim(); @@ -74,6 +98,8 @@ export async function main(): Promise { const publicBaseUrl = process.env.PUBLIC_BASE_URL || `http://${detectedLanAddress}:${port}`; const resolvedHttps = resolveHttpsPort(process.env.HTTPS_PORT); const runtimeState = createRuntimeState(); + const homeAssistantMode = isHomeAssistantMode(process.env.HOME_ASSISTANT_MODE); + const updateMode = updateModeForDeployment(homeAssistantMode); const allowPrivateCalendarNetworks = parseCalendarAllowPrivateNetworks( process.env.CALENDAR_ALLOW_PRIVATE_NETWORKS, ); @@ -124,10 +150,18 @@ export async function main(): Promise { googleRoutesEndpoint ? { endpoint: googleRoutesEndpoint } : {}, ); + const homeAssistantClient = new HomeAssistantClient({ + enabled: homeAssistantMode, + baseUrl: process.env.HOME_ASSISTANT_BASE_URL, + token: process.env.SUPERVISOR_TOKEN, + }); + const homeAssistantUserStore = new HomeAssistantUserStore(join(dataDir, '.home-assistant-users.json')); const frames = new FrameService({ renderer, cache: new SourceCache(join(dataDir, 'cache')), calendarSource, + homeAssistantClient, + homeAssistantUserStore, trainSource, busSource, trafficSource, @@ -140,7 +174,7 @@ export async function main(): Promise { const secret = await loadOrCreateSecret(join(dataDir, '.session-secret')); const trustProxy = parseTrustProxy(process.env.TRUST_PROXY); - const app = createApp({ + const sharedDeps: AppDeps = { store, frames, publicBaseUrl, runtimeState, dataDir, firmwareDir, auth: { password, secret }, trustProxy, trainCredentials, @@ -150,20 +184,36 @@ export async function main(): Promise { todoStore, printerStore, moonrakerClient, - }); + homeAssistantClient, + updateMode, + homeAssistantUserStore, + homeAssistantRelease: homeAssistantMode ? process.env.INKPANEL_HA_RELEASE : undefined, + enrolmentDefaults: homeAssistantEnrolmentDefaults(homeAssistantMode, homeAssistantClient), + }; + const app = createApp({ ...sharedDeps, access: { mode: 'lan' } }); const server = app.listen(port); - await new Promise((resolveListening, rejectListening) => { - if (server.listening) { - resolveListening(); - return; + await waitUntilListening(server); + + let ingressServer: ReturnType | null = null; + if (homeAssistantMode) { + const ingressPort = resolveHomeAssistantIngressPort(process.env.HOME_ASSISTANT_INGRESS_PORT); + if (ingressPort === port) { + server.close(); + throw new Error('HOME_ASSISTANT_INGRESS_PORT must differ from PORT'); } - const onError = (err: Error) => rejectListening(err); - server.once('error', onError); - server.once('listening', () => { - server.off('error', onError); - resolveListening(); + const ingressApp = createApp({ + ...sharedDeps, + access: { mode: 'home-assistant-ingress' }, }); - }); + ingressServer = ingressApp.listen(ingressPort); + try { + await waitUntilListening(ingressServer); + } catch (err) { + server.close(); + throw err; + } + console.log(`Home Assistant Ingress listening internally on port ${ingressPort}`); + } console.log(`inkpanel ${version} listening on ${publicBaseUrl}`); console.log(`data directory: ${dataDir}`); console.log(password ? 'authentication: enabled' : 'authentication: disabled (no INKPANEL_PASSWORD)'); @@ -171,6 +221,7 @@ export async function main(): Promise { console.log(`National Rail live departures: ${trainCredentials.status().configured ? 'configured' : 'not configured'}`); console.log(`TransportAPI bus departures: ${busCredentials.status().configured ? 'configured' : 'not configured'}`); console.log(`Google traffic routes: ${googleMapsCredentials.status().configured ? 'configured' : 'not configured'}`); + console.log(`Home Assistant mode: ${homeAssistantMode ? 'enabled' : 'standalone'}`); const httpsServer = resolvedHttps.httpsPort === null ? null @@ -197,6 +248,7 @@ export async function main(): Promise { const shutdown = async () => { server.close(); + ingressServer?.close(); httpsServer?.close(); await renderer.close(); process.exit(0); diff --git a/src/model/dashboard.ts b/src/model/dashboard.ts index 2c754604..6f2e208d 100644 --- a/src/model/dashboard.ts +++ b/src/model/dashboard.ts @@ -68,14 +68,27 @@ 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 } | { 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/model/hash.ts b/src/model/hash.ts index 8e582a43..461e77f1 100644 --- a/src/model/hash.ts +++ b/src/model/hash.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { ProfileDashboardData } from './dashboard.ts'; +import type { CalendarEvent, ProfileDashboardData } from './dashboard.ts'; /** * Hash only what is visible on the panel. @@ -54,7 +54,12 @@ export function contentHash(data: ProfileDashboardData): string { type: section.type, data: section.type === 'todo' && section.data ? { items: section.data.items.slice(0, 5) } - : section.type === 'printers' ? printerData : section.data, + : section.type === 'printers' ? printerData + : section.type === 'calendar' && section.data + ? Object.fromEntries(Object.entries(section.data).map(([day, events]) => [ + day, events.map(({ title, start, end, allDay }: CalendarEvent) => ({ title, start, end, allDay })), + ])) + : section.data, // These widgets visibly distinguish an absent configuration ("not set // up") from a configured source whose first/live fetch failed // ("unavailable"). Health details themselves remain diagnostic-only. @@ -64,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 } @@ -82,7 +87,7 @@ export function contentHash(data: ProfileDashboardData): string { sections: [visibleSection(section)], }; } else { - // Keep the established large-panel hash shape byte-for-byte equivalent. + // Keep the established large-panel global fields; source metadata stays hidden. visible = { timezone: data.timezone, today: data.today, diff --git a/src/render/entities.ts b/src/render/entities.ts new file mode 100644 index 00000000..a961f12d --- /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 283899b7..44091a83 100644 --- a/src/render/frameService.ts +++ b/src/render/frameService.ts @@ -14,6 +14,11 @@ import { batteryPercent } from '../devices/battery.ts'; 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 { runHomeAssistantEntities } from '../sources/homeAssistantEntities.ts'; +import type { HomeAssistantClient } from '../homeAssistant/client.ts'; +import type { HomeAssistantUserStore } from '../homeAssistant/userStore.ts'; import { openMeteoSource } from '../sources/openMeteo.ts'; import { binsSource } from '../sources/bins.ts'; import { runLiveSource, runSource } from '../sources/runner.ts'; @@ -53,6 +58,8 @@ export interface FrameDeps { fetchData?: (device: DeviceRecord) => Promise; /** Injected once at startup so calendar network policy is explicit/testable. */ calendarSource?: Source; + homeAssistantClient?: HomeAssistantClient; + homeAssistantUserStore?: HomeAssistantUserStore; weatherSource?: typeof openMeteoSource; binsSource?: typeof binsSource; trainSource?: Source; @@ -128,19 +135,27 @@ export class FrameService { const requests = new Map>(); const sectionRequest = (widget: DeviceRecord['dashboardSections'][number]): Promise => { - const key = `${widget.type}:${JSON.stringify(widget.config)}`; + const key = `${widget.type}:${widget.version}:${JSON.stringify(widget.config)}`; const existing = requests.get(key); if (existing) return existing; 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 = runCalendars( + request = ('entityIds' in widget.config + ? runHomeAssistantCalendars(widget.config.entityIds, device.timezone, this.deps.homeAssistantClient, this.deps.cache, runOptions) + : runCalendars( widget.config.calendarUrls, device.timezone, this.deps.cache, { ...runOptions, source: this.deps.calendarSource }, - ).then((outcome) => ({ type: 'calendar', data: outcome.data, health: outcome.health })); + )).then((outcome) => ({ type: 'calendar', data: outcome.data, health: outcome.health })); break; case 'weather': request = headerWeatherPromise.then((outcome) => ({ @@ -232,7 +247,13 @@ 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, + widget.version === 3 ? { ownerUserId: widget.config.ownerUserId, store: this.deps.homeAssistantUserStore } : undefined) + .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/render/miniTemplate.ts b/src/render/miniTemplate.ts index 406f7d18..23a642ba 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 b9cf3ae1..d26fcb4e 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/homeAssistantCalendar.ts b/src/sources/homeAssistantCalendar.ts new file mode 100644 index 00000000..720a857a --- /dev/null +++ b/src/sources/homeAssistantCalendar.ts @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto'; +import type { HomeAssistantClient } from '../homeAssistant/client.ts'; +import { calendarEntityIdSchema, homeAssistantCalendarEventsSchema, type HomeAssistantCalendarEvent } from '../homeAssistant/calendarSchemas.ts'; +import type { CalendarData, CalendarEvent } from '../model/dashboard.ts'; +import type { SourceCache } from './cache.ts'; +import { localDateKey } from './ical.ts'; +import { runSource, type RunOutcome, type RunSourceOptions } from './runner.ts'; +import type { Source } from './types.ts'; + +const SOURCE_ID = 'home-assistant-calendar'; +const addDays = (date: string, days: number) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); + +/** Bounded UTC envelope around two panel-local dates, including every UTC offset and DST. */ +export function homeAssistantCalendarWindow(now: Date, timezone: string) { + const today = localDateKey(now, timezone); + return { start: `${addDays(today, -1)}T00:00:00.000Z`, end: `${addDays(today, 3)}T00:00:00.000Z` }; +} + +export function normalizeHomeAssistantCalendars( + calendars: Array<{ entityId: string; events: HomeAssistantCalendarEvent[] }>, now: Date, timezone: string, +): CalendarData { + const todayKey = localDateKey(now, timezone); + const tomorrowKey = addDays(todayKey, 1); + const result: CalendarData = { today: [], tomorrow: [] }; + for (const { entityId, events } of calendars) { + for (const event of events) { + const allDay = 'date' in event.start; + const start = 'date' in event.start ? `${event.start.date}T00:00:00.000Z` : new Date(event.start.dateTime).toISOString(); + const end = 'date' in event.end ? `${event.end.date}T00:00:00.000Z` : new Date(event.end.dateTime).toISOString(); + const title = event.summary?.trim() || '(no title)'; + const uid = event.uid || createHash('sha256').update(JSON.stringify([entityId, start, end, title])).digest('hex'); + const normalized: CalendarEvent = { uid, title, start, end, allDay }; + const includes = (day: string) => allDay + // Date-only events are floating local dates, never instants converted across zones. + ? start.slice(0, 10) <= day && day < end.slice(0, 10) + : localDateKey(new Date(start), timezone) === day; + if (includes(todayKey)) result.today.push(normalized); + if (includes(tomorrowKey)) result.tomorrow.push(normalized); + } + } + const compare = (a: CalendarEvent, b: CalendarEvent) => a.start.localeCompare(b.start) + || a.title.localeCompare(b.title) || a.end.localeCompare(b.end) || Number(a.allDay) - Number(b.allDay) + || a.uid.localeCompare(b.uid); + result.today.sort(compare); + result.tomorrow.sort(compare); + return result; +} + +interface CalendarSourceConfig { instance: string; entityId: string; start: string; end: string } + +/** Per-entity last-good raw events, scoped to instance + bounded window + device. No credentials. */ +export async function runHomeAssistantCalendars( + entityIds: string[], timezone: string, client: HomeAssistantClient | undefined, cache: SourceCache, + options: RunSourceOptions & { now?: Date }, +): Promise> { + const unavailable = (error: string): RunOutcome => ({ + data: null, health: { id: SOURCE_ID, status: 'error', fetchedAt: null, error }, + }); + const instance = client?.calendarCacheScope; + if (!client || !instance) return unavailable('Home Assistant calendars are unavailable'); + const ids = [...new Set(entityIds)]; + if (ids.length === 0) return unavailable('no Home Assistant calendars selected'); + if (ids.length > 10 || ids.some((id) => !calendarEntityIdSchema.safeParse(id).success)) { + return unavailable('invalid Home Assistant calendar selection'); + } + const now = options.now ?? new Date(); + const window = homeAssistantCalendarWindow(now, timezone); + const source: Source = { + id: SOURCE_ID, + async fetch(config, signal) { + const result = await client.getCalendarEvents(config.entityId, config.start, config.end, signal); + return result.available + ? { status: 'ok', data: result.data, fetchedAt: new Date().toISOString() } + : { status: 'error', error: result.error }; + }, + }; + const outcomes = await Promise.all(ids.map(async (entityId) => { + const outcome = await runSource(source, { instance, entityId, ...window }, cache, options); + // Cache files are not a trusted API boundary either. Never render malformed saved data. + const parsed = homeAssistantCalendarEventsSchema.safeParse(outcome.data); + return { entityId, outcome: parsed.success ? { ...outcome, data: parsed.data } : { + data: null, health: { id: SOURCE_ID, status: 'error' as const, fetchedAt: null, error: 'Home Assistant calendar data unavailable' }, + } }; + })); + const available = outcomes.filter(({ outcome }) => outcome.data !== null); + const failed = outcomes.filter(({ outcome }) => outcome.health.status === 'error').length; + const stale = outcomes.filter(({ outcome }) => outcome.health.status === 'stale').length; + const errors = [ + ...(failed ? [`${failed} of ${ids.length} Home Assistant calendars unavailable`] : []), + ...(stale ? [`${stale} using cached data`] : []), + ]; + return { + data: available.length ? normalizeHomeAssistantCalendars(available.map(({ entityId, outcome }) => ({ + entityId, events: outcome.data!, + })), now, timezone) : null, + health: { + id: SOURCE_ID, status: failed ? 'error' : stale ? 'stale' : 'ok', + fetchedAt: available.map(({ outcome }) => outcome.health.fetchedAt).filter((v): v is string => v !== null).sort()[0] ?? null, + error: errors.join('; ') || null, + }, + }; +} diff --git a/src/sources/homeAssistantEntities.ts b/src/sources/homeAssistantEntities.ts new file mode 100644 index 00000000..e5a477b3 --- /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/sources/homeAssistantTodo.ts b/src/sources/homeAssistantTodo.ts new file mode 100644 index 00000000..e79f4051 --- /dev/null +++ b/src/sources/homeAssistantTodo.ts @@ -0,0 +1,32 @@ +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'; +import type { HomeAssistantUserStore } from '../homeAssistant/userStore.ts'; +import { todoWidgetV3Schema } from '../widgets/registry.ts'; + +/** Task completion is live-only: never replay a stale task list from disk. */ +export function runHomeAssistantTodo(entityId: string, client: HomeAssistantClient | undefined, options: RunSourceOptions, + ownership?: { ownerUserId: string; store: HomeAssistantUserStore | undefined }) { + const source: Source = { + id: 'home-assistant-todo', + async fetch(id, signal) { + try { + const authorized = async () => !ownership || ( + todoWidgetV3Schema.safeParse({ type: 'todo', version: 3, + config: { provider: 'home-assistant', ownerUserId: ownership.ownerUserId, entityId: id } }).success + && await ownership.store?.assigned(ownership.ownerUserId, id) === true); + if (!await authorized()) return { status: 'error', error: 'Home Assistant To Do ownership is unavailable or no longer assigned' }; + const result = await client?.getTodoItems(id, signal); + // Revocation during a live request must not display the just-fetched tasks. + if (!await authorized()) return { status: 'error', error: 'Home Assistant To Do ownership is unavailable or no longer assigned' }; + 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/system/updateOwnership.ts b/src/system/updateOwnership.ts new file mode 100644 index 00000000..eff820e2 --- /dev/null +++ b/src/system/updateOwnership.ts @@ -0,0 +1,16 @@ +export type UpdateMode = 'self' | 'home-assistant'; + +export const HOME_ASSISTANT_UPDATE_ERROR = 'updates are managed by Home Assistant'; + +export interface ManagedUpdateInfo { + state: 'managed'; + manager: 'home-assistant'; +} + +export function updateModeForDeployment(homeAssistantMode: boolean): UpdateMode { + return homeAssistantMode ? 'home-assistant' : 'self'; +} + +export function managedUpdateInfo(): ManagedUpdateInfo { + return { state: 'managed', manager: 'home-assistant' }; +} diff --git a/src/widgets/editorPreferences.ts b/src/widgets/editorPreferences.ts index 8e396cf7..190d8889 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)); }); }); @@ -61,12 +67,15 @@ 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 'calendar': return widget.config.calendarUrls.length > 0; + 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); 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 !('ownerUserId' in widget.config) + && 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': @@ -76,13 +85,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(); } /** @@ -90,7 +104,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 70b0ebc0..b1a2e2c6 100644 --- a/src/widgets/registry.ts +++ b/src/widgets/registry.ts @@ -1,10 +1,28 @@ import { z } from 'zod'; +import { calendarEntityIdsSchema } from '../homeAssistant/calendarSchemas.ts'; +import { todoEntityIdSchema } from '../homeAssistant/todoSchemas.ts'; +import { homeAssistantUserIdSchema } from '../homeAssistant/ingressUser.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({ calendarUrls: z.array(z.string().url()).max(10), }); +export const calendarWidgetConfigV2Schema = z.discriminatedUnion('provider', [ + z.strictObject({ provider: z.literal('ical'), calendarUrls: z.array(z.string().url()).max(10) }), + z.strictObject({ provider: z.literal('home-assistant'), entityIds: calendarEntityIdsSchema }), +]); + +export const calendarWidgetV2Schema = z.strictObject({ + type: z.literal('calendar'), version: z.literal(2), config: calendarWidgetConfigV2Schema, +}); + export const weatherWidgetConfigV1Schema = z.strictObject({}); const crsSchema = z.string().regex(/^(?:|[A-Z]{3})$/, 'CRS must be empty or three uppercase letters'); @@ -45,6 +63,20 @@ 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 todoWidgetV3Schema = z.strictObject({ + type: z.literal('todo'), version: z.literal(3), config: z.discriminatedUnion('provider', [ + z.strictObject({ provider: z.literal('local'), listId: todoWidgetConfigV1Schema.shape.listId }), + z.strictObject({ provider: z.literal('home-assistant'), ownerUserId: homeAssistantUserIdSchema, entityId: todoEntityIdSchema }), + ]), +}); + 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'), @@ -108,26 +140,31 @@ export const emptyWidgetV1Schema = z.strictObject({ }); export type DashboardWidget = + | z.infer | z.infer + | z.infer | z.infer | z.infer | z.infer | z.infer | z.infer | z.infer + | z.infer + | z.infer | z.infer | z.infer | z.infer; /** Current runtime registry, explicitly keyed by widget type and version. */ export const widgetRegistry = { - calendar: { 1: calendarWidgetV1Schema }, + entities: { 1: entitiesWidgetV1Schema }, + calendar: { 1: calendarWidgetV1Schema, 2: calendarWidgetV2Schema }, weather: { 1: weatherWidgetV1Schema }, trains: { 1: trainsWidgetV1Schema }, bus: { 1: busWidgetV1Schema }, traffic: { 1: trafficWidgetV1Schema }, octopus: { 1: octopusWidgetV1Schema }, - todo: { 1: todoWidgetV1Schema }, + todo: { 1: todoWidgetV1Schema, 2: todoWidgetV2Schema, 3: todoWidgetV3Schema }, printers: { 1: printersWidgetV1Schema }, bins: { 1: binsWidgetV1Schema }, empty: { 1: emptyWidgetV1Schema }, diff --git a/test/devices/schema.test.ts b/test/devices/schema.test.ts index 9616e5d3..92b502e3 100644 --- a/test/devices/schema.test.ts +++ b/test/devices/schema.test.ts @@ -146,10 +146,10 @@ test('V2 persistence envelope remains generic while its frozen runtime registry if (!unknownResult.success) assert.match(unknownResult.error.message, /unknown widget type: future-widget/); const futureVersion = structuredClone(migrated); - futureVersion.devices[0]!.dashboardSections[0] = { type: 'calendar', version: 2, config: { calendarUrls: [] } }; + futureVersion.devices[0]!.dashboardSections[0] = { type: 'calendar', version: 99, config: { calendarUrls: [] } }; const versionResult = deviceStoreV2Schema.safeParse(futureVersion); assert.equal(versionResult.success, false); - if (!versionResult.success) assert.match(versionResult.error.message, /unsupported calendar widget version: 2/); + if (!versionResult.success) assert.match(versionResult.error.message, /unsupported calendar widget version: 99/); const malformed = structuredClone(migrated); malformed.devices[0]!.dashboardSections[0] = { type: 'calendar', version: 1, config: { calendarUrls: [], extra: true } }; diff --git a/test/devices/store.test.ts b/test/devices/store.test.ts index 42d5aa2c..74985c54 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'); @@ -206,7 +236,7 @@ test('current runtime defaults are explicit and return independent section confi const second = defaultDevice('default-b'); assert.equal('calendarUrls' in first, false, 'runtime defaults use the widget-envelope shape, not historical V1 fields'); assert.deepEqual(first.dashboardSections.map((section) => section.type), ['calendar', 'weather', 'trains', 'bins']); - if (first.dashboardSections[0].type === 'calendar') { + if (first.dashboardSections[0].type === 'calendar' && first.dashboardSections[0].version === 1) { first.dashboardSections[0].config.calendarUrls.push('https://example.com/a.ics'); } assert.deepEqual(second.dashboardSections[0], { @@ -351,7 +381,7 @@ for (const [description, sections] of [ ['fewer than four sections', defaultDevice('esp32-layout').dashboardSections.slice(0, 3)], ['more than four sections', [...defaultDevice('esp32-layout').dashboardSections, { type: 'empty', version: 1, config: {} }]], ['an unknown widget type', [{ type: 'future-widget', version: 1, config: {} }, ...defaultDevice('esp32-layout').dashboardSections.slice(1)]], - ['an unsupported widget version', [{ type: 'calendar', version: 2, config: { calendarUrls: [] } }, ...defaultDevice('esp32-layout').dashboardSections.slice(1)]], + ['an unsupported widget version', [{ type: 'calendar', version: 99, config: { calendarUrls: [] } }, ...defaultDevice('esp32-layout').dashboardSections.slice(1)]], ['a malformed strict widget config', [{ type: 'calendar', version: 1, config: { calendarUrls: [], extra: true } }, ...defaultDevice('esp32-layout').dashboardSections.slice(1)]], ] as const) { test(`${description} fails closed and preserves the original bytes`, async () => { diff --git a/test/fixtures/existingWidgets.ts b/test/fixtures/existingWidgets.ts new file mode 100644 index 00000000..49caa8bd --- /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/calendarClient.test.ts b/test/homeAssistant/calendarClient.test.ts new file mode 100644 index 00000000..b70c42a1 --- /dev/null +++ b/test/homeAssistant/calendarClient.test.ts @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; + +const token = 'private-supervisor-test-token'; +const start = '2026-08-27T00:00:00+01:00'; +const end = '2026-08-29T00:00:00+01:00'; +const event = { summary: 'Appointment', start: { dateTime: start }, end: { dateTime: end } }; + +test('calendar discovery and events use the shared authenticated base and strip unrelated fields', async () => { + const calls: URL[] = []; + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async (input, init) => { + const url = new URL(String(input)); calls.push(url); + assert.equal(new Headers(init?.headers).get('authorization'), `Bearer ${token}`); + assert.equal(init?.redirect, 'error'); + return Response.json(url.pathname.endsWith('/calendars') + ? [{ entity_id: 'calendar.family', name: 'Family', attributes: { secret: token } }] + : [{ ...event, description: token, location: 'ignored', uid: 'known-uid' }]); + } }); + const discovery = await client.listCalendars(); + assert.deepEqual(discovery, { supported: true, available: true, calendars: [{ entityId: 'calendar.family', name: 'Family' }], error: null }); + const events = await client.getCalendarEvents('calendar.family', start, end); + assert.deepEqual(events, { available: true, data: [{ ...event, uid: 'known-uid' }] }); + assert.equal(calls[0]!.href, 'http://supervisor/core/api/calendars'); + assert.equal(calls[1]!.pathname, '/core/api/calendars/calendar.family'); + assert.equal(calls[1]!.searchParams.get('start'), start); + assert.match(calls[1]!.search, /%2B01%3A00/); + assert.doesNotMatch(JSON.stringify([discovery, events, client.calendarCacheScope]), new RegExp(token)); +}); + +test('disabled discovery is cleanly unavailable and unsafe entity paths never reach fetch', async () => { + let calls = 0; + const fetchImpl = async () => { calls++; return Response.json([]); }; + const disabled = new HomeAssistantClient({ enabled: false, token, fetchImpl }); + assert.deepEqual(await disabled.listCalendars(), { supported: false, available: false, calendars: [], error: null }); + assert.equal(disabled.calendarCacheScope, null); + const enabled = new HomeAssistantClient({ enabled: true, token, fetchImpl }); + for (const id of ['light.home', '../config', 'calendar.a/../config', 'calendar.a%2f..', 'calendar.a?x=1', 'https://evil']) { + assert.equal((await enabled.getCalendarEvents(id, start, end)).available, false); + } + assert.equal(calls, 0); +}); + +test('calendar failures are safe and response shapes are runtime validated', async () => { + for (const fetchImpl of [ + async () => new Response(token, { status: 401 }), + async () => new Response('{not json'), + async () => Response.json([{ ...event, start: { dateTime: 'not a timestamp' } }]), + async () => Response.json([{ ...event, end: { date: '2026-08-28' } }]), + async () => { throw new Error(`request to internal URL failed: ${token}`); }, + ]) { + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl }); + const result = await client.getCalendarEvents('calendar.home', start, end); + assert.equal(result.available, false); + assert.doesNotMatch(JSON.stringify(result), /private-supervisor|internal URL/); + assert.equal((await client.listCalendars()).available, false); + } +}); + +test('calendar request timeout and cancellation use safe shared-client handling', async () => { + const client = new HomeAssistantClient({ enabled: true, token, timeoutMs: 5, fetchImpl: async (_input, init) => new Promise((_resolve, reject) => { + if (init?.signal?.aborted) reject(init.signal.reason); + else init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) }); + assert.deepEqual(await client.getCalendarEvents('calendar.home', start, end), { available: false, error: 'Home Assistant request timed out' }); + assert.deepEqual(await client.getCalendarEvents('calendar.home', start, end, AbortSignal.abort()), { available: false, error: 'Home Assistant request was cancelled' }); +}); diff --git a/test/homeAssistant/client.test.ts b/test/homeAssistant/client.test.ts new file mode 100644 index 00000000..bb8571c4 --- /dev/null +++ b/test/homeAssistant/client.test.ts @@ -0,0 +1,120 @@ +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({ + enabled: false, + fetchImpl: async () => { calls += 1; throw new Error('must not fetch'); }, + }); + assert.deepEqual(await client.status(), { + available: false, mode: 'standalone', version: null, + locationName: null, timeZone: null, error: null, + }); + assert.equal(calls, 0); + assert.equal(isHomeAssistantMode('1'), true); + assert.equal(isHomeAssistantMode('true'), false); + assert.equal((await new HomeAssistantClient({ enabled: false, baseUrl: 'not a URL' }).status()).mode, 'standalone'); +}); + +test('the shared client uses the Supervisor bearer token and normalizes /config', async () => { + let requestedUrl = ''; + let authorization = ''; + const client = new HomeAssistantClient({ + enabled: true, + token: 'super-secret-token', + fetchImpl: async (input, init) => { + requestedUrl = String(input); + authorization = new Headers(init?.headers).get('authorization') ?? ''; + return Response.json({ version: '2026.8.1', location_name: 'Home', time_zone: 'Europe/London' }); + }, + }); + assert.deepEqual(await client.status(), { + available: true, mode: 'home-assistant-app', version: '2026.8.1', + locationName: 'Home', timeZone: 'Europe/London', error: null, + }); + assert.equal(requestedUrl, 'http://supervisor/core/api/config'); + assert.equal(authorization, 'Bearer super-secret-token'); +}); + +test('missing credentials and failures produce safe status without reflecting secrets', async () => { + const missing = new HomeAssistantClient({ enabled: true }); + assert.match((await missing.status()).error ?? '', /token is unavailable/i); + + const token = 'never-reflect-this-token'; + const failed = new HomeAssistantClient({ + enabled: true, + token, + fetchImpl: async () => new Response('denied', { status: 401 }), + }); + const status = await failed.status(); + assert.equal(status.available, false); + assert.equal(status.error, 'Home Assistant request failed (401)'); + assert.doesNotMatch(JSON.stringify(status), new RegExp(token)); + assert.equal((await new HomeAssistantClient({ enabled: true, baseUrl: 'file:///secret', token }).status()).error, + 'Home Assistant base URL is invalid'); +}); + +test('Home Assistant calls have a bounded timeout', async () => { + const client = new HomeAssistantClient({ + enabled: true, + token: 'secret', + timeoutMs: 5, + fetchImpl: async (_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }), + }); + assert.equal((await client.status()).error, 'Home Assistant request timed out'); +}); diff --git a/test/homeAssistant/package.test.ts b/test/homeAssistant/package.test.ts new file mode 100644 index 00000000..2f56f0fa --- /dev/null +++ b/test/homeAssistant/package.test.ts @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +test('repository and immediate App metadata parse and describe the HA-1 boundary', async () => { + 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.12'); + assert.equal(config.panel_admin, true); + for (const privilege of ['hassio_role', 'full_access', 'docker_api', 'auth_api']) assert.equal(config[privilege], undefined); + assert.equal(config.image, 'ghcr.io/ctrlaltcouk/inkpanel-home-assistant'); + assert.deepEqual(config.arch, ['amd64', 'aarch64']); + assert.equal(config.ingress, true); + assert.equal(config.ingress_port, 8099); + assert.equal(config.ports['8080/tcp'], 8080); + assert.equal(config.ports['8443/tcp'], 8443); + assert.equal(config.ports['8099/tcp'], undefined, 'Ingress must never be host-mapped'); + assert.equal(config.homeassistant_api, true); + assert.equal(config.backup, 'cold'); + assert.equal(config.schema.panel_base_url, 'url'); + 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'); + const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + assert.match(dockerfile, new RegExp(`playwright:v${pkg.dependencies.playwright.replace(/^[\\^~]/, '')}-noble`)); + 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, + 'the verified production firmware must remain in the Docker build context'); +}); + +test('the image workflow builds, verifies and embeds production firmware before publishing', async () => { + const workflow = await readFile(join(root, '.github', 'workflows', 'home-assistant-image.yml'), 'utf8'); + const parsed = parse(workflow); + assert.ok(parsed.on.push.branches.includes('Home-Assistant')); + assert.ok(parsed.jobs.init && parsed.jobs.firmware && parsed.jobs.build && parsed.jobs.publish && parsed.jobs['inspect-published']); + assert.match(workflow, /prepare-multi-arch-matrix@4de35182/); + assert.match(workflow, /build-image@4de35182/); + assert.match(workflow, /publish-multi-arch-manifest@4de35182/); + assert.match(workflow, /\["amd64", "aarch64"\]/); + 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/); + assert.deepEqual(parsed.jobs.build.needs, ['init', 'firmware']); + assert.match(workflow, /ref: \$\{\{ github\.sha \}\}/); + assert.match(workflow, /\.\/scripts\/build-firmware\.sh/); + assert.match(workflow, /bash scripts\/verify-firmware-package\.sh/); + assert.match(workflow, /actions\/upload-artifact@v4/); + assert.match(workflow, /actions\/download-artifact@v4/); + assert.match(workflow, /name: inkpanel-production-firmware-\$\{\{ github\.sha \}\}/); + assert.match(workflow, /path: firmware\/dist\/?/); + assert.match(workflow, /load: \$\{\{ github\.event_name == 'pull_request' \}\}/); + assert.match(workflow, /expected_hash="\$\(bash scripts\/firmware-input-hash\.sh\)"/); + assert.match(workflow, /\/app\/scripts\/verify-firmware-package\.sh \/app\/firmware\/dist "\$expected_hash"/); + assert.match(workflow, /docker pull "\$IMAGE_REF"/); + assert.doesNotMatch(workflow, /fixtures?/i, 'release images must use real compiled firmware'); + assert.doesNotMatch(workflow, /github-token:/); +}); + +test('the shared firmware verifier enforces complete, current full-size and Mini packages', async () => { + const verifier = await readFile(join(root, 'scripts', 'verify-firmware-package.sh'), 'utf8'); + const ci = await readFile(join(root, '.github', 'workflows', 'ci.yml'), 'utf8'); + assert.match(verifier, /manifest\.json/); + assert.match(verifier, /mini\/manifest\.json/); + assert.match(verifier, /find .*'\*\.bin'.*-size \+0c/); + assert.match(verifier, /= "full"/); + assert.match(verifier, /= "mini"/); + assert.match(verifier, /input\.sha256/); + assert.match(verifier, /firmware-input-hash\.sh/); + assert.match(verifier, /EXPECTED_INPUT_HASH="\$\{2:-\}"/, + 'an independently calculated hash can validate an image that intentionally has no .git directory'); + assert.match(ci, /bash scripts\/verify-firmware-package\.sh/, + 'normal firmware CI and the Home Assistant release use the same package invariant'); +}); diff --git a/test/homeAssistant/sensors.test.ts b/test/homeAssistant/sensors.test.ts new file mode 100644 index 00000000..75d10bce --- /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/homeAssistant/startup.test.js b/test/homeAssistant/startup.test.js new file mode 100644 index 00000000..391898cf --- /dev/null +++ b/test/homeAssistant/startup.test.js @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizePanelBaseUrl, runtimeEnvironment } from '../../scripts/home-assistant-start.mjs'; + +test('App options become the fixed three-listener runtime environment', () => { + assert.deepEqual(runtimeEnvironment({ + panel_base_url: ' http://192.168.1.20:8080/ ', + lan_password: ' correct horse battery staple ', + }, {}), { + DATA_DIR: '/data', PORT: '8080', PUBLIC_BASE_URL: 'http://192.168.1.20:8080', + INKPANEL_PASSWORD: 'correct horse battery staple', HTTPS_PORT: '8443', + HOME_ASSISTANT_MODE: '1', HOME_ASSISTANT_INGRESS_PORT: '8099', + HOME_ASSISTANT_BASE_URL: 'http://supervisor/core/api', + }); +}); + +test('panel base URL must be a clean LAN origin and LAN password is required', () => { + assert.equal(normalizePanelBaseUrl('https://panel.local:8443/'), 'https://panel.local:8443'); + for (const value of ['ftp://panel.local', 'http://user:pass@panel.local', 'http://panel.local/path', 'not a url']) { + assert.throws(() => normalizePanelBaseUrl(value)); + } + 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/homeAssistant/todo.test.ts b/test/homeAssistant/todo.test.ts new file mode 100644 index 00000000..e498c17d --- /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/homeAssistant/users.test.ts b/test/homeAssistant/users.test.ts new file mode 100644 index 00000000..0b3be379 --- /dev/null +++ b/test/homeAssistant/users.test.ts @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, readdir, stat, rm, mkdir, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseIngressUser } from '../../src/homeAssistant/ingressUser.ts'; +import { HomeAssistantUserStore, homeAssistantUsersV1Schema } from '../../src/homeAssistant/userStore.ts'; + +const chris = { id: 'id-chris', username: 'chris', displayName: 'Chris' }; +test('central identity parser rejects missing, malformed, duplicate and control headers; LAN ignores all headers', () => { + const headers = { 'x-remote-user-id': chris.id, 'x-remote-user-name': ' chris ', 'x-remote-user-display-name': ' Chris ' }; + assert.deepEqual(parseIngressUser({ headers }, true), chris); + assert.equal(parseIngressUser({ headers }, false), null); + for (const id of [undefined, '', ' ', ' x', 'x\n', 'x\u007f', 'x\u0085', 'a'.repeat(129), ['one', 'two']]) { + assert.equal(parseIngressUser({ headers: { ...headers, 'x-remote-user-id': id } }, true), null); + } + for (const name of ['x\t', 'x'.repeat(257), ['one', 'two']]) { + assert.equal(parseIngressUser({ headers: { ...headers, 'x-remote-user-name': name } }, true), null); + } +}); + +test('ownership persists atomically, refreshes metadata by ID only and supports stale mapping removal', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, '.home-assistant-users.json'); + const store = new HomeAssistantUserStore(path); + assert.deepEqual(await store.list(), []); + await store.observe(chris); + await store.assign(chris.id, ['todo.chris', 'todo.removed']); + await store.observe({ ...chris, username: 'new-name', displayName: 'New name' }); + await store.observe({ id: 'new-id', username: 'chris', displayName: 'Chris' }); + assert.equal(await store.assigned(chris.id, 'todo.removed'), true, 'missing entities remain assigned'); + assert.equal(await store.assigned('new-id', 'todo.chris'), false, 'reused names never inherit'); + const restarted = new HomeAssistantUserStore(path); + assert.deepEqual(await restarted.list(), [ + { userId: chris.id, username: 'new-name', displayName: 'New name', todoEntityIds: ['todo.chris', 'todo.removed'] }, + { userId: 'new-id', username: 'chris', displayName: 'Chris', todoEntityIds: [] }, + ]); + const original = await readFile(path, 'utf8'); + await assert.rejects(store.assign('new-id', ['todo.chris']), /duplicate/); + await assert.rejects(store.assign(chris.id, ['todo.chris', 'todo.chris'])); + await assert.rejects(store.assign('unknown', []), /Unknown/); + assert.equal(await readFile(path, 'utf8'), original); + assert.deepEqual(await readdir(dir), ['.home-assistant-users.json']); + if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600); + await store.remove(chris.id); + assert.equal(await restarted.assigned(chris.id, 'todo.chris'), false); + await store.assign('new-id', ['todo.chris']); + assert.equal(await restarted.assigned('new-id', 'todo.chris'), true); +}); + +test('concurrent observations and assignments retain every successful mutation', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const store = new HomeAssistantUserStore(join(dir, 'users.json')); + await Promise.all(Array.from({ length: 15 }, (_, index) => store.observe({ ...chris, id: `user-${index}` }))); + await Promise.all(Array.from({ length: 15 }, (_, index) => store.assign(`user-${index}`, [`todo.user_${index}`]))); + assert.equal((await store.list()).length, 15); + assert.equal((await readdir(dir)).length, 1); +}); + +test('invalid JSON/schema remains untouched, backed up, and cannot be reset by registration', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, 'users.json'); + const store = new HomeAssistantUserStore(path); + for (const raw of ['{bad', JSON.stringify({ version: 2, users: [] }), JSON.stringify({ version: 1, users: [{ userId: chris.id, username: null, displayName: null, todoEntityIds: ['sensor.bad'] }] })]) { + await writeFile(path, raw); + await assert.rejects(store.observe(chris), /original left untouched/); + await assert.rejects(store.assigned(chris.id, 'todo.chris')); + assert.equal(await readFile(path, 'utf8'), raw); + const backups = (await readdir(dir)).filter((name) => name.includes('.corrupt-')); + assert.ok((await Promise.all(backups.map((name) => readFile(join(dir, name), 'utf8')))).includes(raw)); + } +}); + +test('strict ownership format rejects duplicate users, cross-user lists, secrets and contents', () => { + const user = { userId: chris.id, username: null, displayName: null, todoEntityIds: ['todo.chris'] }; + for (const users of [[user, user], [user, { ...user, userId: 'different' }], [{ ...user, token: 'secret' }], [{ ...user, items: ['private'] }]]) { + assert.equal(homeAssistantUsersV1Schema.safeParse({ version: 1, users }).success, false); + } +}); + +test('unreadable ownership pathname fails closed without altering the existing directory', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, 'users.json'); + await mkdir(path); + await assert.rejects(new HomeAssistantUserStore(path).observe(chris), /unavailable/); + assert.deepEqual(await readdir(dir), ['users.json']); +}); + +test('Linux write failure preserves the committed file and later mutation can retry', { + skip: process.platform === 'win32' || process.getuid?.() === 0, +}, async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-write-')); + t.after(async () => { await chmod(dir, 0o700); await rm(dir, { recursive: true, force: true }); }); + const path = join(dir, 'users.json'); + const store = new HomeAssistantUserStore(path); + await store.observe(chris); + const original = await readFile(path, 'utf8'); + await chmod(dir, 0o500); + await assert.rejects(store.assign(chris.id, ['todo.chris']), /Could not commit/); + assert.equal(await readFile(path, 'utf8'), original); + assert.deepEqual(await readdir(dir), ['users.json']); + await chmod(dir, 0o700); + await store.assign(chris.id, ['todo.chris']); + assert.equal(await store.assigned(chris.id, 'todo.chris'), true, 'write queue is not poisoned'); +}); diff --git a/test/http/app.test.ts b/test/http/app.test.ts index 282ee9e6..a6563f8b 100644 --- a/test/http/app.test.ts +++ b/test/http/app.test.ts @@ -38,15 +38,41 @@ test('/api/runtime-config reads current active HTTPS state before the auth gate' const runtimeState = createRuntimeState(); const app = makeApp(undefined, undefined, frames, runtimeState, 'hunter2'); const before = await requestJson(app, '/api/runtime-config'); - assert.deepEqual(before.body, { httpsPort: null }); + assert.deepEqual(before.body, { httpsPort: null, updateMode: 'self' }); runtimeState.httpsPort = 9443; const after = await requestJson(app, '/api/runtime-config'); assert.equal(after.status, 200); - assert.deepEqual(after.body, { httpsPort: 9443 }); + assert.deepEqual(after.body, { httpsPort: 9443, updateMode: 'self' }); assert.equal((await requestJson(app, '/api/devices')).status, 401, '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/deviceRoutes.test.ts b/test/http/deviceRoutes.test.ts index 6eede8ba..4c403ee2 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`); diff --git a/test/http/homeAssistantIngress.test.ts b/test/http/homeAssistantIngress.test.ts new file mode 100644 index 00000000..8bc90feb --- /dev/null +++ b/test/http/homeAssistantIngress.test.ts @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; +import { createApp, directWebFlashUrl } from '../../src/http/app.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import type { FrameService } from '../../src/render/frameService.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; + +const frames = { + warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, +} as unknown as FrameService; + +function app(access: 'lan' | 'trusted-ingress' | 'real-ingress', activeHttps: number | null = null) { + const runtimeState = createRuntimeState(); + runtimeState.httpsPort = activeHttps; + const homeAssistantClient = new HomeAssistantClient({ + enabled: true, + 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' } }, { 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', + }), + }); + return createApp({ + store: new DeviceStore(join('unused', 'config.json')), + frames, + publicBaseUrl: 'http://192.168.1.20:8080', + runtimeState, + dataDir: 'unused', + firmwareDir: 'unused', + auth: { password: 'lan-password', secret: randomBytes(32) }, + updateMode: 'home-assistant', + homeAssistantRelease: 'test-image-release', + homeAssistantClient, + access: access === 'lan' + ? { mode: 'lan' } + : { mode: 'home-assistant-ingress', ...(access === 'trusted-ingress' ? { isTrustedRequest: () => true } : {}) }, + }); +} + +async function requestJson(application: ReturnType, path: string, init?: RequestInit) { + const server = application.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${address.port}${path}`, init); + return { status: response.status, body: await response.json() as Record }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +test('LAN APIs retain InkPanel authentication while trusted Ingress bypasses only that login', async () => { + assert.equal((await requestJson(app('lan'), '/api/home-assistant/status')).status, 401); + const trusted = await requestJson(app('trusted-ingress'), '/api/home-assistant/status'); + assert.equal(trusted.status, 200); + assert.equal(trusted.body.available, true); + assert.equal(trusted.body.locationName, 'Home'); + assert.doesNotMatch(JSON.stringify(trusted.body), /server-only-supervisor-token/); +}); + +test('the production Ingress boundary rejects direct non-Supervisor connections', async () => { + const response = await requestJson(app('real-ingress'), '/api/home-assistant/status', { + headers: { + 'x-ingress-path': '/api/hassio_ingress/forged-token', + 'x-forwarded-for': '172.30.32.2', + }, + }); + assert.equal(response.status, 403); + assert.match(String(response.body.error), /Ingress proxy required/); +}); + +test('calendar discovery uses the existing authentication boundary and returns only safe metadata', async () => { + assert.equal((await requestJson(app('lan'), '/api/home-assistant/calendars')).status, 401); + assert.equal((await requestJson(app('real-ingress'), '/api/home-assistant/calendars')).status, 403); + const result = await requestJson(app('trusted-ingress'), '/api/home-assistant/calendars'); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { supported: true, available: true, calendars: [{ entityId: 'calendar.family', name: 'Family' }], error: null }); + 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('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'); + assert.deepEqual((await requestJson(app('trusted-ingress'), '/api/runtime-config')).body, { + 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', 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', 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'); + assert.equal(directWebFlashUrl('http://user:pass@panel.local:8080/', 8443), null); +}); diff --git a/test/http/homeAssistantUsers.test.ts b/test/http/homeAssistantUsers.test.ts new file mode 100644 index 00000000..879c2b10 --- /dev/null +++ b/test/http/homeAssistantUsers.test.ts @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { createApp } from '../../src/http/app.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import type { FrameService } from '../../src/render/frameService.ts'; +import { HomeAssistantUserStore } from '../../src/homeAssistant/userStore.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; + +test('trusted identity, scoped discovery, admin mappings and shared APIs enforce separate boundaries', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-user-api-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, '.home-assistant-users.json'); + const users = new HomeAssistantUserStore(path); + const store = new DeviceStore(join(dir, 'config.json')); + await store.getOrCreate('panel'); + const token = 'never-return-or-persist-supervisor-token'; + const client = new HomeAssistantClient({ enabled: true, token, fetchImpl: async (url) => Response.json(String(url).endsWith('/calendars') + ? [{ entity_id: 'calendar.shared', name: 'Shared' }] + : [{ entity_id: 'todo.chris', attributes: { friendly_name: 'Chris', token } }, { entity_id: 'todo.other', attributes: { friendly_name: 'Other', token } }, + { entity_id: 'sensor.shared', state: '20', attributes: { friendly_name: 'Shared', token } }]) }); + const frame = { buffer: Buffer.alloc(48000), etag: 'fixed-frame', renderedAt: new Date().toISOString() }; + const deps = { store, frames: { warmUp: async () => {}, enrolmentFrame: async () => frame } as unknown as FrameService, + homeAssistantClient: client, homeAssistantUserStore: users, dataDir: dir, firmwareDir: dir, + publicBaseUrl: 'http://panel.test:8080', runtimeState: createRuntimeState(), auth: { password: null, secret: randomBytes(32) } }; + const servers = ['lan', 'trusted', 'untrusted'].map((mode) => createApp({ ...deps, + access: mode === 'lan' ? { mode: 'lan' } : { mode: 'home-assistant-ingress', ...(mode === 'trusted' ? { isTrustedRequest: () => true } : {}) }, + }).listen(0, '127.0.0.1')); + await Promise.all(servers.map((server) => new Promise((resolve) => server.once('listening', resolve)))); + t.after(() => Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve()))))); + const request = async (index: number, path: string, id?: string, method = 'GET', body?: unknown) => { + const response = await fetch(`http://127.0.0.1:${(servers[index]!.address() as AddressInfo).port}${path}`, { + method, headers: { ...(id === undefined ? {} : { 'x-remote-user-id': id, 'x-remote-user-display-name': 'Chris' }), + 'x-forwarded-for': '172.30.32.2', ...(body ? { 'content-type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: response.status, body: await response.json() }; + }; + assert.equal((await request(2, '/api/home-assistant/current-user', 'chris-id')).status, 403); + assert.deepEqual((await request(0, '/api/home-assistant/current-user', 'forged')).body, { available: false, user: null, accessMode: 'lan' }); + assert.deepEqual(await users.list(), []); + for (const id of [undefined, '', 'x'.repeat(129)]) for (const route of ['current-user', 'my-todo-lists', 'users']) { + assert.equal((await request(1, `/api/home-assistant/${route}`, id)).status, 403); + assert.equal((await request(1, `/api/HOME-ASSISTANT/${route.toUpperCase()}/`, id)).status, 403, + 'identity guard follows the same case/trailing-slash rules as Express routes'); + } + const identity = await request(1, '/api/home-assistant/current-user', 'chris-id'); + assert.deepEqual(identity.body, { available: true, user: { id: 'chris-id', username: null, displayName: 'Chris' } }); + await request(1, '/api/home-assistant/current-user', 'other-id'); + assert.equal((await request(0, '/api/home-assistant/users/chris-id', undefined, 'PUT', { todoEntityIds: ['todo.chris', 'todo.missing'] })).status, 200); + assert.equal((await request(0, '/api/home-assistant/users/other-id', undefined, 'PUT', { todoEntityIds: ['todo.other'] })).status, 200); + const mine = await request(1, '/api/home-assistant/my-todo-lists', 'chris-id'); + assert.deepEqual(mine.body.lists.map((list: { entityId: string }) => list.entityId), ['todo.chris', 'todo.missing']); + assert.doesNotMatch(JSON.stringify(mine.body), /todo.other|other-id|never-return|items/); + assert.equal((await request(0, '/api/home-assistant/my-todo-lists', 'chris-id')).status, 403); + assert.equal((await request(0, '/api/home-assistant/users/other-id', undefined, 'PUT', { todoEntityIds: ['todo.chris'] })).status, 400); + assert.equal((await request(1, '/api/home-assistant/users/chris-id', undefined, 'DELETE')).status, 403); + for (const route of ['calendars', 'sensors']) { + const shared = await request(1, `/api/home-assistant/${route}`); + assert.equal(shared.status, 200, 'household discovery does not require personal identity'); + assert.match(JSON.stringify(shared.body), /shared/); + } + const widget = { type: 'todo', version: 3, config: { provider: 'home-assistant', ownerUserId: 'chris-id', entityId: 'todo.missing' } }; + const dashboardSections = [widget, ...Array.from({ length: 3 }, () => ({ type: 'empty', version: 1, config: {} }))]; + assert.equal((await request(1, '/api/devices/panel', undefined, 'PUT', { dashboardSections })).status, 403); + assert.equal((await request(1, '/api/dashboard-editor/panel', undefined, 'PUT', { slots: [[widget], [], [], []] })).status, 403); + assert.equal((await request(0, '/api/devices/panel', undefined, 'PUT', { dashboardSections })).status, 200); + assert.equal((await request(0, '/api/devices/panel', undefined, 'PUT', { name: 'Still personal' })).status, 200); + assert.deepEqual((await store.get('panel'))!.dashboardSections[0], widget); + for (const route of ['/api/devices', '/api/devices/panel', '/api/devices/panel/preview', '/api/devices/panel/render.png', '/api/devices/panel/frame']) { + assert.equal((await request(1, route)).status, 403, 'missing Ingress identity cannot read personal config or preview'); + } + assert.equal((await request(1, '/api/devices/panel/push', undefined, 'POST')).status, 403); + assert.equal((await request(0, '/api/dashboard-editor/panel', undefined, 'PUT', { slots: [[widget], [], [], []] })).status, 200); + assert.equal((await request(1, '/api/dashboard-editor/panel')).status, 403); + const firmware = await fetch(`http://127.0.0.1:${(servers[0]!.address() as AddressInfo).port}/api/devices/panel/frame`, { headers: { 'x-remote-user-id': 'forged' } }); + assert.equal(firmware.status, 200); + assert.equal((await firmware.arrayBuffer()).byteLength, 48000); + assert.doesNotMatch(await readFile(path, 'utf8'), new RegExp(`${token}|forged`)); + await writeFile(path, '{broken'); + assert.equal((await request(1, '/api/home-assistant/my-todo-lists', 'chris-id')).status, 503); + assert.equal((await request(0, '/api/home-assistant/users/chris-id', undefined, 'DELETE')).status, 503); + assert.equal(await readFile(path, 'utf8'), '{broken'); +}); diff --git a/test/http/manageRoutes.test.ts b/test/http/manageRoutes.test.ts index 52e7c428..844f53f8 100644 --- a/test/http/manageRoutes.test.ts +++ b/test/http/manageRoutes.test.ts @@ -56,6 +56,31 @@ test('lists devices', async () => { }); }); +test('Calendar V1 and V2 save side-by-side without a DeviceStore migration or unrelated rewrite', async () => { + await withServer(async (base, store) => { + await store.getOrCreate('esp32-ha'); + const sections = [ + { type: 'calendar', version: 1, config: { calendarUrls: ['https://legacy.example/feed'] } }, + { type: 'calendar', version: 2, config: { provider: 'ical', calendarUrls: ['https://new.example/feed'] } }, + { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.family', 'calendar.work'] } }, + { type: 'weather', version: 1, config: {} }, + ]; + const save = (body: unknown) => fetch(`${base}/api/devices/esp32-ha`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + assert.equal((await save({ dashboardSections: sections })).status, 200); + assert.equal((await save({ name: 'Renamed' })).status, 200); + assert.deepEqual((await store.get('esp32-ha'))!.dashboardSections, sections); + for (const config of [ + { provider: 'home-assistant', entityIds: ['light.home'] }, + { provider: 'home-assistant', entityIds: ['calendar.home/../../config'] }, + { provider: 'home-assistant', entityIds: ['calendar.home', 'calendar.home'] }, + { provider: 'home-assistant', entityIds: [], calendarUrls: [] }, + { provider: 'ical', calendarUrls: ['ftp://example.com/feed'] }, + { provider: 'ical', calendarUrls: ['https://user:pass@example.com/feed'] }, + ]) assert.equal((await save({ dashboardSections: [{ type: 'calendar', version: 2, config }, ...sections.slice(1)] })).status, 400); + assert.deepEqual((await store.get('esp32-ha'))!.dashboardSections, sections); + }); +}); + test('updates and claims a device', async () => { await withServer(async (base, store) => { await store.getOrCreate('esp32-1'); @@ -219,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'); }); @@ -484,4 +510,4 @@ test('system info reports version and device count', async () => { assert.equal(body.sources.renderedDevices, 0); assert.equal(body.sources.totalDevices, 1); }); -}); \ No newline at end of file +}); diff --git a/test/http/systemRoutes.test.ts b/test/http/systemRoutes.test.ts index 9f2034d8..fc952304 100644 --- a/test/http/systemRoutes.test.ts +++ b/test/http/systemRoutes.test.ts @@ -5,19 +5,26 @@ import { mkdtemp, rm, writeFile, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createApp } from '../../src/http/app.ts'; +import express from 'express'; import { DeviceStore } from '../../src/devices/store.ts'; import type { FrameService } from '../../src/render/frameService.ts'; +import { systemRoutes } from '../../src/http/systemRoutes.ts'; +import type { UpdateMode } from '../../src/system/updateOwnership.ts'; const frames = { warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, } as unknown as FrameService; -async function withServer(fn: (base: string, dataDir: string) => Promise) { +async function withServer( + fn: (base: string, dataDir: string) => Promise, + updateMode: UpdateMode = 'self', +) { const dir = await mkdtemp(join(tmpdir(), 'inkpanel-system-')); const store = new DeviceStore(join(dir, 'config.json')); const server = createApp({ store, frames, publicBaseUrl: 'http://test.local:8080', runtimeState: { httpsPort: null }, dataDir: dir, firmwareDir: dir, + updateMode, auth: { password: null, secret: randomBytes(32) }, }).listen(0); await new Promise((resolve) => server.once('listening', resolve)); @@ -30,6 +37,17 @@ async function withServer(fn: (base: string, dataDir: string) => Promise) } } +async function requestFromApp(application: express.Express, path: string, init?: RequestInit) { + const server = application.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const port = (server.address() as { port: number }).port; + try { + return await fetch(`http://127.0.0.1:${port}${path}`, init); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + test('POST /api/system/update returns 202 and creates the flag file the updater watches', async () => { await withServer(async (base, dataDir) => { const res = await fetch(`${base}/api/system/update`, { method: 'POST' }); @@ -82,3 +100,81 @@ test('GET /api/system/update/status reflects the status file on disk', async () assert.deepEqual(body.log, ['== git pull ==']); }); }); + +test('Home Assistant refuses update mutations without creating request state', async () => { + await withServer(async (base, dataDir) => { + const response = await fetch(`${base}/api/system/update`, { method: 'POST' }); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: 'updates are managed by Home Assistant' }); + await assert.rejects(access(join(dataDir, '.update-requested'))); + }, 'home-assistant'); +}); + +test('Home Assistant update status reports ownership instead of standalone activity', async () => { + await withServer(async (base, dataDir) => { + await writeFile(join(dataDir, 'update-status.json'), JSON.stringify({ + state: 'running', startedAt: '2026-08-04T09:00:00.000Z', finishedAt: null, + log: ['standalone updater state must be ignored'], error: null, + }), 'utf8'); + const response = await fetch(`${base}/api/system/update/status`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { state: 'managed', manager: 'home-assistant' }); + }, 'home-assistant'); +}); + +test('Home Assistant system info skips standalone Git update checks', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-system-managed-')); + let updateChecks = 0; + const app = express(); + app.use('/api', systemRoutes( + new DeviceStore(join(dir, 'config.json')), + frames, + dir, + { + updateMode: 'home-assistant', + updateChecker: async () => { + updateChecks += 1; + throw new Error('standalone Git check must not run'); + }, + }, + )); + try { + const response = await requestFromApp(app, '/api/system/info?refresh=1'); + assert.equal(response.status, 200); + const body = await response.json() as { update: unknown }; + assert.deepEqual(body.update, { state: 'managed', manager: 'home-assistant' }); + assert.equal(updateChecks, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('standalone system info retains normal refreshable update checks', async () => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-system-self-')); + const forcedChecks: boolean[] = []; + const app = express(); + app.use('/api', systemRoutes( + new DeviceStore(join(dir, 'config.json')), + frames, + dir, + { + updateMode: 'self', + updateChecker: async (force) => { + forcedChecks.push(force === true); + return { + state: 'current', local: 'abc1234', remote: 'abc1234', + checkedAt: '2026-08-26T12:00:00.000Z', + }; + }, + }, + )); + try { + const response = await requestFromApp(app, '/api/system/info?refresh=1'); + assert.equal(response.status, 200); + const body = await response.json() as { update: { state: string } }; + assert.equal(body.update.state, 'current'); + assert.deepEqual(forcedChecks, [true]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/http/todoRoutes.test.ts b/test/http/todoRoutes.test.ts index f93b0bdd..e214aa7f 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/https.test.ts b/test/https.test.ts index 5e0c4ba8..007503dd 100644 --- a/test/https.test.ts +++ b/test/https.test.ts @@ -390,14 +390,14 @@ test('active HTTPS port is published only after a successful listener start', as }, ); - assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null }, + assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null, updateMode: 'self' }, 'a requested port must not be advertised while listener startup is still pending'); releaseStart(); const server = await activation; assert.ok(server?.listening); - assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: 9443 }); + assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: 9443, updateMode: 'self' }); await new Promise((resolve) => listener.close(() => resolve())); - assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null }, + assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null, updateMode: 'self' }, 'a stopped listener must no longer be advertised'); } finally { releaseStart(); @@ -439,7 +439,7 @@ test('HTTP and HTTPS port collision keeps HTTP healthy and HTTPS undisclosed', a runtimeState, ); assert.equal(httpsServer, null, 'the already-bound HTTP port must reject the HTTPS listener'); - assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null }); + assert.deepEqual((await getPlainJson(httpPort, '/api/runtime-config')).body, { httpsPort: null, updateMode: 'self' }); assert.equal((await getPlainJson(httpPort, '/health')).status, 200, 'optional HTTPS failure must not affect the primary HTTP service'); } finally { diff --git a/test/indexConfig.test.ts b/test/indexConfig.test.ts index e0f7561f..6c6a55cc 100644 --- a/test/indexConfig.test.ts +++ b/test/indexConfig.test.ts @@ -1,6 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createTrainSourceFromEnv } from '../src/index.ts'; +import { createTrainSourceFromEnv, resolveHomeAssistantIngressPort } from '../src/index.ts'; + +test('Home Assistant Ingress uses its internal default and validates overrides', () => { + assert.equal(resolveHomeAssistantIngressPort(undefined), 8099); + assert.equal(resolveHomeAssistantIngressPort(' 9010 '), 9010); + for (const value of ['0', '65536', '1.5', 'bad']) { + assert.throws(() => resolveHomeAssistantIngressPort(value), /HOME_ASSISTANT_INGRESS_PORT/); + } +}); test('National Rail transport stays optional when no environment is configured', () => { assert.equal(createTrainSourceFromEnv({}), undefined); diff --git a/test/model/hash.test.ts b/test/model/hash.test.ts index 0899e747..a52e9737 100644 --- a/test/model/hash.test.ts +++ b/test/model/hash.test.ts @@ -13,6 +13,21 @@ test('hash excludes render and fetch timestamps', () => { assert.equal(contentHash(base), contentHash(changed)); }); +test('calendar UIDs are hidden while normalized event content changes invalidate the hash', () => { + const base = dashboardData(); + const renamed = structuredClone(base); + if (renamed.sections[0].type === 'calendar') renamed.sections[0].data!.today[0]!.uid = 'new-ha-uid'; + assert.equal(contentHash(base), contentHash(renamed)); + for (const patch of [ + { title: 'Changed title' }, { start: '2026-08-03T08:00:00Z' }, + { end: '2026-08-03T17:00:00Z' }, { allDay: true }, + ]) { + const changed = structuredClone(base); + if (changed.sections[0].type === 'calendar') Object.assign(changed.sections[0].data!.today[0]!, patch); + assert.notEqual(contentHash(base), contentHash(changed)); + } +}); + test('hash includes the displayed minute of a stale badge, but not raw seconds', () => { const base = dashboardData(); if (base.sections[0].type === 'calendar') base.sections[0].health = { id: 'ical', status: 'stale', fetchedAt: '2026-08-03T03:10:01.000Z', error: 'timeout' }; diff --git a/test/public/calendarEditor.test.js b/test/public/calendarEditor.test.js new file mode 100644 index 00000000..71f6c2d0 --- /dev/null +++ b/test/public/calendarEditor.test.js @@ -0,0 +1,54 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { calendarControlsHtml, switchCalendarProvider } from '../../public/calendarEditor.js'; +import { createDashboardDraftState, serialiseDashboardDraftState, serialiseRememberedDashboardDrafts, switchDashboardDraft } from '../../public/dashboardEditor.js'; + +const v1 = { type: 'calendar', version: 1, config: { calendarUrls: ['https://example.com/feed'] } }; +const ha = { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.family'] } }; + +test('draft versions follow active > slot > shared precedence and survive unrelated widget edits', () => { + const slots = createDashboardDraftState([v1, { type: 'empty', version: 1, config: {} }, { type: 'weather', version: 1, config: {} }], { + shared: [ha], slots: [[ha], [{ ...ha, config: { provider: 'ical', calendarUrls: ['https://slot.example/feed'] } }], []], + }); + assert.deepEqual(serialiseDashboardDraftState(slots)[0], v1); + switchDashboardDraft(slots, 1, 'calendar', {}); + assert.deepEqual(serialiseDashboardDraftState(slots)[1], { ...ha, config: { provider: 'ical', calendarUrls: ['https://slot.example/feed'] } }); + switchDashboardDraft(slots, 2, 'calendar', {}); + assert.deepEqual(serialiseDashboardDraftState(slots)[2], ha); + switchDashboardDraft(slots, 1, 'weather', slots[1].drafts.calendar); + assert.deepEqual(serialiseDashboardDraftState(slots)[0], v1); + const remembered = serialiseRememberedDashboardDrafts(slots); + assert.equal(remembered.slots[1].find((widget) => widget.type === 'calendar').version, 2); + assert.equal(remembered.slots[0].find((widget) => widget.type === 'calendar').version, 1); +}); + +test('explicit provider switches upgrade V1 and retain separate provider choices as V2', () => { + const [slot] = createDashboardDraftState([v1], { shared: [ha] }); + switchCalendarProvider(slot, 'home-assistant'); + assert.deepEqual(serialiseDashboardDraftState([slot])[0], ha); + slot.drafts.calendar.entityIds.push('calendar.work'); + switchCalendarProvider(slot, 'ical'); + assert.deepEqual(serialiseDashboardDraftState([slot])[0], { ...v1, version: 2, config: { provider: 'ical', calendarUrls: v1.config.calendarUrls } }); + switchCalendarProvider(slot, 'home-assistant'); + assert.deepEqual(slot.drafts.calendar.entityIds, ['calendar.family', 'calendar.work']); + switchDashboardDraft([slot], 0, 'bins', slot.drafts.calendar); + switchDashboardDraft([slot], 0, 'calendar', { uprn: '123' }); + assert.equal(serialiseDashboardDraftState([slot])[0].version, 2); +}); + +test('Calendar controls retain iCal help and safely show HA discovery, empty, missing, and unavailable states', () => { + const standalone = calendarControlsHtml(v1.config, { supported: false }); + assert.match(standalone, /Secret iCal URLs, one per line/); + assert.match(standalone, /support.google.com/); + assert.doesNotMatch(standalone, /data-calendar-provider/); + const legacy = calendarControlsHtml(v1.config, { supported: true }); + assert.match(legacy, /value="ical" selected/); + const html = calendarControlsHtml(ha.config, { supported: true, available: true, calendars: [{ entityId: 'calendar.work', name: 'Work & Play' }] }); + assert.match(html, /Work & Play/); + assert.match(html, /value="calendar.family" checked/); + assert.match(html, /calendar.family \(missing\/unavailable\)/); + assert.doesNotMatch(html, /Secret iCal|support.google.com/); + assert.match(calendarControlsHtml(ha.config, { supported: true, available: true, calendars: [] }), /No Home Assistant calendars found/); + assert.match(calendarControlsHtml(ha.config, { supported: true, available: false }), /Saved selections are retained/); + assert.match(calendarControlsHtml(ha.config, { supported: false, available: false }), /value="home-assistant" selected disabled/); +}); diff --git a/test/public/calendarEditorUx.test.js b/test/public/calendarEditorUx.test.js new file mode 100644 index 00000000..eb9c7ca3 --- /dev/null +++ b/test/public/calendarEditorUx.test.js @@ -0,0 +1,55 @@ +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('Studio preserves V1 on unrelated saves, selects HA IDs, and retains provider versions and missing IDs', 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-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/public/entitiesEditorUx.test.js b/test/public/entitiesEditorUx.test.js new file mode 100644 index 00000000..e0bfc711 --- /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/public/flash.test.js b/test/public/flash.test.js index f93bf9e8..23b3b136 100644 --- a/test/public/flash.test.js +++ b/test/public/flash.test.js @@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url'; import { serialSupported, httpsUrl, + safeWebFlashUrl, unsupportedNotice, + directWebFlashNotice, noBuildNotice, readyPanel, renderFlash, @@ -107,9 +109,9 @@ test('serialSupported reads navigator.serial, not just truthiness of navigator', }); }); -test('httpsUrl uses the server port and preserves pathname, query and hash', async () => { +test('httpsUrl uses the server port but leaves an Ingress path for the direct Studio root', async () => { await withWindow({ location: { href: 'http://192.168.1.50:8080/manage?mode=new#flash' } }, () => { - assert.equal(httpsUrl(9443), 'https://192.168.1.50:9443/manage?mode=new#flash'); + assert.equal(httpsUrl(9443), 'https://192.168.1.50:9443/#flash'); }); }); @@ -140,7 +142,7 @@ test('an insecure HTTP context on a Chromium browser gets the HTTPS-redirect not withWindow({ isSecureContext: false, location: { href: 'http://192.168.1.50:8080/#flash' } }, () => { const html = unsupportedNotice(9443); assert.equal(occurrences(html, '

Flashing needs a secure connection

'), 1); - assert.equal(occurrences(html, 'Open inkpanel over HTTPS'), 1); + assert.equal(occurrences(html, 'href="https://192.168.1.50:9443/#flash"'), 1); assert.equal(occurrences(html, 'This browser cannot flash boards'), 0); }), ); @@ -171,6 +173,34 @@ test('a secure context with no WebSerial support gets the unsupported-browser no }); }); +test('a secure Home Assistant Ingress context gets the direct WebFlash fallback', async () => { + await withNavigator({ userAgent: CHROME_UA }, () => + withWindow({ isSecureContext: true, location: { href: 'https://ha.local/api/hassio_ingress/token/#flash' } }, () => { + const html = directWebFlashNotice('https://192.168.1.50:8443/#flash'); + assert.match(html, /WebFlash opens in a secure window/); + assert.match(html, /USB flashing needs a direct secure InkPanel connection outside Home Assistant\./); + assert.match(html, />Open WebFlash<\/a>/); + assert.match(html, /Your browser may show the local certificate warning the first time\./); + assert.match(html, /href="https:\/\/192\.168\.1\.50:8443\/#flash"/); + assert.match(html, /target="_blank" rel="noopener"/); + assert.doesNotMatch(html, /come back/i); + assert.doesNotMatch(html, /This browser cannot flash boards/); + }), + ); +}); + +test('the explicit Ingress notice validates its direct WebFlash URL', () => { + const safe = directWebFlashNotice('https://192.168.1.50:8443/#flash'); + assert.match(safe, /class="button-link"/); + assert.match(safe, /Open WebFlash/); + assert.doesNotMatch(safe.replace(/href="[^"]+"/, ''), /192\.168\.1\.50/, + 'the private address is only exposed as the validated link destination'); + const unsafe = directWebFlashNotice('https://user:pass@panel.local:8443/#flash'); + assert.match(unsafe, /no HTTPS address has been guessed/); + assert.doesNotMatch(unsafe, /user:pass/); + assert.doesNotMatch(unsafe, / { // The URL API itself percent-encodes `<`, `>` and `"` in a fragment, so a // hash built from those alone would look "safe" even with esc() missing — @@ -179,11 +209,13 @@ test('the HTTPS notice link target is escaped rather than interpolated raw', asy // distinguishes an escaped link from a raw one here. await withNavigator({ userAgent: CHROME_UA }, () => withWindow( - { isSecureContext: false, location: { href: 'http://192.168.1.50:8080/#flash&reload=1' } }, + { isSecureContext: false, location: { href: 'http://ingress.local/api/hassio_ingress/token/#flash' } }, () => { - const html = unsupportedNotice(9443); - assert.equal(occurrences(html, 'href="https://192.168.1.50:9443/#flash&reload=1"'), 1); - assert.equal(html.includes('href="https://192.168.1.50:9443/#flash&reload=1"'), false); + const html = unsupportedNotice(null, 'https://192.168.1.50:8443/#flash&reload=1'); + assert.equal(occurrences(html, 'href="https://192.168.1.50:8443/#flash&reload=1"'), 1); + assert.equal(html.includes('href="https://192.168.1.50:8443/#flash&reload=1"'), false); + assert.equal(safeWebFlashUrl('javascript:alert(1)'), null); + assert.equal(safeWebFlashUrl('https://user:pass@panel.local/'), null); }, ), ); @@ -218,19 +250,19 @@ test('readyPanel escapes manifest fields rather than interpolating them raw', () // "Firefox + HTTPS" quadrant: secure context, but the browser itself lacks // WebSerial, so no URL change can help. test('renderFlash shows the unsupported-browser notice and never calls the manifest API when WebSerial is simply absent', async () => { - let fetchCalled = false; + const fetched = []; await withNavigator({ userAgent: FIREFOX_UA }, () => withWindow({ isSecureContext: true }, () => withFetch( - async () => { - fetchCalled = true; - throw new Error('renderFlash must not fetch the manifest when WebSerial is unavailable'); + async (path) => { + fetched.push(path); + return { status: 200, ok: true, json: async () => ({ httpsPort: 8443 }) }; }, async () => { const root = { innerHTML: '' }; await renderFlash(root); assert.equal(occurrences(root.innerHTML, 'This browser cannot flash boards'), 1); - assert.equal(fetchCalled, false); + assert.deepEqual(fetched, ['/api/runtime-config']); }, ), ), @@ -239,8 +271,8 @@ test('renderFlash shows the unsupported-browser notice and never calls the manif // "Chrome + HTTP" quadrant: the browser would support WebSerial, but the // origin is not a secure context, so the fix is the HTTPS link. -test('renderFlash shows the HTTPS-redirect notice when a Chromium browser lacks serial because the page is on plain HTTP', async () => { - await withNavigator({ userAgent: CHROME_UA }, () => +test('direct HTTP LAN shows the HTTPS redirect even if Chromium exposes navigator.serial', async () => { + await withNavigator({ userAgent: CHROME_UA, serial: {} }, () => withWindow({ isSecureContext: false, location: { href: 'http://192.168.1.50:8080/path?q=1#flash' } }, () => withFetch( async (path) => { @@ -251,7 +283,7 @@ test('renderFlash shows the HTTPS-redirect notice when a Chromium browser lacks const root = { innerHTML: '' }; await renderFlash(root); assert.equal(occurrences(root.innerHTML, 'Flashing needs a secure connection'), 1); - assert.equal(occurrences(root.innerHTML, 'https://192.168.1.50:9443/path?q=1#flash'), 1); + assert.equal(occurrences(root.innerHTML, 'https://192.168.1.50:9443/#flash'), 1); assert.equal(occurrences(root.innerHTML, 'This browser cannot flash boards'), 0); }, ), @@ -259,6 +291,71 @@ test('renderFlash shows the HTTPS-redirect notice when a Chromium browser lacks ); }); +test('HA Ingress with navigator.serial absent always shows direct WebFlash and never loads firmware', async () => { + const fetched = []; + await withNavigator({ userAgent: CHROME_UA }, () => + withWindow({ isSecureContext: true, location: { + href: 'https://ha.local/api/hassio_ingress/token/#flash', + pathname: '/api/hassio_ingress/token/', + } }, () => + withFetch( + async (path) => { + fetched.push(path); + assert.equal(path, '/api/hassio_ingress/token/api/runtime-config'); + return { + status: 200, + ok: true, + json: async () => ({ + httpsPort: 8443, + accessMode: 'home-assistant-ingress', + webFlashUrl: 'https://192.168.1.50:8443/#flash', + }), + }; + }, + async () => { + const root = { innerHTML: '' }; + await renderFlash(root); + assert.match(root.innerHTML, /WebFlash opens in a secure window/); + assert.match(root.innerHTML, /https:\/\/192\.168\.1\.50:8443\/#flash/); + assert.deepEqual(fetched, ['/api/hassio_ingress/token/api/runtime-config']); + }, + ), + ), + ); +}); + +test('HA Ingress with navigator.serial present still shows direct WebFlash and never activates the flasher', async () => { + const fetched = []; + await withNavigator({ serial: { requestPort: async () => { throw new Error('must not activate WebSerial'); } }, userAgent: CHROME_UA }, () => + withWindow({ isSecureContext: true, location: { + href: 'https://ha.local/api/hassio_ingress/token/#flash', + pathname: '/api/hassio_ingress/token/', + } }, () => + withFetch( + async (path) => { + fetched.push(path); + return { + status: 200, + ok: true, + json: async () => ({ + httpsPort: 8443, + accessMode: 'home-assistant-ingress', + webFlashUrl: 'https://192.168.1.50:8443/#flash', + }), + }; + }, + async () => { + const root = { innerHTML: '' }; + await renderFlash(root); + assert.match(root.innerHTML, /WebFlash opens in a secure window/); + assert.doesNotMatch(root.innerHTML, /Flash or configure a panel/); + assert.deepEqual(fetched, ['/api/hassio_ingress/token/api/runtime-config']); + }, + ), + ), + ); +}); + test('runtime-config failure shows no guessed HTTPS port', async () => { await withNavigator({ userAgent: CHROME_UA }, () => withWindow({ isSecureContext: false, location: { href: 'http://192.168.1.50:8080/#flash' } }, () => @@ -297,35 +394,46 @@ test('inactive HTTPS runtime state shows no dead redirect', async () => { // false and the browser lacks WebSerial, but only the unsupported-browser // notice is correct — an HTTPS link would not give Firefox WebSerial. test('renderFlash shows the unsupported-browser notice, not the HTTPS-redirect notice, for Firefox on plain HTTP', async () => { - let fetchCalled = false; + const fetched = []; await withNavigator({ userAgent: FIREFOX_UA }, () => withWindow({ isSecureContext: false, location: { href: 'http://192.168.1.50:8080/#flash' } }, () => withFetch( - async () => { - fetchCalled = true; - throw new Error('renderFlash must not fetch the manifest when WebSerial is unavailable'); + async (path) => { + fetched.push(path); + return { status: 200, ok: true, json: async () => ({ httpsPort: 8443 }) }; }, async () => { const root = { innerHTML: '' }; await renderFlash(root); assert.equal(occurrences(root.innerHTML, 'This browser cannot flash boards'), 1); assert.equal(occurrences(root.innerHTML, 'Flashing needs a secure connection'), 0); - assert.equal(fetchCalled, false); + assert.deepEqual(fetched, ['/api/runtime-config']); }, ), ), ); }); -test('renderFlash shows the no-firmware notice when the manifest reports unavailable', async () => { +test('direct HTTPS LAN with navigator.serial present renders the normal WebFlash flow', async () => { + const fetched = []; await withNavigator({ serial: {} }, () => - withFetch( - async () => ({ status: 200, ok: true, json: async () => ({ available: false }) }), - async () => { - const root = { innerHTML: '' }; - await renderFlash(root); - assert.equal(occurrences(root.innerHTML, 'No firmware has been built'), 1); - }, + withWindow({ isSecureContext: true, location: { href: 'https://192.168.1.50:8443/#flash', pathname: '/' } }, () => + withFetch( + async (path) => { + fetched.push(path); + const body = path === '/api/runtime-config' + ? { httpsPort: 8443, accessMode: 'lan', webFlashUrl: 'https://192.168.1.50:8443/#flash' } + : { available: false }; + return { status: 200, ok: true, json: async () => body }; + }, + async () => { + const root = { innerHTML: '' }; + await renderFlash(root); + assert.equal(occurrences(root.innerHTML, 'No firmware has been built'), 1); + assert.doesNotMatch(root.innerHTML, /direct secure Studio/); + assert.deepEqual(fetched, ['/api/runtime-config', '/api/firmware/manifest']); + }, + ), ), ); }); diff --git a/test/public/homeAssistantUsersUx.test.js b/test/public/homeAssistantUsersUx.test.js new file mode 100644 index 00000000..e49444f6 --- /dev/null +++ b/test/public/homeAssistantUsersUx.test.js @@ -0,0 +1,112 @@ +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 { DeviceStore } from '../../src/devices/store.ts'; +import { HomeAssistantClient } from '../../src/homeAssistant/client.ts'; +import { HomeAssistantUserStore } from '../../src/homeAssistant/userStore.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; +import { homeAssistantUsersHtml } from '../../public/homeAssistantUsers.js'; + +const release = parse(await readFile(new URL('../../home-assistant/config.yaml', import.meta.url), 'utf8')).version; +test('ownership Settings explains observed-user registration and empty assignments without task contents', () => { + assert.match(homeAssistantUsersHtml([], [], null), /Open InkPanel through Home Assistant Ingress using the account you want to register/); + assert.match(homeAssistantUsersHtml([{ userId: 'owner', displayName: 'Owner', todoEntityIds: [] }], [], null), /No personal To Do lists assigned/); +}); +test('real Ingress Studio supports explicit personal conversion, owner-filtered choices, provider memory and admin Settings', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-users-ux-')); + const store = new DeviceStore(join(dir, 'config.json')); + const users = new HomeAssistantUserStore(join(dir, 'users.json')); + await users.observe({ id: 'chris', username: 'chris', displayName: 'Chris' }); + await users.observe({ id: 'other', username: 'other', displayName: 'Other' }); + await users.assign('chris', ['todo.chris', 'todo.missing']); + await users.assign('other', ['todo.other']); + const legacy = { type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.shared' } }; + await store.getOrCreate('panel', 'ssd1681-200x200-mono'); + await store.update('panel', { claimed: true, dashboardSections: [legacy] }); + const client = new HomeAssistantClient({ enabled: true, token: 'no-browser-secret', fetchImpl: async (url) => { + if (String(url).endsWith('/config')) return Response.json({ version: '2026.8.1', location_name: 'Home', time_zone: 'Europe/London' }); + return Response.json(['chris', 'other', 'shared', 'unassigned'].map((id) => ({ entity_id: `todo.${id}`, attributes: { friendly_name: id, token: 'no-browser-secret' } }))); + } }); + const frame = { buffer: Buffer.alloc(5000, 255), etag: 'a'.repeat(32), renderedAt: new Date().toISOString() }; + const frames = { warmUp: async () => {}, sourceIssues: () => [], renderedDeviceCount: () => 0, frameFor: async () => frame, renderNow: async () => frame }; + const prefix = '/api/hassio_ingress/personal-test'; + const app = express(); + app.use(prefix, createApp({ store, frames, homeAssistantClient: client, homeAssistantUserStore: users, + publicBaseUrl: 'http://panel.test:8080', runtimeState: createRuntimeState(), dataDir: dir, firmwareDir: dir, + auth: { password: null, secret: randomBytes(32) }, updateMode: 'home-assistant', homeAssistantRelease: release, + 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 }); + t.after(async () => { await browser.close(); await new Promise((resolve) => server.close(resolve)); await rm(dir, { recursive: true, force: true }); }); + const page = await browser.newPage({ extraHTTPHeaders: { 'x-remote-user-id': 'chris', 'x-remote-user-name': 'chris', 'x-remote-user-display-name': 'Chris' } }); + const base = `http://127.0.0.1:${server.address().port}${prefix}/`; + await page.goto(`${base}?inkpanel_release=${release}`); + await page.locator('[data-todo-make-personal]').waitFor(); + assert.match(await page.locator('#dashboard-editor').textContent(), /Legacy shared Home Assistant To Do/); + assert.equal(await page.locator('#save-state').textContent(), 'All changes saved'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction(() => document.querySelector('#save-state')?.textContent === 'All changes saved'); + assert.deepEqual((await store.get('panel')).dashboardSections[0], legacy, 'unrelated save never attaches current user'); + await page.locator('[data-todo-make-personal]').click(); + assert.equal(await page.locator('[data-ha-todo-owner]').inputValue(), 'chris'); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), '', 'legacy entity is never inferred as personal'); + const values = () => page.locator('[data-ha-todo-list] option').evaluateAll((options) => options.map((option) => option.value)); + assert.deepEqual(await values(), ['', 'todo.chris', 'todo.missing']); + assert.match(await page.locator('[data-ha-todo-list]').textContent(), /missing\/unavailable/); + await page.locator('[data-ha-todo-owner]').selectOption('other'); + assert.deepEqual(await values(), ['', 'todo.other']); + await page.locator('[data-ha-todo-list]').selectOption('todo.other'); + await page.locator('[data-todo-provider]').selectOption('local'); + await page.locator('[data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('[data-ha-todo-owner]').inputValue(), 'other'); + assert.equal(await page.locator('[data-ha-todo-list]').inputValue(), 'todo.other'); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction(() => document.querySelector('#save-state')?.textContent === 'All changes saved'); + assert.deepEqual((await store.get('panel')).dashboardSections[0], { type: 'todo', version: 3, config: { provider: 'home-assistant', ownerUserId: 'other', entityId: 'todo.other' } }); + await page.reload(); + await page.locator('[data-ha-todo-owner]').waitFor(); + assert.equal(await page.locator('[data-ha-todo-owner]').inputValue(), 'other', 'current browser user cannot replace saved owner'); + await page.goto(`${base}#settings`); + await page.locator('[data-ha-users]').waitFor(); + assert.match(await page.locator('[data-ha-users]').textContent(), /Signed in through Home Assistant as Chris/); + assert.match(await page.locator('[data-ha-users]').textContent(), /Other InkPanel data remains shared/); + const card = page.locator('[data-ha-user="chris"]'); + await card.locator('summary').click(); + assert.equal(await card.locator('input[value="todo.other"]').isDisabled(), true); + assert.equal(await card.locator('input[value="todo.missing"]').isChecked(), true); + await card.locator('input[value="todo.unassigned"]').check(); + await card.locator('[data-save-assignments]').click(); + await page.waitForFunction(() => !document.querySelector('[data-ha-user="chris"]')?.open); + assert.equal(await users.assigned('chris', 'todo.unassigned'), true); + assert.doesNotMatch(await page.content(), /no-browser-secret/); + const other = page.locator('[data-ha-user="other"]'); + await other.locator('summary').click(); + page.once('dialog', (dialog) => dialog.accept()); + await other.locator('[data-remove-user]').click(); + await other.waitFor({ state: 'detached' }); + assert.equal(await users.assigned('other', 'todo.other'), false); + + // New-widget creation uses the same production editor module as Studio. + await page.evaluate(async ({ release, prefix }) => { + const { renderDashboardEditor, collectDashboardSections, collectRememberedDashboardSettings } = await import(`${prefix}/assets/${release}/dashboardEditor.js`); + const root = document.createElement('div'); root.id = 'new-editor'; document.body.append(root); + renderDashboardEditor(root, { id: 'new', panelProfileId: 'ssd1681-200x200-mono', dashboardSections: [{ type: 'todo', version: 1, config: { listId: '' } }] }, {}, {}, {}, {}, [], [], {}, + { supported: true, personalSupported: true, available: true, currentUser: { id: 'chris' }, + users: [{ userId: 'chris', displayName: 'Chris', todoEntityIds: ['todo.chris'] }], + lists: [{ entityId: 'todo.chris', name: 'Chris' }, { entityId: 'todo.other', name: 'Other' }] }); + window.collectPersonal = () => ({ sections: collectDashboardSections(root), remembered: collectRememberedDashboardSettings(root) }); + }, { release, prefix }); + await page.locator('#new-editor [data-todo-provider]').selectOption('home-assistant'); + assert.equal(await page.locator('#new-editor [data-ha-todo-owner]').inputValue(), 'chris'); + assert.deepEqual(await page.locator('#new-editor [data-ha-todo-list] option').evaluateAll((options) => options.map((option) => option.value)), ['', 'todo.chris']); + await page.locator('#new-editor [data-ha-todo-list]').selectOption('todo.chris'); + assert.deepEqual((await page.evaluate(() => window.collectPersonal())).sections[0], { type: 'todo', version: 3, config: { provider: 'home-assistant', ownerUserId: 'chris', entityId: 'todo.chris' } }); +}); diff --git a/test/public/panelsReliability.test.js b/test/public/panelsReliability.test.js new file mode 100644 index 00000000..882c5b3a --- /dev/null +++ b/test/public/panelsReliability.test.js @@ -0,0 +1,226 @@ +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 { createApp } from '../../src/http/app.ts'; +import { DeviceStore } from '../../src/devices/store.ts'; +import { createRuntimeState } from '../../src/runtimeConfig.ts'; +import { HomeAssistantUserStore } from '../../src/homeAssistant/userStore.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, realEntry = false } = {}, run) { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-studio-reliability-')); + const store = new DeviceStore(join(dir, 'config.json')); + const users = new HomeAssistantUserStore(join(dir, 'users.json')); + await users.observe({ id: 'test-user', username: null, displayName: 'Test user' }); + await users.assign('test-user', ['todo.shopping']); + 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', { 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 = { + 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', + homeAssistantRelease: appConfig.version, homeAssistantUserStore: users, + ...(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({ extraHTTPHeaders: { 'x-remote-user-id': 'test-user' } }); + 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)); + await rm(dir, { recursive: true, force: true }); + } +} + +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']) { + 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); + } + }); + }); + + 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 new file mode 100644 index 00000000..70382aec --- /dev/null +++ b/test/public/paths.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +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('/'), '/'); + assert.equal(browserBasePath('/index.html'), '/'); + assert.equal(appPath('/api/devices', '/'), '/api/devices'); +}); + +test('one helper prefixes APIs, pages and images under arbitrary Ingress paths', () => { + const ingress = '/api/hassio_ingress/opaque-session-token/'; + assert.equal(browserBasePath(ingress), ingress); + assert.equal(appPath('/api/devices', ingress), `${ingress}api/devices`); + assert.equal(appPath('/login.html', `${ingress}index.html`), `${ingress}login.html`); + 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'); + const panels = await readFile(`${root}panels.js`, '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\)\}"/); + 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\('\/'\)/); +}); diff --git a/test/public/router.test.js b/test/public/router.test.js index e6eccddb..ae1343d5 100644 --- a/test/public/router.test.js +++ b/test/public/router.test.js @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resolveRouteName } from '../../public/router.js'; +import { + fallbackRouteForUpdateMode, + removeManagedUpdateNavigation, + resolveRouteName, + routesForUpdateMode, +} from '../../public/router.js'; const ROUTES = { panels: () => {}, settings: () => {} }; @@ -20,3 +25,29 @@ test('an unrecognised hash resolves to the fallback, not itself', () => { // view while leaving every tab unhighlighted. assert.equal(resolveRouteName('#foo', ROUTES, 'panels'), 'panels'); }); + +test('Home Assistant removes Updates navigation and the updater route', () => { + let removed = false; + const root = { querySelector: () => ({ remove: () => { removed = true; } }) }; + const routes = { ...ROUTES, updates: () => 'standalone updater' }; + const available = routesForUpdateMode(routes, 'home-assistant'); + removeManagedUpdateNavigation(root, 'home-assistant'); + + assert.equal(removed, true); + assert.equal('updates' in available, false); + const fallback = fallbackRouteForUpdateMode('#updates', 'home-assistant'); + assert.equal(fallback, 'settings'); + assert.equal(resolveRouteName('#updates', available, fallback), 'settings'); +}); + +test('standalone keeps Updates navigation and routing unchanged', () => { + let removed = false; + const root = { querySelector: () => ({ remove: () => { removed = true; } }) }; + const routes = { ...ROUTES, updates: () => 'standalone updater' }; + const available = routesForUpdateMode(routes, 'self'); + removeManagedUpdateNavigation(root, 'self'); + + assert.equal(removed, false); + assert.equal(available, routes); + assert.equal(resolveRouteName('#updates', available, 'panels'), 'updates'); +}); diff --git a/test/public/settings.test.js b/test/public/settings.test.js index 5ff4eb66..1a621a7b 100644 --- a/test/public/settings.test.js +++ b/test/public/settings.test.js @@ -1,6 +1,33 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isCurrentStatus } from '../../public/settings.js'; +import { isCurrentStatus, settingsView } from '../../public/settings.js'; + +const info = { + version: '0.1.0', commit: null, uptimeSeconds: 60, deviceCount: 0, freeBytes: 1024, + sources: { issues: [], renderedDevices: 0, totalDevices: 0 }, +}; + +test('Settings reports standalone, connected and safely unavailable Home Assistant states', () => { + assert.match(settingsView(info, { mode: 'standalone', available: false }), /Not running as a Home Assistant App/); + const connected = settingsView(info, { + mode: 'home-assistant-app', available: true, version: '2026.8.1', + locationName: 'Home', timeZone: 'Europe\/London', error: null, + }); + assert.match(connected, /Connected/); + assert.match(connected, /Core 2026\.8\.1/); + assert.match(connected, /Europe\/London/); + assert.match(connected, /Updates are managed by Home Assistant\./); + const unavailable = settingsView(info, { + mode: 'home-assistant-app', available: false, error: 'Home Assistant request failed (401)', + }); + assert.match(unavailable, /Unavailable/); + assert.match(unavailable, /request failed \(401\)/); + assert.match(unavailable, /Updates are managed by Home Assistant\./); + assert.doesNotMatch( + settingsView(info, { mode: 'standalone', available: false }), + /Updates are managed by Home Assistant\./, + ); +}); test('a status with no startedAt is not current — including the idle default', () => { assert.equal(isCurrentStatus({ state: 'idle', startedAt: null }, '2026-08-04T12:00:00.000Z'), false); diff --git a/test/public/studioAssets.test.js b/test/public/studioAssets.test.js new file mode 100644 index 00000000..33bd2954 --- /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', '`)); + 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/entitiesFrameService.test.ts b/test/render/entitiesFrameService.test.ts new file mode 100644 index 00000000..7b2ce6c0 --- /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 00000000..881a3d73 --- /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 00000000..cfc33744 --- /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/render/homeAssistantCalendar.test.ts b/test/render/homeAssistantCalendar.test.ts new file mode 100644 index 00000000..1801eb94 --- /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/render/homeAssistantTodo.test.ts b/test/render/homeAssistantTodo.test.ts new file mode 100644 index 00000000..2557ae43 --- /dev/null +++ b/test/render/homeAssistantTodo.test.ts @@ -0,0 +1,162 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, readFile, rm, writeFile } 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 { HomeAssistantUserStore } from '../../src/homeAssistant/userStore.ts'; +import { runHomeAssistantTodo } from '../../src/sources/homeAssistantTodo.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/); + device.dashboardSections = device.dashboardSections.map(() => ({ type: 'todo', version: 3, config: { provider: 'local', listId: list.id } })); + assert.equal((await frames.frameFor(device, null)).etag, first.etag, 'V3 local has identical visible pixels'); + } + } finally { await renderer.close(); await rm(dir, { recursive: true, force: true }); } +}); + +test('personal V3 uses fixed assignment, identical pixels, no substitution or stale replay on both profiles', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-personal-frame-')); + const renderer = new Renderer(); + t.after(async () => { await renderer.close(); await rm(dir, { recursive: true, force: true }); }); + const path = join(dir, 'users.json'); + const users = new HomeAssistantUserStore(path); + await users.observe({ id: 'owner', username: null, displayName: 'Owner' }); + await users.observe({ id: 'browser-user', username: null, displayName: 'Browser user' }); + const requests: string[] = []; + let items = ['First personal task', 'Second', 'Third', 'Fourth', 'Fifth', 'Hidden sixth']; + const client = new HomeAssistantClient({ enabled: true, token: 'secret-token', fetchImpl: async (_url, init) => { + const id = JSON.parse(String(init?.body)).entity_id; + requests.push(id); + return Response.json({ changed_states: [], service_response: { [id]: { items: items.map((summary) => ({ summary, status: 'needs_action' })) } } }); + } }); + const frames = new FrameService({ renderer, cache: new SourceCache(join(dir, 'cache')), weatherSource, homeAssistantClient: client, homeAssistantUserStore: users }); + for (const profile of ['wft0583-800x480-mono', 'ssd1681-200x200-mono'] as const) { + await users.assign('browser-user', []); + await users.assign('owner', ['todo.personal']); + items = ['First personal task', 'Second', 'Third', 'Fourth', 'Fifth', 'Hidden sixth']; + const device = defaultDevice(`personal-${profile}`, profile); + device.dashboardSections = device.dashboardSections.map(() => ({ type: 'todo', version: 2, config: { provider: 'home-assistant', entityId: 'todo.personal' } })); + const shared = await frames.frameFor(device, null); + device.dashboardSections = device.dashboardSections.map(() => ({ type: 'todo', version: 3, config: { provider: 'home-assistant', ownerUserId: 'owner', entityId: 'todo.personal' } })); + assert.equal((await frames.frameFor(device, null)).etag, shared.etag, 'ownership metadata never enters the pixel hash'); + await users.observe({ id: 'browser-user', username: 'owner', displayName: 'Owner' }); + assert.equal((await frames.frameFor(device, null)).etag, shared.etag, 'observed browser identity cannot select panel contents'); + assert.doesNotMatch(await frames.previewHtml(device), /Hidden sixth|secret-token/); + items = []; + assert.match(await frames.previewHtml(device), /ALL DONE|All done/); + await users.assign('owner', []); + const before = requests.length; + assert.doesNotMatch(await frames.previewHtml(device), /First personal task|ALL DONE|All done/); + await frames.frameFor(device, null); + assert.equal(requests.length, before, 'revoked ownership does not fetch'); + assert.ok(frames.sourceIssues().some((issue) => issue.deviceId === device.id && issue.sourceId.includes('home-assistant-todo'))); + await users.assign('browser-user', ['todo.personal']); + assert.doesNotMatch(await frames.previewHtml(device), /ALL DONE|All done/); + assert.equal(requests.length, before, 'reassignment cannot substitute a new owner'); + } + const before = requests.length; + await writeFile(path, '{invalid'); + const outcome = await runHomeAssistantTodo('todo.personal', client, { deviceId: 'p', timeoutMs: 1000 }, { ownerUserId: 'owner', store: users }); + assert.equal(outcome.data, null); + assert.equal(outcome.health.status, 'error'); + assert.equal(requests.length, before); + assert.ok(requests.every((id) => id === 'todo.personal')); +}); + +test('revocation during an in-flight personal fetch discards the returned tasks', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-revoke-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const store = new HomeAssistantUserStore(join(dir, 'users.json')); + await store.observe({ id: 'owner', username: null, displayName: null }); + await store.assign('owner', ['todo.personal']); + const client = new HomeAssistantClient({ enabled: true, token: 'secret', fetchImpl: async () => { + await store.assign('owner', []); + return Response.json({ changed_states: [], service_response: { 'todo.personal': { items: [{ summary: 'Private', status: 'needs_action' }] } } }); + } }); + const outcome = await runHomeAssistantTodo('todo.personal', client, { deviceId: 'p', timeoutMs: 1000 }, { ownerUserId: 'owner', store }); + assert.equal(outcome.data, null); + assert.equal(outcome.health.status, 'error'); +}); diff --git a/test/sources/homeAssistantCalendar.test.ts b/test/sources/homeAssistantCalendar.test.ts new file mode 100644 index 00000000..579c8f04 --- /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 00000000..0d33e8d1 --- /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 b28074e7..1933898d 100644 --- a/test/widgets/editorPreferences.test.ts +++ b/test/widgets/editorPreferences.test.ts @@ -12,6 +12,69 @@ function emptySlots(): DashboardEditorSlots { return [[], [], [], []]; } +test('personal To Do is remembered only for its panel while Calendar and Sensors remain shared', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'inkpanel-personal-prefs-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, 'preferences.json'); + const store = new DashboardEditorPreferencesStore(path); + const slots = emptySlots(); + slots[0] = [ + { type: 'todo', version: 3, config: { provider: 'home-assistant', ownerUserId: 'owner', entityId: 'todo.personal' } }, + { type: 'todo', version: 2, config: { provider: 'local', listId: 'home' } }, + { type: 'calendar', version: 2, config: { provider: 'home-assistant', entityIds: ['calendar.shared'] } }, + { type: 'entities', version: 1, config: { entityIds: ['sensor.shared'] } }, + ]; + await store.set('panel', slots); + const loaded = new DashboardEditorPreferencesStore(path); await loaded.load(); + assert.deepEqual(loaded.get('panel').slots, slots); + assert.deepEqual(loaded.get('other').shared, slots[0].slice(1)); +}); + +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[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/entities.test.ts b/test/widgets/entities.test.ts new file mode 100644 index 00000000..1d28a9fd --- /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 }); } +}); diff --git a/test/widgets/todoRegistry.test.ts b/test/widgets/todoRegistry.test.ts index ffefb02f..587a6c78 100644 --- a/test/widgets/todoRegistry.test.ts +++ b/test/widgets/todoRegistry.test.ts @@ -30,3 +30,36 @@ 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); +}); + +test('To Do V3 strictly validates fixed owner/entity without live discovery or a store migration', () => { + for (const config of [{ provider: 'local', listId: 'home' }, { provider: 'home-assistant', ownerUserId: 'user-id', entityId: 'todo.no_longer_exists' }]) { + const widget = { type: 'todo', version: 3, config }; + assert.deepEqual(dashboardWidgetSchema.parse(widget), widget); + } + for (const config of [ + { provider: 'home-assistant', entityId: 'todo.home' }, + { provider: 'home-assistant', ownerUserId: '', entityId: 'todo.home' }, + { provider: 'home-assistant', ownerUserId: 'bad\n', entityId: 'todo.home' }, + { provider: 'home-assistant', ownerUserId: 'a'.repeat(129), entityId: 'todo.home' }, + { provider: 'home-assistant', ownerUserId: 'user', entityId: '' }, + { provider: 'home-assistant', ownerUserId: 'user', entityId: 'sensor.home' }, + ]) assert.equal(dashboardWidgetSchema.safeParse({ type: 'todo', version: 3, config }).success, false); + assert.equal(CURRENT_DEVICE_STORE_SCHEMA_VERSION, 3); +});