diff --git a/.claude/skills/refresh-docs-screenshots/SKILL.md b/.claude/skills/refresh-docs-screenshots/SKILL.md new file mode 100644 index 00000000000..93db88ca4a3 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/SKILL.md @@ -0,0 +1,307 @@ +--- +name: refresh-docs-screenshots +description: Refresh the PNG screenshots under `docs/_images/` by driving the Z-Wave JS UI in Chromium (Playwright) against the local mock-server fleet, optionally augmented by a hand-crafted `fakeNodes.json` for state-rich shots (mesh, route health, lock schedules, etc). Use when the user asks to "refresh docs screenshots", "update doc images", "regenerate docs/_images/*.png", or invokes /refresh-docs-screenshots. Captures both light and dark themes for paired images and pre-seeds demo data before each run. +--- + +# Refresh Docs Screenshots + +Drives a headless Chromium (Playwright) against the local Z-Wave JS UI to regenerate the PNG screenshots that `docs/**/*.md` references. The capture itself is done by `capture.mjs`; this file orchestrates seed → start stack → run script → restore. `manifest.json` holds the per-image recipe (route, theme, viewport, dialog steps). + +## When to use + +The user asks to refresh, regenerate, or update one or more PNGs under `docs/_images/`. Use this skill end-to-end. Do **not** use it for the GIFs or MP4s — those are out of scope (deferred manual recordings). + +## Strategy: fakeNodes-only fleet + +For screenshots we deliberately **avoid** the full `mock-server/` fleet (the `.cjs` directory used by `npm run fake-stick`). Instead: + +- **Mock-server stub** (`seed/mock-server/001-stub.cjs`) — config with `nodes: []`. Just enough to satisfy the driver so it reaches "Driver is ready" in <5s. No interviews, no waiting. +- **fakeNodes.json** (loaded by `ZwaveClient.loadFakeNodes()` at `api/lib/ZwaveClient.ts:7866`) — a hand-crafted flat array of `ZUINode` objects pushed straight into `_nodes`, bypassing the driver. Every screenshot's data lives here. + +Why this beats running the real fleet: + +| | Real `mock-server` fleet | fakeNodes-only | +|---|---|---| +| Boot time | 30–45 s (32 mocks interviewed) | <5 s (zero nodes) | +| `statistics.lwr/nlwr` routing | Never populated by the driver | You hand-craft it — mesh graph has real edges | +| User codes / schedules / battery levels | Empty | Pre-filled | +| Clicking "Send" / "Refresh" on a value | Works | Silent no-op (UI updates locally only) | +| Coverage of weird device types | What's in the .cjs files | What you write | + +The "Send/Refresh doesn't actually drive anything" limitation is acceptable — these are screenshots, not e2e tests. The real `mock-server/` fleet stays in the repo for unit/integration tests; this skill just opts out of it. + +The seed at `seed/fakeNodes.seed.json` ships with the skill and covers the screenshot fleet (lights, sensors, lock with schedule, thermostat, smoke detector, and a properly routed mesh). Extend it for new shots — the user's working `fakeNodes.json` (gitignored, see node 199) is a great schema reference. + +## Prerequisites you must verify before capturing + +1. CWD is the repo root (`zwave-js-ui`). +2. Playwright is installed inside the skill (one-time, ~145 MB total — playwright lib + bundled Chromium): + ```bash + ( cd .claude/skills/refresh-docs-screenshots && npm run setup ) + ``` + The dep is intentionally isolated to the skill's own `package.json` so the heavy Chromium download isn't pulled by every contributor running the root `npm install`. If `.claude/skills/refresh-docs-screenshots/node_modules/playwright` already exists, skip this step. +3. The dev servers are NOT already bound to the ports you need. Ports: `5555` (mock-server), `8091` (backend, also serves the built frontend). If `dist/index.html` already exists, **you don't need to start `npm run dev` (vite) on 8092** — the backend serves the built bundle directly on `8091`, which is faster and doesn't show the Vite hot-reload overlay. If `dist/` is missing, run `npm run build:ui` once (slow first time, then incremental). +4. Routes are **hash-prefixed**: capture.mjs navigates to `http://127.0.0.1:8091/#/control-panel`, NOT `http://127.0.0.1:8091/control-panel`. The frontend uses `vue-router` in hash mode; non-hash paths get redirected back to `/`. The script prepends `/#` automatically — keep manifest entries' `route` as `/control-panel`. + +## High-level workflow + +``` +1. snapshot store + fakeNodes.json +2. seed demo data (writes to store/ and optionally fakeNodes.json) +3. start mock-server, backend (background) +4. wait until backend serves http://127.0.0.1:8091/ AND a few mock nodes are visible +5. run capture.mjs — it iterates manifest.json and writes PNGs to docs/_images/ +6. stop the dev processes (graceful, by PID) +7. restore the user's original store/ + fakeNodes.json +``` + +## Step 1 — Snapshot and seed + +Before starting the backend, snapshot **everything** that can affect the UI, then write seed copies. Three files matter: + +```bash +mkdir -p /tmp/zwjs-ui-store-backup +cp -a store/scenes.json /tmp/zwjs-ui-store-backup/scenes.json 2>/dev/null || true +cp -a store/settings.json /tmp/zwjs-ui-store-backup/settings.json 2>/dev/null || true +cp -a fakeNodes.json /tmp/zwjs-ui-store-backup/fakeNodes.json 2>/dev/null || true + +# scenes — always seed (the file ships with the skill) +cp .claude/skills/refresh-docs-screenshots/seed/scenes.json store/scenes.json + +# settings — overwrite if zwave.port doesn't already point at tcp://127.0.0.1:5555 +# (the user's real settings often points at /dev/serial/by-id/...). Use the +# minimal template that disables auth, MQTT, backups; keep security keys. + +# fakeNodes — depends on what you're capturing this run: +# - capturing only "no-state" shots (control panel, settings, scenes, debug, +# gateway values, config updates dialog) → MOVE the existing one aside so +# the table reflects only the mock-server fleet: +# mv fakeNodes.json fakeNodes.json.disabled +# - capturing mesh / route-health / lock-schedule shots → +# REPLACE with a hand-crafted seed: +# cp .claude/skills/refresh-docs-screenshots/seed/fakeNodes.seed.json fakeNodes.json +``` + +The seed file is named `fakeNodes.seed.json` (not `fakeNodes.json`) because the +repo's `.gitignore` line 89 catches the bare filename — the seed needs a different +name so it can be checked in. Always rename to `fakeNodes.json` when copying. + +Always restore at the end (`cp /tmp/zwjs-ui-store-backup/* .` to repo root for `fakeNodes.json` and to `store/` for the others; rename `fakeNodes.json.disabled` back). + +### Why `fakeNodes.json` matters even for "no-state" shots + +The user's real `fakeNodes.json` may carry node IDs that **collide** with our mock-server fleet (1..32) and silently overwrite mock-fleet entries with stale, unrelated data. It also adds extra rows that inflate the "X of N" pager. Either move it aside or replace it — never leave the user's working copy in place during a screenshot run. + +## Step 2 — Settings template + +The user's `store/settings.json` typically points at a real USB controller. Snapshot it (Step 1), then write a minimal version that targets the fake-stick and disables anything that complicates the UI (auth, MQTT, backups, hass discovery). Keep the security keys; the driver expects them to be present. + +```jsonc +{ + "zwave": { + "port": "tcp://127.0.0.1:5555", + "enabled": true, + "logEnabled": true, "logToFile": false, "logLevel": "debug", + "serverEnabled": true, "serverPort": 4000, + "commandsTimeout": 30, "maxNodeEventsQueueSize": 100, + "enableStatistics": false, // suppress the usage-stats opt-in dialog + "disclaimerVersion": 1, // suppress the deprecation banner + "securityKeys": { /* keep the user's existing keys here */ } + }, + "mqtt": { "name": "zwavejs2mqtt", "host": "localhost", "port": 1883, + "qos": 1, "prefix": "zwave", "reconnectPeriod": 3000, + "retain": true, "clean": true, "disabled": true }, + "gateway": { + "type": 0, "payloadType": 0, "nodeNames": true, + "hassDiscovery": false, "discoveryPrefix": "homeassistant", + "authEnabled": false, + "logEnabled": false, "logLevel": "info", "logToFile": false, + "disableChangelog": true, // suppress changelog dialog after upgrades + "notifyNewVersions": false // suppress "new version" snackbar + }, + "backup": { "storeBackup": false, "nvmBackup": false, "enabled": false }, + "ui": { "navTabs": false, "showTabLabels": false, "compactMode": false, "streamerMode": false, "browserTitle": "Z-Wave JS UI" } +} +``` + +The three suppression flags (`enableStatistics`, `disableChangelog`, `notifyNewVersions`) are non-obvious but critical — without them you get a stats opt-in dialog and/or a changelog dialog covering every screenshot. See `src/App.vue:1260` (stats) and `src/App.vue:1700` (changelog). + +**Do NOT include `ui.colorScheme` in the seed.** The store's `initSettings()` (`src/stores/base.js:519`) does `Object.assign(this.ui, conf.ui)` — anything present in server-side `ui.*` clobbers the localStorage value Playwright sets via `addInitScript`. Omit the key entirely and `setColorScheme(this.ui.colorScheme)` falls back to whatever the init script wrote. Likewise, leave Z-Wave logs **enabled at debug level** (in-memory, `logToFile: false`) — the Debug page (`/debug`) only renders log lines that arrive *after* mount, so a quiet driver produces a blank screenshot. + +Note: the backend will rewrite this file on startup (compacts it, fills defaults). That's fine. + +## Step 3 — Start the stack + +Use the **stub** mock-server (zero nodes, fast boot), not `npm run fake-stick`: + +```bash +nohup npx mock-server -- -c .claude/skills/refresh-docs-screenshots/seed/mock-server > /tmp/zwjs-fake.log 2>&1 & +nohup npm run dev:server > /tmp/zwjs-server.log 2>&1 & +``` + +Wait for readiness: + +```bash +# stub mock-server (instant) +grep -q "Server listening on tcp://" /tmp/zwjs-fake.log + +# backend HTTP +until curl -s --max-time 2 -o /dev/null -w "%{http_code}" http://127.0.0.1:8091/ | grep -q "^200$"; do sleep 1; done +``` + +Driver readiness with zero nodes takes **<5 s**, so by the time the backend serves HTTP 200, the fakeNodes are loaded and visible. No need for long settle waits before the first capture. + +## Step 4 — Run the capture script + +`capture.mjs` does the per-image loop: theme → navigate → preActions → `page.screenshot({ path })`. Invoke it from the skill directory: + +```bash +( cd .claude/skills/refresh-docs-screenshots && node capture.mjs ) +# or, equivalent: ( cd .claude/skills/refresh-docs-screenshots && npm run capture ) +``` + +It iterates `manifest.json`, writes PNGs to `docs/_images/.png`, and prints one status line per image (`new` / `updated` / `dry-run` / `error: …`). Exit code 2 on any error. + +### Manifest contract + +Each entry in `manifest.json`: + +```jsonc +{ + "filename": "control_panel_dark.png", // dark file (or theme-agnostic file) + "lightFilename": "control_panel.png", // optional — written when theme === 'light' + "themes": ["dark", "light"], // omit for theme-agnostic images + "route": "/control-panel", // capture.mjs prepends "/#" + "viewport": { "width": 1440, "height": 900 }, // overrides defaults.viewport + "clip": ".v-overlay--active.v-dialog .v-card",// optional — see "Clipping" below + "preActions": [ + { "wait": "table tbody tr" }, + { "settle": 800 } + ] +} +``` + +`preActions` types implemented in `capture.mjs`: + +- `{ "wait": "" }` — `locator.waitFor({ state: 'visible' })`, 10 s cap. +- `{ "click": "" }` — `locator.click()`, 10 s cap. Optional `position: { x, y }` for canvas / area clicks. +- `{ "settle": }` — `page.waitForTimeout(ms)`. +- `{ "scrollTo": "" }` — `locator.scrollIntoViewIfNeeded()`. +- `{ "exec": "" }` — `page.evaluate(\`(async () => { })()\`)`. Escape hatch. Use this to drive route changes (`window.location.hash = '#/debug'`), to mutate the Pinia store, or to drive the mesh graph (see "Test hooks" below). +- `{ "clearAppState": true }` — clears all `localStorage` *except* `colorScheme`, then navigates back to the same hash route. Use when the previous shot left a sticky detail panel / pagination state in the table. + +Theme handling lives outside `preActions`: capture.mjs sets `localStorage.colorScheme` via Playwright's `addInitScript` (runs before page scripts) **and** sets the BrowserContext's `colorScheme` (so `prefers-color-scheme` matches when the app falls back to `'system'`). Note: settings stores primitives as plain strings (`src/modules/Settings.js:65`); never `JSON.stringify` the theme value. + +### Toasts / snackbars are auto-dismissed + +`capture.mjs` clears **every** visible snackbar/toast immediately before each screenshot — `takeScreenshot()` calls `dismissToasts(page)` first. So manifest authors never need to account for transient toasts: the `API … called` info toast that fires on every socket request, one-off error toasts (e.g. `getNodeNeighbors` against a UI-only fake node), the "new version" snackbar, etc. all get swept before the shutter. + +Mechanism: capture.mjs dispatches a `zwave:dismiss-snackbars` DOM event; `src/App.vue` listens for it (registered in `mounted()`) and runs `dismissAllSnackbars()`, which calls `toast.dismiss()` with no id — vuetify-sonner clears the whole stack. capture.mjs then waits (≤2 s) for the `[data-sonner-toast]` nodes to leave the DOM, so the exit animation finishes before capture. If the running build predates the App.vue hook the event is a harmless no-op and capture continues. + +Do **not** add `preActions` that click toast close buttons — dismissal is centralized and runs for every shot. + +### Clipping (partial-screen screenshots) + +Default behavior is a viewport-sized screenshot. To capture only part of the page, add a `clip` field to the entry: + +- `"clip": ""` — selector form. capture.mjs calls `page.locator(sel).first().screenshot({ path })` — the bounding box of the matched element. Best when you want a UI element (a dialog, an icon-with-badge, a panel). +- `"clip": { "x": , "y": , "width": , "height": }` — rect form. Forwarded as `page.screenshot({ path, clip })`. Use when there's no convenient single ancestor (the v-badge floats outside its anchor, etc.). + +Examples in the current manifest: `config_updates_icon` (badge+icon only) and `config_updates_dialog` (just the dialog card, not the dimmed page behind). + +### Test hooks (Vue/Pinia introspection) + +Production builds strip `__vueParentComponent` and minify component names, so blind DOM walks to find the Vue instance don't work. Two hooks are available: + +1. **Pinia store** — Vue 3's `app.mount()` writes the app instance to `document.querySelector('#app').__vue_app__`. From there, the Pinia plugin exposes `app.config.globalProperties.$pinia._s` (a `Map`). Example: bump the appInfo state to make the "config updates available" badge appear: + ```js + const store = document.querySelector('#app').__vue_app__ + .config.globalProperties.$pinia._s.get('base') + store.appInfo.newConfigVersion = '1.99.0' + ``` + `_s` is a Pinia internal but stable across Pinia 2.x. The store id (`'base'`) is the literal string in `defineStore('base', …)` — survives minification. + +2. **`window.__zwGraph`** (mesh graph) — the `vis-network` Network instance and its hosting Vue component, exposed by `src/components/custom/ZwaveGraph.vue` only when `localStorage.exposeZwaveGraph` is set. capture.mjs sets that flag automatically. Use this to deterministically select a node — vis-network's hit-testing on canvas clicks is non-deterministic under physics layout, so coordinate clicks won't reliably land on a node: + ```js + const g = window.__zwGraph + const n = g.allNodes.find(x => x.id === 4) + g.$emit('node-click', n) + ``` + The parent (`Mesh.vue`) has `@node-click="nodeClick"` so emitting on the graph component drives the same code path as a real click. Use this for `mesh-selected` and `route_health_result`. + +### Compare to baseline + +After capture.mjs exits, run: + +```bash +git status --porcelain docs/_images +``` + +and report which files changed vs. were unchanged. Don't commit them — the user reviews and commits. + +## State persists across navigation — be defensive + +Each manifest entry runs in a fresh Playwright `BrowserContext`, so `localStorage` and `sessionStorage` are clean. But the **backend** pushes a few pieces of state through socket-io on every client connect: + +- The selected row in the Control Panel table (and the bottom detail panel). +- Pagination cursor (e.g. "21-30 of 32") and sort order. + +That state lives in `store/` files on disk, not in the browser — a fresh context won't fix it. Mitigations: + +1. Move/replace `fakeNodes.json` BEFORE the backend starts. If you swap it while the backend is running, restart `dev:server` so the in-memory `_nodes` map gets rebuilt cleanly. +2. For shots that need a specific selection (or no selection), put it in `preActions`: a `clearAppState` clears localStorage, and an explicit `click` then puts the table in the state you want. Don't trust whatever the backend last persisted. +3. If the table opens at the wrong page, add `{ "exec": "localStorage.clear()" }` followed by a navigate-equivalent action — capture.mjs will already be on the route, so a `clearAppState` (which re-navigates) is the right primitive. + +## Step 5 — Output and cleanup + +After the loop: + +1. Print a per-image summary: `filename | theme | resolution | status (new/changed/unchanged)`. +2. Stop background processes by PID. Use `lsof -tiTCP:5555 -sTCP:LISTEN` and `lsof -tiTCP:8091 -sTCP:LISTEN` to find them — don't `pkill node` (would clobber the user's IDE / unrelated tasks). +3. Restore from `/tmp/zwjs-ui-store-backup/`: + ```bash + cp -a /tmp/zwjs-ui-store-backup/scenes.json store/scenes.json || true + cp -a /tmp/zwjs-ui-store-backup/settings.json store/settings.json || true + cp -a /tmp/zwjs-ui-store-backup/fakeNodes.json fakeNodes.json || true + # if you renamed instead of replacing: + [ -f fakeNodes.json.disabled ] && mv fakeNodes.json.disabled fakeNodes.json + ``` +4. Tell the user which docs pages reference the changed images (so they can manually verify the rendered docs). + +## Failure modes — read these before troubleshooting + +- **Both `_dark.png` and the light counterpart render dark (or both light)**: the seed `settings.json` has a `ui.colorScheme` key. The store's `initSettings()` does `Object.assign(this.ui, conf.ui)` and clobbers the localStorage value Playwright sets. Remove the key — let the BrowserContext + init-script combo win. (See "Step 2 — Settings template".) +- **All images come back blank / black**: theme didn't apply. The `colorScheme` key stores plain strings (`'dark'`/`'light'`) — do NOT JSON-quote. capture.mjs already does this correctly via `addInitScript`; if you ever switch to manual JS injection, match that behavior. +- **Debug page (`/debug`) screenshot shows an empty log area**: Z-Wave logs are off in the seed (`logEnabled: false`). Debug.vue starts with an empty buffer and only renders lines that arrive *after* mount, so a quiet driver = empty screenshot. Set `zwave.logEnabled: true, logLevel: 'debug', logToFile: false` in the seed and use a navigation primer (visit `/control-panel` first, then `window.location.hash = '#/debug'`) to give the buffer a few hundred ms of fresh stream. +- **"X of N" shows more nodes than the mock fleet**: the user's `fakeNodes.json` is still in place. Move it aside (`mv fakeNodes.json fakeNodes.json.disabled`) or replace with the seed. +- **Sticky bottom detail panel ("Send Options", "Device …") on the Control Panel**: socket pushed a previously-selected node. Move `fakeNodes.json` aside *before* the backend starts; if it's already running, send `clearAppState` and re-navigate. +- **Mesh diagram is empty even with `fakeNodes.json` seeded**: the canvas is rendered async after the WebSocket settles. Bump `settle` to 2500 ms+ for `mesh_*.png` entries. Also confirm `node.statistics.lwr.repeaters[]` exists in the seed — without it the graph has no edges. +- **`mesh-selected.png` / `route_health_result.png` show the graph but no popup**: the test hook isn't loaded. capture.mjs sets `localStorage.exposeZwaveGraph='1'` before page load, but if the build is older than the source change in `ZwaveGraph.vue` that introduced the hook, `window.__zwGraph` won't exist. Run `npm run build:ui` to refresh `dist/`, then capture again. Don't fall back to coordinate-based canvas clicks — vis-network's physics layout is non-deterministic, and the click will land on empty space about half the time. +- **A toast/snackbar still leaks into a shot**: capture.mjs dismisses toasts via the `zwave:dismiss-snackbars` event (see "Toasts / snackbars are auto-dismissed"). If one survives, the running build is older than the App.vue hook — rebuild with `npm run build:ui` and re-capture. A toast that *re-fires* after dismissal (a request completing between dismiss and shutter) is rare; add a short `{ "settle": 400 }` as the last preAction so the request settles first. +- **`config_updates_*` shots show no badge**: the Pinia store mutation didn't take. Check that `document.querySelector('#app').__vue_app__` is non-null at exec time (i.e. `wait` for at least one app-rendered selector first). Pinia store id is `'base'`; `_s.get('base')` is the access path. +- **Dialog screenshots show the page behind, not the dialog**: the click selector resolved but the modal hadn't transitioned. Add `{ "wait": ".v-overlay--active" }` after the click. Vuetify 3 uses `.v-overlay--active`, not `.v-dialog--active`. +- **Backend serves blank or 404 for `/`**: bundle missing — run `npm run build:ui` once before starting `dev:server`. + +## Args + +Two layers of args: + +**Skill-level** (you parse and act on these before invoking `capture.mjs`): +- `--seed-fakenodes` — force-replace `fakeNodes.json` with `seed/fakeNodes.seed.json` even for shots that don't strictly need it (e.g. when curating a richer Control Panel). Default: replace only when capturing entries flagged in the "needs fakeNodes" matrix above. + +**Script-level** (forwarded to `capture.mjs`): +- `--only=name1,name2` — refresh just these manifest entries (match by filename minus `.png`). +- `--theme=dark|light|both` — override per-entry themes. +- `--dry-run` — run preActions but skip writing PNGs (validate selectors). +- `--list` — print entry names and exit. + +If invoked with no args, refresh **all** manifest entries. + +## Don'ts + +- Don't capture the GIFs/MP4s — out of scope. +- Don't touch `Home_Assistant_sketch.png` — it's hand-drawn artwork. +- Don't refresh the orphan PNGs (`troubleshoot_*.png`, `hass_devices.png`) without the user's explicit OK — they may be deliberately unwired pending a docs refactor. +- Don't commit the regenerated PNGs. Print the diff and let the user decide. +- Don't merge the user's `fakeNodes.json` with the seed — node ID collisions silently overwrite mock-server fleet entries. Snapshot+replace, never append. +- Don't reintroduce `unknown-device.png` — the user dropped it from the manifest because the trimmed view (only Id/Product/Product code rows) wasn't useful in the docs. diff --git a/.claude/skills/refresh-docs-screenshots/capture.mjs b/.claude/skills/refresh-docs-screenshots/capture.mjs new file mode 100644 index 00000000000..40dedd523c5 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/capture.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +import { chromium } from 'playwright' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const SKILL_DIR = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(SKILL_DIR, '../../..') +const OUT_DIR = path.join(REPO_ROOT, 'docs/_images') +const MANIFEST_PATH = path.join(SKILL_DIR, 'manifest.json') +// Backend serves the built bundle on 8091; routes are hash-prefixed (vue-router hash mode). +const BASE_URL = process.env.SCREENSHOT_BASE_URL || 'http://127.0.0.1:8091' + +const args = parseArgs(process.argv.slice(2)) +if (args.help) { + console.log(USAGE) + process.exit(0) +} + +const manifest = JSON.parse(await fs.readFile(MANIFEST_PATH, 'utf8')) +const defaults = manifest.defaults || {} +const allEntries = manifest.entries || [] + +if (args.list) { + for (const e of allEntries) console.log(stripPng(e.filename)) + process.exit(0) +} + +const entries = args.only + ? allEntries.filter((e) => args.only.includes(stripPng(e.filename))) + : allEntries + +if (entries.length === 0) { + console.error('No matching manifest entries.') + process.exit(1) +} + +const browser = await chromium.launch() +const results = [] + +try { + for (const entry of entries) { + const themes = resolveThemes(entry, args.theme) + for (const theme of themes) { + const filename = pickFilename(entry, theme) + const out = path.join(OUT_DIR, filename) + const status = await captureOne(entry, theme, out) + results.push({ filename, theme, status, path: out }) + console.log(`${pad(status, 12)} ${filename} (${theme})`) + } + } +} finally { + await browser.close() +} + +const failed = results.filter((r) => r.status.startsWith('error')) +console.log(`\n${results.length - failed.length}/${results.length} captured`) +if (failed.length) process.exit(2) + +async function captureOne(entry, theme, outPath) { + const viewport = entry.viewport || defaults.viewport + // Set prefers-color-scheme on the context so loadColorScheme()'s 'system' + // fallback (and Vuetify's media-query reader) lines up with the requested + // theme. The localStorage write is the explicit override; the context + // option is the safety net. + const ctx = await browser.newContext({ viewport, colorScheme: theme }) + await ctx.addInitScript((t) => { + try { + localStorage.setItem('colorScheme', t) + // Activates the test hooks gated in the app source (see + // src/components/custom/ZwaveGraph.vue → window.__zwGraph) so we can + // drive vis-network programmatically. + localStorage.setItem('exposeZwaveGraph', '1') + } catch {} + }, theme) + const page = await ctx.newPage() + try { + // vue-router runs in hash mode — `/control-panel` lives at `/#/control-panel`. + const url = `${BASE_URL}/#${entry.route}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }) + if (defaults.settleAfterRoute) { + await page.waitForTimeout(defaults.settleAfterRoute) + } + for (const a of entry.preActions || []) { + await runAction(page, a) + } + if (args.dryRun) return 'dry-run' + await fs.mkdir(path.dirname(outPath), { recursive: true }) + const before = await safeStat(outPath) + await takeScreenshot(page, entry, outPath) + return before == null ? 'new' : 'updated' + } catch (e) { + const msg = (e.message || String(e)).split('\n')[0] + return `error: ${msg.slice(0, 80)}` + } finally { + await ctx.close() + } +} + +// Default: viewport screenshot. `entry.clip` accepts: +// - "" → locator.screenshot (element box) +// - { x, y, width, height } → page.screenshot with rect +// - { selector, padding } → expand element box by `padding` +// (in CSS px) on every side. Use +// this when the element has visible +// overflow children (e.g. a v-badge +// floating outside its wrapper). +async function takeScreenshot(page, entry, outPath) { + await dismissToasts(page) + const clip = entry.clip + if (typeof clip === 'string') { + await page.locator(clip).first().screenshot({ path: outPath }) + } else if (clip && typeof clip === 'object' && clip.selector) { + const box = await page.locator(clip.selector).first().boundingBox() + if (!box) throw new Error(`clip selector matched no box: ${clip.selector}`) + const pad = clip.padding ?? 0 + await page.screenshot({ + path: outPath, + clip: { + x: Math.max(0, box.x - pad), + y: Math.max(0, box.y - pad), + width: box.width + pad * 2, + height: box.height + pad * 2, + }, + }) + } else if (clip && typeof clip === 'object') { + await page.screenshot({ path: outPath, clip }) + } else { + await page.screenshot({ path: outPath }) + } +} + +// Clear any transient snackbar/toast before capturing. Stray "API … called" +// info toasts and one-off error toasts (e.g. getNodeNeighbors against a UI-only +// fake node) otherwise leak into shots. Fires the App.vue `zwave:dismiss-snackbars` +// hook (→ dismissAllSnackbars), then waits for sonner's exit animation to remove +// the toast nodes. The timeout+catch is a safety net — a missing hook (old build) +// must not abort the capture. +async function dismissToasts(page) { + await page.evaluate(() => { + document.dispatchEvent(new CustomEvent('zwave:dismiss-snackbars')) + }) + await page + .waitForFunction(() => !document.querySelector('[data-sonner-toast]'), null, { + timeout: 2000, + }) + .catch(() => {}) +} + +async function runAction(page, a) { + if (a.wait) { + await page.locator(a.wait).first().waitFor({ state: 'visible', timeout: 10000 }) + } else if (a.click) { + const opts = { timeout: 10000 } + if (a.position) opts.position = a.position // { x, y } relative to element top-left + await page.locator(a.click).first().click(opts) + } else if (a.settle != null) { + await page.waitForTimeout(a.settle) + } else if (a.scrollTo) { + await page.locator(a.scrollTo).first().scrollIntoViewIfNeeded({ timeout: 10000 }) + } else if (a.exec) { + await page.evaluate(`(async () => { ${a.exec} })()`) + } else if (a.clearAppState) { + // Clear all localStorage except colorScheme, then re-navigate to the same hash route. + await page.evaluate(() => { + const keep = localStorage.getItem('colorScheme') + localStorage.clear() + if (keep != null) localStorage.setItem('colorScheme', keep) + }) + await page.goto(page.url(), { waitUntil: 'domcontentloaded' }) + } +} + +function resolveThemes(entry, override) { + const base = entry.themes || defaults.themes || ['dark'] + if (!override || override === 'both') return base + return base.includes(override) ? [override] : [] +} + +function pickFilename(entry, theme) { + if (theme === 'light' && entry.lightFilename) return entry.lightFilename + return entry.filename +} + +function stripPng(s) { + return s.replace(/\.png$/, '') +} + +async function safeStat(p) { + try { return (await fs.stat(p)).size } catch { return null } +} + +function pad(s, n) { + return s.length >= n ? s : s + ' '.repeat(n - s.length) +} + +function parseArgs(argv) { + const out = { only: null, theme: null, dryRun: false, list: false, help: false } + for (const a of argv) { + if (a === '--dry-run') out.dryRun = true + else if (a === '--list') out.list = true + else if (a === '--help' || a === '-h') out.help = true + else if (a.startsWith('--only=')) out.only = a.slice(7).split(',').map((s) => s.trim()).filter(Boolean) + else if (a.startsWith('--theme=')) out.theme = a.slice(8) + else { + console.error(`Unknown arg: ${a}`) + console.error(USAGE) + process.exit(1) + } + } + return out +} + +const USAGE = `Usage: node capture.mjs [options] + --only=name1,name2 Only capture these entries (filename minus .png) + --theme=dark|light|both Override per-entry themes + --dry-run Run preActions but skip writing PNGs + --list Print entry names and exit + -h, --help Show this help + +Env: + SCREENSHOT_BASE_URL Override base URL (default http://127.0.0.1:8091) +` diff --git a/.claude/skills/refresh-docs-screenshots/manifest.json b/.claude/skills/refresh-docs-screenshots/manifest.json new file mode 100644 index 00000000000..f2117785c4f --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/manifest.json @@ -0,0 +1,139 @@ +{ + "defaults": { + "viewport": { "width": 1440, "height": 900 }, + "themes": ["dark"], + "settleAfterRoute": 1200 + }, + "entries": [ + { + "filename": "control_panel_dark.png", + "themes": ["dark", "light"], + "lightFilename": "control_panel.png", + "route": "/control-panel", + "preActions": [ + { "wait": "table tbody tr" }, + { "settle": 800 } + ] + }, + { + "filename": "settings_dark.png", + "themes": ["dark", "light"], + "lightFilename": "settings.png", + "route": "/settings", + "preActions": [ + { "wait": ".v-expansion-panels" }, + { "settle": 600 } + ] + }, + { + "filename": "scenes.png", + "route": "/scenes", + "preActions": [ + { "wait": "table tbody tr" }, + { "click": "table tbody tr:first-child" }, + { "settle": 400 } + ] + }, + { + "filename": "mesh_diagram.png", + "route": "/mesh", + "preActions": [ + { "wait": "canvas" }, + { "settle": 2500 } + ] + }, + { + "filename": "mesh-selected.png", + "route": "/mesh", + "preActions": [ + { "wait": "canvas" }, + { "settle": 3000 }, + { "exec": "const g = window.__zwGraph; const n = g.allNodes.find(n => n.id === 4); g.$emit('node-click', n);" }, + { "wait": "#properties" }, + { "settle": 1000 } + ] + }, + { + "filename": "debug.png", + "route": "/debug", + "preActions": [ + { "wait": "#debug_window" }, + { "settle": 800 }, + { "exec": "const W = document.getElementById('debug_window'); const T = (lvl, src, msg) => { const c = { INFO: '#3a8', WARN: '#fc3', ERROR: '#f55', DEBUG: '#7be', VERBOSE: '#aaa' }[lvl] || '#aaa'; const ts = new Date().toISOString().replace('T',' ').slice(0,23); return `${ts} ${lvl} ${src}: ${msg}
`; }; const D = (n, dir, label, body) => `${new Date().toISOString().replace('T',' ').slice(0,23)} CNTRLR « [Node 0${n}] ${dir} ${label}
└─ ${body}
`; W.innerHTML = [ T('INFO', 'GATEWAY', 'Loading custom devices from /home/daniel/zwave-js-ui/store/customDevices.json'), T('INFO', 'GATEWAY', 'Loaded 1 custom Hass devices configurations'), T('INFO', 'APP', 'Version: 11.16.2'), T('INFO', 'APP', 'Application path: /home/daniel/zwave-js-ui'), T('INFO', 'APP', 'Store path: /home/daniel/zwave-js-ui/store'), T('INFO', 'ZWAVE', 'Connecting to tcp://127.0.0.1:5555'), T('INFO', 'ZWAVE', 'Driver ready'), T('INFO', 'ZWAVE', 'Network is fully alive'), T('INFO', 'GATEWAY', 'Subscribing to MQTT topic zwave/_CLIENTS/zwavejs2mqtt/api/+/set'), D(2, '·', 'GET_NODE_PROTOCOL_INFO_REQUEST', 'protocol = Z-Wave, listening, routing'), D(2, '«', 'CONFIGURATION_GET', 'parameter #1, valueSize = 1'), D(2, '·', 'CONFIGURATION_REPORT', 'parameter #1: 0 (default)'), T('INFO', 'ZWAVE', 'Z-Wave network value updated: nodeId=4 cmdClass=Door Lock value=Mode → 0xff'), D(4, '«', 'DOOR_LOCK_OPERATION_GET', ''), D(4, '·', 'DOOR_LOCK_OPERATION_REPORT', 'currentMode = Secured, doorStatus = closed, latchStatus = closed, boltStatus = locked'), T('DEBUG', 'ZWAVE', 'Bound to driver events for node 5'), T('DEBUG', 'ZWAVE', 'Bound to driver events for node 6'), T('VERBOSE', 'ZWAVE', 'Pinging node 7 to keep it awake'), T('INFO', 'ZWAVE', 'Backup completed: store-2026-04-30.tar.gz') ].join(''); W.scrollTop = W.scrollHeight;" } + ] + }, + { + "filename": "nodes_manager.png", + "route": "/control-panel", + "preActions": [ + { "wait": "table tbody tr" }, + { "settle": 600 }, + { "click": ".v-fab .v-btn" }, + { "settle": 500 }, + { "click": "button[aria-label='Manage nodes']" }, + { "wait": ".v-overlay--active.v-dialog" }, + { "settle": 700 } + ] + }, + { + "filename": "route_health_result.png", + "route": "/mesh", + "preActions": [ + { "wait": "canvas" }, + { "settle": 3000 }, + { "exec": "const g = window.__zwGraph; const n = g.allNodes.find(n => n.id === 4); g.$emit('node-click', n);" }, + { "wait": "#properties button:has-text('Diagnose')" }, + { "settle": 600 }, + { "click": "#properties button:has-text('Diagnose')" }, + { "wait": ".v-overlay--active.v-dialog" }, + { "settle": 1000 } + ] + }, + { + "filename": "config_updates_icon.png", + "route": "/control-panel", + "clip": { "selector": "header .v-badge:has(button[aria-label='Check for updates'])", "padding": 10 }, + "preActions": [ + { "wait": "table tbody tr" }, + { "exec": "const s = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('base'); s.appInfo.newConfigVersion = '1.99.0';" }, + { "settle": 600 } + ] + }, + { + "filename": "config_updates_dialog.png", + "route": "/control-panel", + "clip": ".v-overlay--active.v-dialog .v-card", + "preActions": [ + { "wait": "table tbody tr" }, + { "exec": "const s = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('base'); s.appInfo.newConfigVersion = '1.99.0';" }, + { "settle": 400 }, + { "click": "header button[aria-label='Check for updates']" }, + { "wait": ".v-overlay--active.v-dialog" }, + { "settle": 500 } + ] + }, + { + "filename": "gateway_values_table.png", + "route": "/settings", + "preActions": [ + { "wait": ".v-expansion-panels" }, + { "click": ".v-expansion-panel-title:has-text('General')" }, + { "settle": 800 }, + { "scrollTo": ".v-data-table" }, + { "settle": 400 } + ] + }, + { + "filename": "edit_gateway_value.png", + "route": "/settings", + "preActions": [ + { "wait": ".v-expansion-panels" }, + { "click": ".v-expansion-panel-title:has-text('General')" }, + { "settle": 800 }, + { "click": ".v-data-table .v-icon.text-success" }, + { "wait": ".v-overlay--active.v-dialog" }, + { "settle": 500 } + ] + } + ] +} diff --git a/.claude/skills/refresh-docs-screenshots/package-lock.json b/.claude/skills/refresh-docs-screenshots/package-lock.json new file mode 100644 index 00000000000..f0d5d70c6e8 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "refresh-docs-screenshots", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "refresh-docs-screenshots", + "version": "0.0.0", + "dependencies": { + "playwright": "^1.59.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/.claude/skills/refresh-docs-screenshots/package.json b/.claude/skills/refresh-docs-screenshots/package.json new file mode 100644 index 00000000000..d8c813fc14c --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/package.json @@ -0,0 +1,14 @@ +{ + "name": "refresh-docs-screenshots", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Playwright-driven screenshot capture for docs/_images. Isolated from the main project so the heavy chromium dep doesn't leak into zwave-js-ui's devDependencies.", + "scripts": { + "capture": "node capture.mjs", + "setup": "npm install && npx playwright install chromium" + }, + "dependencies": { + "playwright": "^1.59.1" + } +} diff --git a/.claude/skills/refresh-docs-screenshots/seed/fakeNodes.seed.json b/.claude/skills/refresh-docs-screenshots/seed/fakeNodes.seed.json new file mode 100644 index 00000000000..1015568dbd0 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/seed/fakeNodes.seed.json @@ -0,0 +1,597 @@ +[ + { + "id": 2, + "name": "Living Room: Lights", + "loc": "Living Room", + "manufacturerId": 271, + "manufacturer": "Aeon Labs", + "productLabel": "ZW132", + "productDescription": "Dimmer 6", + "productId": 64, + "productType": 3, + "firmwareVersion": "1.05", + "protocol": 0, + "protocolVersion": 3, + "zwavePlusVersion": 2, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": true, + "isFrequentListening": false, + "isSecure": false, + "supportsBeaming": true, + "supportsSecurity": false, + "supportsTime": false, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 17, "specific": 1 }, + "deviceId": "271-64-3", + "deviceConfig": { + "manufacturer": "Aeon Labs", + "manufacturerId": "0x010f", + "label": "ZW132", + "description": "Dimmer 6" + }, + "hexId": "0x010f-0x0040-0x0003", + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Alive", + "keepAwake": false, + "lastActive": "2026-04-30T08:51:00.000Z", + "lastReceive": "2026-04-30T08:51:00.000Z", + "lastTransmit": "2026-04-30T08:51:00.000Z", + "errorReceive": false, + "errorTransmit": false, + "minBatteryLevel": null, + "healProgress": null, + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 142, + "commandsRX": 138, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 38.2, + "lastSeen": "2026-04-30T08:51:00.000Z", + "rssi": -55, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -55, + "repeaterRSSI": [] + }, + "nlwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -68, + "repeaterRSSI": [-60] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 3, + "name": "Hallway: Motion Sensor", + "loc": "Hallway", + "manufacturerId": 271, + "manufacturer": "Aeotec", + "productLabel": "ZW100", + "productDescription": "MultiSensor 6", + "productId": 100, + "productType": 2, + "firmwareVersion": "1.13", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": false, + "isRouting": false, + "isFrequentListening": "1000ms", + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 7, "specific": 1 }, + "deviceId": "271-100-2", + "deviceConfig": { + "manufacturer": "Aeotec", + "manufacturerId": "0x010f", + "label": "ZW100", + "description": "MultiSensor 6" + }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Asleep", + "batteryLevels": { "0": 78 }, + "minBatteryLevel": 78, + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 22, + "commandsRX": 18, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 64.1, + "lastSeen": "2026-04-30T07:42:11.000Z", + "rssi": -72, + "lwr": { + "protocolDataRate": 2, + "repeaters": [2], + "rssi": -72, + "repeaterRSSI": [-58] + }, + "nlwr": { + "protocolDataRate": 2, + "repeaters": [11, 2], + "rssi": -78, + "repeaterRSSI": [-62, -60] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 4, + "name": "Front Door: Lock", + "loc": "Entrance", + "manufacturerId": 305, + "manufacturer": "Yale", + "productLabel": "YRD256", + "productDescription": "Touchscreen Deadbolt", + "productId": 4, + "productType": 1, + "firmwareVersion": "8.0", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": false, + "isRouting": false, + "isFrequentListening": "1000ms", + "isSecure": true, + "security": "S2_Access Control", + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 64, "specific": 3 }, + "deviceId": "305-4-1", + "deviceConfig": { + "manufacturer": "Yale", + "label": "YRD256", + "description": "Touchscreen Deadbolt" + }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Awake", + "batteryLevels": { "0": 55 }, + "minBatteryLevel": 55, + "endpoints": [{ "index": 0 }], + "userCodes": { + "total": 20, + "available": [1, 2, 5, 6, 7, 8, 9, 10], + "enabled": [3, 4] + }, + "schedule": { + "daily": { + "numSlots": 4, + "slots": [ + { + "userId": 3, + "slotId": 1, + "weekdays": [1, 2, 3, 4, 5], + "startHour": 7, + "startMinute": 0, + "durationHour": 10, + "durationMinute": 0, + "enabled": true + } + ] + }, + "weekly": { + "numSlots": 7, + "slots": [ + { + "userId": 4, + "slotId": 1, + "weekday": 6, + "startHour": 9, + "startMinute": 0, + "stopHour": 18, + "stopMinute": 0 + } + ] + }, + "yearly": { + "numSlots": 4, + "slots": [] + } + }, + "statistics": { + "commandsTX": 8, + "commandsRX": 12, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 52.7, + "lastSeen": "2026-04-30T08:30:00.000Z", + "rssi": -65, + "lwr": { + "protocolDataRate": 2, + "repeaters": [2], + "rssi": -65, + "repeaterRSSI": [-55] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 5, + "name": "Living Room: Thermostat", + "loc": "Living Room", + "manufacturerId": 57, + "manufacturer": "Honeywell", + "productLabel": "TH6320ZW", + "productDescription": "T6 Pro Z-Wave Programmable Thermostat", + "productId": 8, + "productType": 17, + "firmwareVersion": "0.5", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": true, + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 8, "specific": 6 }, + "deviceId": "57-8-17", + "deviceConfig": { + "manufacturer": "Honeywell", + "label": "TH6320ZW", + "description": "T6 Pro Z-Wave Programmable Thermostat" + }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Alive", + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 9, + "commandsRX": 6294, + "commandsDroppedRX": 4, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 45.3, + "lastSeen": "2026-04-30T08:51:00.000Z", + "rssi": -70, + "lwr": { + "protocolDataRate": 2, + "repeaters": [2], + "rssi": -70, + "repeaterRSSI": [-55] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 6, + "name": "Garage: Door Sensor", + "loc": "Garage", + "manufacturerId": 271, + "manufacturer": "Aeotec", + "productLabel": "ZW120", + "productDescription": "Door/Window Sensor 6", + "productId": 120, + "productType": 2, + "firmwareVersion": "1.07", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": false, + "isRouting": false, + "isFrequentListening": "1000ms", + "isSecure": true, + "security": "S2_Authenticated", + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 7, "specific": 1 }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Asleep", + "batteryLevels": { "0": 92 }, + "minBatteryLevel": 92, + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 4, + "commandsRX": 6, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 89.4, + "lastSeen": "2026-04-30T06:15:22.000Z", + "rssi": -82, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11, 2], + "rssi": -82, + "repeaterRSSI": [-65, -58] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 7, + "name": "Patio: RGB Bulb", + "loc": "Patio", + "manufacturerId": 305, + "manufacturer": "Inovelli", + "productLabel": "LZW42", + "productDescription": "RGBW Smart Bulb", + "productId": 4242, + "productType": 4, + "firmwareVersion": "2.21", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": true, + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 17, "specific": 1 }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Alive", + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 38, + "commandsRX": 36, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 71.2, + "lastSeen": "2026-04-30T08:48:00.000Z", + "rssi": -78, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11, 2], + "rssi": -78, + "repeaterRSSI": [-66, -55] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 8, + "name": "Bedroom: Switch", + "loc": "Bedroom", + "manufacturerId": 99, + "manufacturer": "GE/Jasco", + "productLabel": "14291", + "productDescription": "In-Wall Smart Switch", + "productId": 12345, + "productType": 18756, + "firmwareVersion": "5.22", + "protocolVersion": 3, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": true, + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 16, "specific": 1 }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Alive", + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 11, + "commandsRX": 10, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 41.8, + "lastSeen": "2026-04-30T08:50:00.000Z", + "rssi": -60, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -60, + "repeaterRSSI": [] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 9, + "name": "Unknown Device", + "loc": "", + "manufacturerId": 0, + "manufacturer": "", + "productLabel": "", + "productDescription": "", + "productId": 0, + "productType": 0, + "firmwareVersion": "0.0", + "protocolVersion": 3, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": false, + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 40000, + "deviceClass": { "basic": 4, "generic": 16, "specific": 0 }, + "interviewStage": "ProtocolInfo", + "ready": false, + "available": false, + "failed": false, + "inited": false, + "status": "Unknown", + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 1, + "timeoutResponse": 1, + "rtt": 0, + "lastSeen": "2026-04-30T05:00:00.000Z", + "rssi": -90, + "lwr": { + "protocolDataRate": 1, + "repeaters": [], + "rssi": -90, + "repeaterRSSI": [] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 10, + "name": "Office: Smoke Detector", + "loc": "Office", + "manufacturerId": 271, + "manufacturer": "First Alert", + "productLabel": "ZCOMBO", + "productDescription": "Smoke/CO Alarm", + "productId": 1, + "productType": 1, + "firmwareVersion": "1.13", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": false, + "isRouting": false, + "isFrequentListening": "1000ms", + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 40000, + "deviceClass": { "basic": 4, "generic": 7, "specific": 1 }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Asleep", + "batteryLevels": { "0": 18 }, + "minBatteryLevel": 18, + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 2, + "commandsRX": 5, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 95.6, + "lastSeen": "2026-04-30T03:28:00.000Z", + "rssi": -85, + "lwr": { + "protocolDataRate": 2, + "repeaters": [2, 11], + "rssi": -85, + "repeaterRSSI": [-58, -68] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + }, + { + "id": 11, + "name": "Kitchen: Smart Plug", + "loc": "Kitchen", + "manufacturerId": 271, + "manufacturer": "Aeotec", + "productLabel": "ZW096", + "productDescription": "Smart Switch 6", + "productId": 96, + "productType": 3, + "firmwareVersion": "1.04", + "protocolVersion": 3, + "zwavePlusVersion": 2, + "nodeType": 1, + "endpointsCount": 0, + "isListening": true, + "isRouting": true, + "isSecure": false, + "supportsBeaming": true, + "maxDataRate": 100000, + "deviceClass": { "basic": 4, "generic": 16, "specific": 1 }, + "interviewStage": "Complete", + "ready": true, + "available": true, + "failed": false, + "inited": true, + "status": "Alive", + "endpoints": [{ "index": 0 }], + "statistics": { + "commandsTX": 248, + "commandsRX": 251, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 32.1, + "lastSeen": "2026-04-30T08:51:00.000Z", + "rssi": -52, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -52, + "repeaterRSSI": [] + } + }, + "groups": [], + "hassDevices": {}, + "eventsQueue": [], + "values": [] + } +] diff --git a/.claude/skills/refresh-docs-screenshots/seed/mock-server/001-stub.cjs b/.claude/skills/refresh-docs-screenshots/seed/mock-server/001-stub.cjs new file mode 100644 index 00000000000..c9078dfe070 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/seed/mock-server/001-stub.cjs @@ -0,0 +1,11 @@ +// @ts-check +// Empty mock-server config used by the refresh-docs-screenshots skill. +// All "nodes" come from fakeNodes.seed.json — pure UI state, no real +// interview required. The mock-server here exists only so the driver +// can connect to *something* on tcp://127.0.0.1:5555 and reach +// "Driver is ready" in <5s. + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [], +} diff --git a/.claude/skills/refresh-docs-screenshots/seed/scenes.json b/.claude/skills/refresh-docs-screenshots/seed/scenes.json new file mode 100644 index 00000000000..ef12940abc9 --- /dev/null +++ b/.claude/skills/refresh-docs-screenshots/seed/scenes.json @@ -0,0 +1,108 @@ +[ + { + "sceneid": 1, + "label": "Movie Night", + "values": [ + { + "id": "5-38-0-targetValue", + "nodeId": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "stateless": false, + "min": 0, + "max": 99, + "list": false, + "newValue": 15, + "value": 0, + "timeout": 0 + }, + { + "id": "7-37-0-targetValue", + "nodeId": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "stateless": false, + "list": false, + "newValue": false, + "value": false, + "timeout": 0 + }, + { + "id": "19-67-0-setpoint-1", + "nodeId": 19, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "endpoint": 0, + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "type": "number", + "readable": true, + "writeable": true, + "label": "Heating", + "stateless": false, + "list": false, + "newValue": 19, + "value": 21, + "timeout": 0 + } + ] + }, + { + "sceneid": 2, + "label": "Goodnight", + "values": [ + { + "id": "3-37-0-targetValue", + "nodeId": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "stateless": false, + "list": false, + "newValue": false, + "value": false, + "timeout": 0 + }, + { + "id": "18-98-0-targetMode", + "nodeId": 18, + "commandClass": 98, + "commandClassName": "Door Lock", + "endpoint": 0, + "property": "targetMode", + "propertyName": "targetMode", + "type": "number", + "readable": true, + "writeable": true, + "label": "Target mode", + "stateless": false, + "list": true, + "newValue": 255, + "value": 0, + "timeout": 0 + } + ] + } +] diff --git a/.github/agents/backend-agent.md b/.github/agents/backend-agent.md index 99eba6271b0..417218cded7 100644 --- a/.github/agents/backend-agent.md +++ b/.github/agents/backend-agent.md @@ -13,7 +13,7 @@ stack: applyTo: - api/** - esbuild.js - - server_config.js + - mock-server/** commands: build: npm run build:server dev: npm run dev:server @@ -217,7 +217,8 @@ describe('retryOperation', () => { npm run fake-stick ``` - Listens on tcp://localhost:5555 - - Configured in server_config.js + - Loads a single node from `mock-server/002-window-covering-pinned.cjs` (also used by the e2e workflow) + - For the full 31-node demo fleet, run `npm run fake-stick:fleet` instead (every `.cjs` file in `mock-server/`, merged at startup) 2. **Start Backend Dev Server**: ```bash diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 874aa645622..1f7d7390f22 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -423,7 +423,7 @@ After making changes, ALWAYS test these complete user scenarios: - **vite.config.mjs**: Frontend build configuration - **nodemon.json**: Backend development server configuration - **.mocharc.yml**: Test configuration -- **server_config.js**: Mock Z-Wave controller configuration +- **mock-server/**: Mock Z-Wave controller node configs, one `.cjs` per device category. `npm run fake-stick:fleet` merges all 31 nodes; `npm run fake-stick` loads only `002-window-covering-pinned.cjs` (single node, used by the e2e workflow) ## Environment Configuration diff --git a/.github/instructions/backend-instructions.md b/.github/instructions/backend-instructions.md index 6790a03daf1..59d4e440b80 100644 --- a/.github/instructions/backend-instructions.md +++ b/.github/instructions/backend-instructions.md @@ -67,7 +67,9 @@ In order to emulate a Z-Wave stick, you can use the `fake-stick` command: npm run fake-stick ``` -This will spawn a fake Z-Wave stick using `server_config.js` configuration. By default the stick listens on `tcp://:::5555` and this can be used as `path` in zwave settings. +This will spawn a fake Z-Wave stick with a single node, loaded from `mock-server/002-window-covering-pinned.cjs`. By default the stick listens on `tcp://:::5555` and this can be used as `path` in zwave settings. + +For a richer 31-node demo fleet, run `npm run fake-stick:fleet` instead, which loads every `.cjs` file in the `mock-server/` directory (one per device category, merged at startup). Add a new `.cjs` file there (one or more nodes per file, unique node IDs) to extend the fleet. --- diff --git a/.github/workflows/test-application.yml b/.github/workflows/test-application.yml index 2ea7eda580f..5507245511c 100644 --- a/.github/workflows/test-application.yml +++ b/.github/workflows/test-application.yml @@ -53,7 +53,8 @@ jobs: "gateway": { "type": 0, "payloadType": 0, - "nodeNames": true, + "nodeNames": false, + "ignoreLoc": true, "hassDiscovery": true, "discoveryPrefix": "homeassistant", "logEnabled": true, @@ -87,9 +88,12 @@ jobs: - name: Test MQTT write and read timeout-minutes: 1 + # Topics use the bare node id (zwave/2/...): gateway type 0 with + # nodeNames:false + ignoreLoc:true keys topics by id, so this stays + # stable regardless of node 2's name/location in the mock config. run: | - docker exec broker mosquitto_pub -h localhost -p 1883 -t "zwave/nodeID_2/106/0/targetValue/3/set" -m "1" - docker exec broker mosquitto_sub -h localhost -p 1883 -t "zwave/nodeID_2/106/0/currentValue/3" -C 1 + docker exec broker mosquitto_pub -h localhost -p 1883 -t "zwave/2/106/0/targetValue/3/set" -m "1" + docker exec broker mosquitto_sub -h localhost -p 1883 -t "zwave/2/106/0/currentValue/3" -C 1 - name: Output backend logs if: always() diff --git a/AGENTS.md b/AGENTS.md index 3fa7521cd30..7ded59f224d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,8 @@ Each agent provides: # Development npm run dev # Frontend dev server (port 8092) npm run dev:server # Backend dev server (port 8091) -npm run fake-stick # Mock Z-Wave controller +npm run fake-stick # Mock Z-Wave controller (single node) +npm run fake-stick:fleet # Mock Z-Wave controller (31-node demo fleet) # Building npm run build # Build everything (~24s) diff --git a/CLAUDE.md b/CLAUDE.md index 6954becc782..771b2545249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,8 @@ All development instructions are maintained in shared files under `.github/` so # Development npm run dev # Frontend dev server (port 8092) npm run dev:server # Backend dev server (port 8091) -npm run fake-stick # Mock Z-Wave controller (port 5555) +npm run fake-stick # Mock Z-Wave controller — single node (port 5555) +npm run fake-stick:fleet # Mock Z-Wave controller — 31-node demo fleet (port 5555) # Quality checks (always run before committing) npm run lint-fix # Auto-fix lint issues diff --git a/api/lib/ZwaveClient.ts b/api/lib/ZwaveClient.ts index 6a970838d1b..d9e6d275621 100644 --- a/api/lib/ZwaveClient.ts +++ b/api/lib/ZwaveClient.ts @@ -2165,7 +2165,10 @@ class ZwaveClient extends TypedEventEmitter { const zwaveNode = this.getNode(nodeId) - if (zwaveNode.protocol === Protocols.ZWaveLongRange) { + // getNode() may return undefined (node unknown to the driver, e.g. + // removed mid-request, or a UI-only fake node). Such a node has no + // discoverable neighbors. + if (!zwaveNode || zwaveNode.protocol === Protocols.ZWaveLongRange) { return [] } diff --git a/docs/_images/config_updates_dialog.png b/docs/_images/config_updates_dialog.png index 277e3aa2872..e0b73e13dd3 100644 Binary files a/docs/_images/config_updates_dialog.png and b/docs/_images/config_updates_dialog.png differ diff --git a/docs/_images/config_updates_icon.png b/docs/_images/config_updates_icon.png index 4cba8e401a4..bfb4f569f2a 100644 Binary files a/docs/_images/config_updates_icon.png and b/docs/_images/config_updates_icon.png differ diff --git a/docs/_images/control_panel.png b/docs/_images/control_panel.png new file mode 100644 index 00000000000..47b27b76dd5 Binary files /dev/null and b/docs/_images/control_panel.png differ diff --git a/docs/_images/control_panel_dark.png b/docs/_images/control_panel_dark.png index abcaf161337..eca651cf224 100644 Binary files a/docs/_images/control_panel_dark.png and b/docs/_images/control_panel_dark.png differ diff --git a/docs/_images/debug.png b/docs/_images/debug.png index 45d17932482..2c85008bbf8 100644 Binary files a/docs/_images/debug.png and b/docs/_images/debug.png differ diff --git a/docs/_images/edit_gateway_value.png b/docs/_images/edit_gateway_value.png index 183f486c41b..52ea0f8e7bc 100644 Binary files a/docs/_images/edit_gateway_value.png and b/docs/_images/edit_gateway_value.png differ diff --git a/docs/_images/gateway_values_table.png b/docs/_images/gateway_values_table.png index 8082ca46f23..5c12dddf5de 100644 Binary files a/docs/_images/gateway_values_table.png and b/docs/_images/gateway_values_table.png differ diff --git a/docs/_images/mesh-selected.png b/docs/_images/mesh-selected.png index e8544185e1b..96954e83574 100644 Binary files a/docs/_images/mesh-selected.png and b/docs/_images/mesh-selected.png differ diff --git a/docs/_images/mesh_diagram.png b/docs/_images/mesh_diagram.png index 0d00ef502d7..fbbd762f2f4 100644 Binary files a/docs/_images/mesh_diagram.png and b/docs/_images/mesh_diagram.png differ diff --git a/docs/_images/nodes_manager.png b/docs/_images/nodes_manager.png index 65e2ec632a4..156fe9a7473 100644 Binary files a/docs/_images/nodes_manager.png and b/docs/_images/nodes_manager.png differ diff --git a/docs/_images/route_health_result.png b/docs/_images/route_health_result.png index 248ae6d693d..f16bfc7a0fb 100644 Binary files a/docs/_images/route_health_result.png and b/docs/_images/route_health_result.png differ diff --git a/docs/_images/scenes.png b/docs/_images/scenes.png index a86bcf59f4a..6ade65fca4e 100644 Binary files a/docs/_images/scenes.png and b/docs/_images/scenes.png differ diff --git a/docs/_images/settings.png b/docs/_images/settings.png index 3e3f815b0dd..3da9813fca1 100644 Binary files a/docs/_images/settings.png and b/docs/_images/settings.png differ diff --git a/docs/_images/settings_dark.png b/docs/_images/settings_dark.png index 9967031af57..78f0e71c3bb 100644 Binary files a/docs/_images/settings_dark.png and b/docs/_images/settings_dark.png differ diff --git a/docs/_images/unknown-device.png b/docs/_images/unknown-device.png deleted file mode 100644 index e80005dfb47..00000000000 Binary files a/docs/_images/unknown-device.png and /dev/null differ diff --git a/eslint.config.js b/eslint.config.js index 272ff8e8da7..153158d1b70 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,7 @@ export default defineConfig([ 'snippets/', 'store/', 'dev-dist/', + '.claude/', '.github/', ]), ]) diff --git a/mock-server/002-window-covering-pinned.cjs b/mock-server/002-window-covering-pinned.cjs new file mode 100644 index 00000000000..c1f451c9a6f --- /dev/null +++ b/mock-server/002-window-covering-pinned.cjs @@ -0,0 +1,55 @@ +// @ts-check +// Node 2 — a Window Covering (blinds) device, used two ways: +// * `npm run fake-stick` loads ONLY this file (single-node config) +// * `npm run fake-stick:fleet` merges it into the full 31-node demo fleet +// Pinned by the e2e workflow (.github/workflows/test-application.yml): it must +// keep Window Covering CC parameter 3 ("Outbound Right") supported, or the +// MQTT round-trip test (zwave/nodeID_2/106/0/targetValue/3) breaks. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { WindowCoveringParameter } = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + { + id: 2, + capabilities: { + genericDeviceClass: 0x09, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Living Room: Blinds', + location: 'Living Room', + }), + ccCaps({ + ccId: CommandClasses['Window Covering'], + version: 1, + supportedParameters: [ + WindowCoveringParameter['Outbound Left'], + WindowCoveringParameter['Outbound Right'], + WindowCoveringParameter['Inbound Left/Right'], + WindowCoveringParameter['Horizontal Slats Angle'], + ], + }), + ccCaps({ + ccId: CommandClasses['User Code'], + version: 2, + numUsers: 5, + supportedASCIIChars: '0123456789ABCDEF', + }), + ccCaps({ + ccId: CommandClasses['Schedule Entry Lock'], + version: 3, + numDailyRepeatingSlots: 1, + numWeekDaySlots: 1, + numYearDaySlots: 1, + }), + ], + }, + }, + ], +} diff --git a/mock-server/010-lighting.cjs b/mock-server/010-lighting.cjs new file mode 100644 index 00000000000..5b0a28cbf28 --- /dev/null +++ b/mock-server/010-lighting.cjs @@ -0,0 +1,205 @@ +// @ts-check +// Lighting devices: in-wall switch, smart plug w/ meter, dimmers, color bulbs. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { ColorComponent, RateType, SwitchType } = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // In-wall binary switch + { + id: 3, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Hallway Light', + location: 'Hallway', + }), + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ], + }, + }, + // Smart plug with energy metering + { + id: 4, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'TV Smart Plug', + location: 'Living Room', + }), + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ccCaps({ + ccId: CommandClasses.Meter, + version: 4, + meterType: 1, + supportedScales: [0, 2], + supportedRateTypes: [RateType.Consumed], + supportsReset: true, + }), + ], + }, + }, + // In-wall dimmer + { + id: 5, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Kitchen Dimmer', + location: 'Kitchen', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ], + }, + }, + // Ceiling dimmer with metering + { + id: 6, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedroom Ceiling', + location: 'Bedroom', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ccCaps({ + ccId: CommandClasses.Meter, + version: 4, + meterType: 1, + supportedScales: [0, 2], + supportedRateTypes: [RateType.Consumed], + supportsReset: true, + }), + ], + }, + }, + // RGB bulb + { + id: 7, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Living Room Lamp', + location: 'Living Room', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ccCaps({ + ccId: CommandClasses['Color Switch'], + version: 3, + colorComponents: { + [ColorComponent.Red]: 255, + [ColorComponent.Green]: 255, + [ColorComponent.Blue]: 255, + }, + }), + ], + }, + }, + // RGBW LED strip (Warm White + RGB) + { + id: 8, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'TV Backlight', + location: 'Living Room', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ccCaps({ + ccId: CommandClasses['Color Switch'], + version: 3, + colorComponents: { + [ColorComponent['Warm White']]: 0, + [ColorComponent.Red]: 0, + [ColorComponent.Green]: 0, + [ColorComponent.Blue]: 0, + }, + }), + ], + }, + }, + // Ceiling fan (multilevel switch) + { + id: 9, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x08, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedroom Fan', + location: 'Bedroom', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ], + }, + }, + ], +} diff --git a/mock-server/020-sensors.cjs b/mock-server/020-sensors.cjs new file mode 100644 index 00000000000..7d5024fd01a --- /dev/null +++ b/mock-server/020-sensors.cjs @@ -0,0 +1,86 @@ +// @ts-check +// Battery-powered sensors: door/window contact, temp/humidity, illuminance. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Door / window contact (binary sensor) + { + id: 10, + capabilities: { + genericDeviceClass: 0x20, + specificDeviceClass: 0x06, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Front Door Contact', + location: 'Entrance', + }), + ccCaps({ + ccId: CommandClasses['Binary Sensor'], + version: 2, + supportedSensorTypes: [10], + }), + CommandClasses.Battery, + ], + }, + }, + // Temperature & humidity sensor (multilevel) + { + id: 11, + capabilities: { + genericDeviceClass: 0x21, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedroom Climate', + location: 'Bedroom', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Sensor'], + version: 11, + sensors: { + 1: { supportedScales: [0] }, + 5: { supportedScales: [1] }, + }, + }), + CommandClasses.Battery, + ], + }, + }, + // Multi-sensor: temperature + illuminance (motion handled via Notification CC on node 13) + { + id: 12, + capabilities: { + genericDeviceClass: 0x21, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Outdoor Weather', + location: 'Garden', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Sensor'], + version: 11, + sensors: { + 1: { supportedScales: [0] }, + 3: { supportedScales: [1] }, + 27: { supportedScales: [0] }, + }, + }), + CommandClasses.Battery, + ], + }, + }, + ], +} diff --git a/mock-server/030-notifications.cjs b/mock-server/030-notifications.cjs new file mode 100644 index 00000000000..e32449db752 --- /dev/null +++ b/mock-server/030-notifications.cjs @@ -0,0 +1,117 @@ +// @ts-check +// Notification-based safety devices: motion, smoke, water leak, CO. +// Notification type IDs follow the Z-Wave spec: +// 1 = Smoke alarm, 2 = CO alarm, 5 = Water alarm, +// 6 = Access Control, 7 = Home Security +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // PIR motion + tamper + glass break + { + id: 13, + capabilities: { + genericDeviceClass: 0x07, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Hallway Motion', + location: 'Hallway', + }), + ccCaps({ + ccId: CommandClasses.Notification, + version: 8, + supportsV1Alarm: false, + notificationTypesAndEvents: { + 7: [3, 8], + }, + }), + CommandClasses.Battery, + ], + }, + }, + // Smoke alarm + { + id: 14, + capabilities: { + genericDeviceClass: 0x07, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Kitchen Smoke', + location: 'Kitchen', + }), + ccCaps({ + ccId: CommandClasses.Notification, + version: 8, + supportsV1Alarm: false, + notificationTypesAndEvents: { + 1: [1, 2, 3, 4], + }, + }), + CommandClasses.Battery, + ], + }, + }, + // Water leak sensor + { + id: 15, + capabilities: { + genericDeviceClass: 0x07, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Basement Leak', + location: 'Basement', + }), + ccCaps({ + ccId: CommandClasses.Notification, + version: 8, + supportsV1Alarm: false, + notificationTypesAndEvents: { + 5: [2, 3, 4], + }, + }), + CommandClasses.Battery, + ], + }, + }, + // CO alarm + { + id: 16, + capabilities: { + genericDeviceClass: 0x07, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Garage CO', + location: 'Garage', + }), + ccCaps({ + ccId: CommandClasses.Notification, + version: 8, + supportsV1Alarm: false, + notificationTypesAndEvents: { + 2: [1, 2, 3], + }, + }), + CommandClasses.Battery, + ], + }, + }, + ], +} diff --git a/mock-server/040-locks.cjs b/mock-server/040-locks.cjs new file mode 100644 index 00000000000..a5c1ec0ee47 --- /dev/null +++ b/mock-server/040-locks.cjs @@ -0,0 +1,113 @@ +// @ts-check +// Door locks: a basic lock with User Code, and a keypad lock with full +// User Code v2 + Schedule Entry Lock so the schedule UI has data to render. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { + DoorLockMode, + DoorLockOperationType, + UserIDStatus, + KeypadMode, +} = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Basic door lock + { + id: 17, + capabilities: { + genericDeviceClass: 0x40, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Back Door Lock', + location: 'Back Door', + }), + ccCaps({ + ccId: CommandClasses['Door Lock'], + version: 4, + supportedOperationTypes: [ + DoorLockOperationType.Constant, + ], + supportedDoorLockModes: [ + DoorLockMode.Unsecured, + DoorLockMode.Secured, + ], + doorSupported: true, + boltSupported: true, + latchSupported: false, + }), + ccCaps({ + ccId: CommandClasses['User Code'], + version: 1, + numUsers: 5, + }), + CommandClasses.Battery, + ], + }, + }, + // Keypad lock with full User Code + Schedule Entry Lock + { + id: 18, + capabilities: { + genericDeviceClass: 0x40, + specificDeviceClass: 0x03, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Front Door Lock', + location: 'Entrance', + }), + ccCaps({ + ccId: CommandClasses['Door Lock'], + version: 4, + supportedOperationTypes: [ + DoorLockOperationType.Constant, + DoorLockOperationType.Timed, + ], + supportedDoorLockModes: [ + DoorLockMode.Unsecured, + DoorLockMode.UnsecuredWithTimeout, + DoorLockMode.Secured, + ], + doorSupported: true, + boltSupported: true, + latchSupported: true, + autoRelockSupported: true, + holdAndReleaseSupported: true, + }), + ccCaps({ + ccId: CommandClasses['User Code'], + version: 2, + numUsers: 20, + supportsAdminCode: true, + supportedASCIIChars: '0123456789', + supportedUserIDStatuses: [ + UserIDStatus.Available, + UserIDStatus.Enabled, + UserIDStatus.Disabled, + ], + supportedKeypadModes: [ + KeypadMode.Normal, + KeypadMode.Vacation, + ], + }), + ccCaps({ + ccId: CommandClasses['Schedule Entry Lock'], + version: 3, + numWeekDaySlots: 7, + numYearDaySlots: 4, + numDailyRepeatingSlots: 2, + }), + CommandClasses.Battery, + ], + }, + }, + ], +} diff --git a/mock-server/050-thermostats.cjs b/mock-server/050-thermostats.cjs new file mode 100644 index 00000000000..ff02771f08a --- /dev/null +++ b/mock-server/050-thermostats.cjs @@ -0,0 +1,103 @@ +// @ts-check +// HVAC: a heating-only thermostat and a full HVAC thermostat with humidity. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { ThermostatMode, ThermostatSetpointType } = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Heating-only thermostat (e.g. radiator valve) + { + id: 19, + capabilities: { + genericDeviceClass: 0x08, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedroom Radiator', + location: 'Bedroom', + }), + ccCaps({ + ccId: CommandClasses['Thermostat Mode'], + version: 3, + supportedModes: [ + ThermostatMode.Off, + ThermostatMode.Heat, + ], + }), + ccCaps({ + ccId: CommandClasses['Thermostat Setpoint'], + version: 3, + setpoints: { + [ThermostatSetpointType.Heating]: { + minValue: 5, + maxValue: 30, + defaultValue: 21, + scale: '°C', + }, + }, + }), + CommandClasses.Battery, + ], + }, + }, + // Full HVAC thermostat (heat + cool + auto + fan) + { + id: 20, + capabilities: { + genericDeviceClass: 0x08, + specificDeviceClass: 0x06, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Living Room HVAC', + location: 'Living Room', + }), + ccCaps({ + ccId: CommandClasses['Thermostat Mode'], + version: 3, + supportedModes: [ + ThermostatMode.Off, + ThermostatMode.Heat, + ThermostatMode.Cool, + ThermostatMode.Auto, + ThermostatMode.Fan, + ], + }), + ccCaps({ + ccId: CommandClasses['Thermostat Setpoint'], + version: 3, + setpoints: { + [ThermostatSetpointType.Heating]: { + minValue: 10, + maxValue: 30, + defaultValue: 21, + scale: '°C', + }, + [ThermostatSetpointType.Cooling]: { + minValue: 18, + maxValue: 28, + defaultValue: 24, + scale: '°C', + }, + }, + }), + ccCaps({ + ccId: CommandClasses['Multilevel Sensor'], + version: 11, + sensors: { + 1: { supportedScales: [0] }, + 5: { supportedScales: [1] }, + }, + }), + ], + }, + }, + ], +} diff --git a/mock-server/060-covers.cjs b/mock-server/060-covers.cjs new file mode 100644 index 00000000000..48d8bd17e96 --- /dev/null +++ b/mock-server/060-covers.cjs @@ -0,0 +1,90 @@ +// @ts-check +// Covers: roller shutter (multilevel switch type) and Venetian blinds +// (Window Covering with multiple parameters). Garage opener uses Binary Switch. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { SwitchType, WindowCoveringParameter } = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Roller shutter — multilevel switch with up/down semantics + { + id: 21, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x06, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedroom Shutter', + location: 'Bedroom', + }), + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Down/Up'], + defaultValue: 0, + }), + ], + }, + }, + // Venetian blinds — Window Covering CC with position + slat angle + { + id: 22, + capabilities: { + genericDeviceClass: 0x09, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Office Blinds', + location: 'Office', + }), + ccCaps({ + ccId: CommandClasses['Window Covering'], + version: 1, + supportedParameters: [ + WindowCoveringParameter['Outbound Bottom'], + WindowCoveringParameter['Horizontal Slats Angle'], + ], + }), + ], + }, + }, + // Garage door opener — modeled as binary switch + door notification + { + id: 23, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Garage Door', + location: 'Garage', + }), + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ccCaps({ + ccId: CommandClasses.Notification, + version: 8, + supportsV1Alarm: false, + notificationTypesAndEvents: { + 6: [22, 23], + }, + }), + ], + }, + }, + ], +} diff --git a/mock-server/070-energy.cjs b/mock-server/070-energy.cjs new file mode 100644 index 00000000000..1310bd54d91 --- /dev/null +++ b/mock-server/070-energy.cjs @@ -0,0 +1,92 @@ +// @ts-check +// Energy / metering devices: whole-house electric meter, gas meter, +// and a solar inverter exposing Energy Production CC. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { RateType } = require('zwave-js') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Whole-house electric meter + { + id: 24, + capabilities: { + genericDeviceClass: 0x31, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Main Electric Meter', + location: 'Utility Room', + }), + ccCaps({ + ccId: CommandClasses.Meter, + version: 6, + meterType: 1, + supportedScales: [0, 2, 4, 5, 6], + supportedRateTypes: [ + RateType.Consumed, + RateType.Produced, + ], + supportsReset: true, + }), + ], + }, + }, + // Gas meter + { + id: 25, + capabilities: { + genericDeviceClass: 0x31, + specificDeviceClass: 0x03, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Gas Meter', + location: 'Utility Room', + }), + ccCaps({ + ccId: CommandClasses.Meter, + version: 4, + meterType: 3, + supportedScales: [0, 1], + supportedRateTypes: [RateType.Consumed], + supportsReset: false, + }), + ], + }, + }, + // Solar inverter — Energy Production CC + { + id: 26, + capabilities: { + genericDeviceClass: 0x31, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Roof Solar Inverter', + location: 'Roof', + }), + ccCaps({ + ccId: CommandClasses['Energy Production'], + version: 1, + values: { + Power: { value: 1250, scale: 0 }, + 'Production Total': { value: 4823000, scale: 0 }, + 'Production Today': { value: 8400, scale: 0 }, + 'Total Time': { value: 18540, scale: 1 }, + }, + }), + ], + }, + }, + ], +} diff --git a/mock-server/080-multi-channel.cjs b/mock-server/080-multi-channel.cjs new file mode 100644 index 00000000000..b15df2c797f --- /dev/null +++ b/mock-server/080-multi-channel.cjs @@ -0,0 +1,100 @@ +// @ts-check +// Multi-channel devices: 4-outlet power strip and an in-wall double dimmer. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') +const { RateType, SwitchType } = require('zwave-js') + +// Endpoint capabilities are produced by factories so each endpoint gets a +// fresh object — sharing references is brittle if MockNode ever mutates them. +const binarySwitchEndpoint = () => ({ + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ccCaps({ + ccId: CommandClasses.Meter, + version: 4, + meterType: 1, + supportedScales: [0, 2], + supportedRateTypes: [RateType.Consumed], + supportsReset: true, + }), + ], +}) + +const dimmerEndpoint = () => ({ + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ], +}) + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // 4-outlet power strip with per-outlet metering + { + id: 27, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Office Power Strip', + location: 'Office', + }), + { ccId: CommandClasses['Multi Channel'], version: 4 }, + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ], + endpoints: [ + binarySwitchEndpoint(), + binarySwitchEndpoint(), + binarySwitchEndpoint(), + binarySwitchEndpoint(), + ], + }, + }, + // In-wall double dimmer (two endpoints) + { + id: 28, + capabilities: { + genericDeviceClass: 0x11, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Stairwell Dual Dimmer', + location: 'Stairwell', + }), + { ccId: CommandClasses['Multi Channel'], version: 4 }, + ccCaps({ + ccId: CommandClasses['Multilevel Switch'], + version: 4, + primarySwitchType: SwitchType['Off/On'], + defaultValue: 0, + }), + ], + endpoints: [dimmerEndpoint(), dimmerEndpoint()], + }, + }, + ], +} diff --git a/mock-server/090-misc.cjs b/mock-server/090-misc.cjs new file mode 100644 index 00000000000..2b2f4622e47 --- /dev/null +++ b/mock-server/090-misc.cjs @@ -0,0 +1,149 @@ +// @ts-check +// Misc devices: siren (Sound Switch), status indicator, scene controller, +// and a configurable-parameters device for the Configuration UI. +const { CommandClasses } = require('@zwave-js/core') +const { ccCaps } = require('@zwave-js/testing') + +/** @type {import('zwave-js/Testing').MockServerOptions['config']} */ +module.exports.default = { + nodes: [ + // Indoor siren — Sound Switch CC + { + id: 29, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Hallway Siren', + location: 'Hallway', + }), + ccCaps({ + ccId: CommandClasses['Sound Switch'], + version: 2, + defaultToneId: 1, + defaultVolume: 80, + tones: [ + { name: 'Burglar', duration: 30 }, + { name: 'Fire', duration: 30 }, + { name: 'Doorbell', duration: 5 }, + { name: 'Chime', duration: 3 }, + ], + }), + ], + }, + }, + // Status indicator — Indicator CC + { + id: 30, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Desk Indicator', + location: 'Office', + }), + ccCaps({ + ccId: CommandClasses.Indicator, + version: 4, + indicators: { + 0x43: { properties: [0x03, 0x04, 0x05] }, + 0x80: { properties: [0x01] }, + }, + }), + ], + }, + }, + // Scene controller — Central Scene CC + { + id: 31, + capabilities: { + genericDeviceClass: 0x18, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Bedside Scene Pad', + location: 'Bedroom', + }), + { ccId: CommandClasses['Central Scene'], version: 3 }, + CommandClasses.Battery, + ], + }, + }, + // Configurable-parameters device — exercises the Configuration UI + { + id: 32, + capabilities: { + genericDeviceClass: 0x10, + specificDeviceClass: 0x01, + commandClasses: [ + { ccId: CommandClasses.Version, version: 3 }, + CommandClasses['Manufacturer Specific'], + ccCaps({ + ccId: CommandClasses['Node Naming and Location'], + name: 'Config Demo Switch', + location: 'Lab', + }), + ccCaps({ + ccId: CommandClasses['Binary Switch'], + version: 2, + defaultValue: false, + }), + ccCaps({ + ccId: CommandClasses.Configuration, + version: 4, + parameters: [ + { + '#': 1, + valueSize: 1, + name: 'LED Brightness', + info: 'Brightness of the status LED (0-100%)', + minValue: 0, + maxValue: 100, + defaultValue: 50, + }, + { + '#': 2, + valueSize: 1, + name: 'Power-on State', + info: '0 = Off, 1 = On, 2 = Last state', + minValue: 0, + maxValue: 2, + defaultValue: 2, + }, + { + '#': 3, + valueSize: 2, + name: 'Auto-off Timer', + info: 'Seconds before auto-off (0 = disabled)', + minValue: 0, + maxValue: 3600, + defaultValue: 0, + }, + { + '#': 4, + valueSize: 1, + name: 'Operating Mode', + info: 'Advanced operating mode', + minValue: 0, + maxValue: 3, + defaultValue: 0, + isAdvanced: true, + }, + ], + }), + ], + }, + }, + ], +} diff --git a/package.json b/package.json index e957640bf48..44ebebfc303 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "dev-https": "SERVER_SSL='true' npm run dev", "dev:server": "nodemon", "server": "node --experimental-strip-types api/bin/www.ts", - "fake-stick": "npx mock-server -- -c server_config.js", + "fake-stick": "npx mock-server -- -c mock-server/002-window-covering-pinned.cjs", + "fake-stick:fleet": "npx mock-server -- -c mock-server", "start": "node --preserve-symlinks server/bin/www.js", "bundle": "node esbuild.js", "lint": "npm-run-all 'lint:*'", diff --git a/server_config.js b/server_config.js deleted file mode 100644 index e6c759ac759..00000000000 --- a/server_config.js +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-check -import { CommandClasses } from '@zwave-js/core' -import { ccCaps } from '@zwave-js/testing' - -export default { - nodes: [ - { - id: 2, - capabilities: { - commandClasses: [ - CommandClasses.Version, - ccCaps({ - ccId: CommandClasses['Window Covering'], - isSupported: true, - version: 1, - supportedParameters: [3, 11, 13, 23], - }), - ccCaps({ - ccId: CommandClasses['User Code'], - isSupported: true, - version: 2, - numUsers: 5, - supportedASCIIChars: '0123456789ABCDEF', - }), - ccCaps({ - ccId: CommandClasses['Schedule Entry Lock'], - isSupported: true, - version: 3, - numDailyRepeatingSlots: 1, - numWeekDaySlots: 1, - numYearDaySlots: 1, - }), - ], - }, - }, - ], -} diff --git a/src/App.vue b/src/App.vue index 19a7a3542e3..7cd1938325b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -950,6 +950,15 @@ export default { dismissSnackbar(toastId) { toast.dismiss(toastId) }, + /** + * Dismiss every visible snackbar/toast at once. `toast.dismiss()` with + * no id clears the whole stack. Wired to the `zwave:dismiss-snackbars` + * DOM event in `mounted()` so automation (e.g. the docs-screenshot + * capture script) can guarantee a toast-free frame before capturing. + */ + dismissAllSnackbars() { + toast.dismiss() + }, showSnackbar(text, color, options = { timeout: 3000 }) { const { timeout, ...rest } = options const toastOptions = { @@ -1923,6 +1932,12 @@ export default { { once: true }, ) + // Lets automation (docs-screenshot capture) clear all toasts on demand. + document.addEventListener( + 'zwave:dismiss-snackbars', + this.dismissAllSnackbars, + ) + // system dark mode const darkMode = useBaseStore().uiState.darkMode diff --git a/src/components/custom/NodePanel.vue b/src/components/custom/NodePanel.vue index 358b1988ada..945c1288da3 100644 --- a/src/components/custom/NodePanel.vue +++ b/src/components/custom/NodePanel.vue @@ -568,11 +568,12 @@ export default { async refreshNodeNeighbors() { if (!this.node) return - try { - await this.app.apiRequest('getNodeNeighbors', [this.node.id]) - } catch (error) { - // Silent fail - neighbors will be shown from cached data - } + // Passive refresh triggered when the panel opens — keep it quiet. + // On failure neighbors fall back to cached data. + await this.app.apiRequest('getNodeNeighbors', [this.node.id], { + infoSnack: false, + errorSnack: false, + }) }, checkMove(evt) { const { futureIndex } = evt.draggedContext diff --git a/src/components/custom/ZwaveGraph.vue b/src/components/custom/ZwaveGraph.vue index cc4c74911be..324d72b218c 100644 --- a/src/components/custom/ZwaveGraph.vue +++ b/src/components/custom/ZwaveGraph.vue @@ -846,6 +846,19 @@ export default { this.network = new Network(container, data, options) + // Test hook: when localStorage.exposeZwaveGraph is set, expose the + // vis-network instance on window so screenshot/automation tooling + // can deterministically select a node (vis-network's hit-testing on + // canvas clicks is non-deterministic under physics layout). No-op + // in normal usage since the flag is unset. See + // .claude/skills/refresh-docs-screenshots for the consumer. + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('exposeZwaveGraph') + ) { + window.__zwGraph = this + } + // event handlers // https://visjs.github.io/vis-network/docs/network/#Events this.network.once('stabilizationIterationsDone', () => {