From 7ad67f400fbb0644e569f1d0785d8a7d62f53690 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 22 Apr 2026 11:07:20 -0500 Subject: [PATCH 01/48] Carry over Pascal Home Assistant work onto dev-ha --- apps/editor/app/_lib/home-assistant-auth.ts | 147 + .../app/_lib/home-assistant-discovery.ts | 1179 +++++ .../editor/app/_lib/home-assistant-imports.ts | 111 + .../app/_lib/home-assistant-linked-profile.ts | 127 + apps/editor/app/_lib/home-assistant-server.ts | 843 ++++ .../app/api/home-assistant/connect/route.ts | 32 + .../home-assistant/connection-status/route.ts | 32 + .../api/home-assistant/device-action/route.ts | 70 + .../home-assistant/discover-devices/route.ts | 37 + .../home-assistant/import-resources/route.ts | 37 + .../home-assistant/oauth/callback/route.ts | 80 + .../api/home-assistant/oauth/start/route.ts | 54 + .../app/api/home-assistant/unlink/route.ts | 13 + bun.lock | 3 +- docs/ha-rts-architecture-migration-plan.md | 905 ++++ docs/ha-rts-demo1-plan.md | 268 ++ docs/ha-rts-detailed-tasklist.md | 1623 +++++++ docs/home-assistant-integration.md | 316 ++ packages/core/package.json | 5 + packages/core/src/schema/collections.ts | 220 + packages/core/src/schema/index.ts | 18 +- packages/core/src/store/use-scene.ts | 71 +- .../editor/floating-action-menu.tsx | 72 +- .../src/components/editor/floorplan-panel.tsx | 63 +- .../home-assistant-connectivity-panel.tsx | 757 ++++ .../editor/src/components/editor/index.tsx | 13 +- .../components/editor/node-action-menu.tsx | 20 + .../components/editor/selection-manager.tsx | 20 +- .../ceiling-selection-affordance-system.tsx | 6 +- .../components/systems/zone/zone-system.tsx | 5 +- .../ui/home-assistant-action-icon.tsx | 175 + .../ui/panels/home-assistant-panel.tsx | 578 +++ .../components/ui/panels/panel-manager.tsx | 5 + .../src/components/viewer-zone-system.tsx | 5 +- packages/editor/src/hooks/use-auto-save.ts | 32 +- .../src/lib/home-assistant-collections.ts | 283 ++ .../editor/src/lib/home-assistant-connect.ts | 40 + .../editor/src/lib/home-assistant-controls.ts | 576 +++ packages/editor/src/lib/home-assistant.ts | 661 +++ packages/editor/src/lib/scene.ts | 6 +- packages/editor/src/store/use-editor.tsx | 4 + packages/viewer/package.json | 2 + .../renderers/ceiling/ceiling-renderer.tsx | 3 +- .../renderers/slab/slab-renderer.tsx | 3 +- .../components/viewer/selection-manager.tsx | 17 +- packages/viewer/src/hooks/use-node-events.ts | 18 + packages/viewer/src/store/use-viewer.d.ts | 7 + packages/viewer/src/store/use-viewer.ts | 19 + .../interactive/interactive-system.tsx | 3779 ++++++++++++++++- 49 files changed, 13152 insertions(+), 208 deletions(-) create mode 100644 apps/editor/app/_lib/home-assistant-auth.ts create mode 100644 apps/editor/app/_lib/home-assistant-discovery.ts create mode 100644 apps/editor/app/_lib/home-assistant-imports.ts create mode 100644 apps/editor/app/_lib/home-assistant-linked-profile.ts create mode 100644 apps/editor/app/_lib/home-assistant-server.ts create mode 100644 apps/editor/app/api/home-assistant/connect/route.ts create mode 100644 apps/editor/app/api/home-assistant/connection-status/route.ts create mode 100644 apps/editor/app/api/home-assistant/device-action/route.ts create mode 100644 apps/editor/app/api/home-assistant/discover-devices/route.ts create mode 100644 apps/editor/app/api/home-assistant/import-resources/route.ts create mode 100644 apps/editor/app/api/home-assistant/oauth/callback/route.ts create mode 100644 apps/editor/app/api/home-assistant/oauth/start/route.ts create mode 100644 apps/editor/app/api/home-assistant/unlink/route.ts create mode 100644 docs/ha-rts-architecture-migration-plan.md create mode 100644 docs/ha-rts-demo1-plan.md create mode 100644 docs/ha-rts-detailed-tasklist.md create mode 100644 docs/home-assistant-integration.md create mode 100644 packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx create mode 100644 packages/editor/src/components/ui/home-assistant-action-icon.tsx create mode 100644 packages/editor/src/components/ui/panels/home-assistant-panel.tsx create mode 100644 packages/editor/src/lib/home-assistant-collections.ts create mode 100644 packages/editor/src/lib/home-assistant-connect.ts create mode 100644 packages/editor/src/lib/home-assistant-controls.ts create mode 100644 packages/editor/src/lib/home-assistant.ts diff --git a/apps/editor/app/_lib/home-assistant-auth.ts b/apps/editor/app/_lib/home-assistant-auth.ts new file mode 100644 index 000000000..643a9946e --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-auth.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto' +import type { NextRequest } from 'next/server' + +export const HOME_ASSISTANT_OAUTH_COOKIE = 'pascal_ha_oauth' + +export type HomeAssistantOauthCookieState = { + clientId: string + externalUrl: string | null + instanceUrl: string + redirectUri: string + state: string +} + +export type HomeAssistantTokenResponse = { + access_token: string + expires_in: number + refresh_token?: string + token_type: string +} + +function normalizeUrlValue(value: string) { + return value.trim().replace(/\/$/, '') +} + +export function normalizeHomeAssistantUrl(value: string) { + const normalized = normalizeUrlValue(value) + const url = new URL(normalized) + if (!(url.protocol === 'http:' || url.protocol === 'https:')) { + throw new Error('Home Assistant URL must use http or https.') + } + return url.toString().replace(/\/$/, '') +} + +export function normalizeOptionalHomeAssistantUrl(value: string | null | undefined) { + if (!value || value.trim().length === 0) { + return null + } + return normalizeHomeAssistantUrl(value) +} + +export function getRequestOrigin(request: NextRequest) { + const forwardedHost = request.headers.get('x-forwarded-host') + const forwardedProto = request.headers.get('x-forwarded-proto') + if (forwardedHost && forwardedProto) { + return `${forwardedProto}://${forwardedHost}` + } + return request.nextUrl.origin +} + +export function buildHomeAssistantOauthState( + request: NextRequest, + instanceUrl: string, + externalUrl: string | null, +): HomeAssistantOauthCookieState { + const clientId = getRequestOrigin(request) + return { + clientId, + externalUrl, + instanceUrl, + redirectUri: `${clientId}/api/home-assistant/oauth/callback`, + state: randomUUID(), + } +} + +function getOauthBaseUrl(oauthState: Pick) { + return oauthState.externalUrl ?? oauthState.instanceUrl +} + +export function buildHomeAssistantAuthorizeUrl(oauthState: HomeAssistantOauthCookieState) { + const authorizeUrl = new URL('/auth/authorize', getOauthBaseUrl(oauthState)) + authorizeUrl.searchParams.set('client_id', oauthState.clientId) + authorizeUrl.searchParams.set('redirect_uri', oauthState.redirectUri) + authorizeUrl.searchParams.set('state', oauthState.state) + return authorizeUrl.toString() +} + +function buildTokenRequestBody(params: Record) { + const body = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + body.set(key, value) + } + return body +} + +async function readTokenResponse(response: Response) { + const payload = (await response.json()) as HomeAssistantTokenResponse | { + error?: string + error_description?: string + } + + if (!response.ok) { + const errorPayload = + 'access_token' in payload + ? null + : payload + throw new Error( + errorPayload?.error_description || errorPayload?.error || 'Home Assistant token request failed.', + ) + } + + return payload as HomeAssistantTokenResponse +} + +export async function exchangeAuthorizationCode( + instanceUrl: string, + clientId: string, + code: string, + externalUrl?: string | null, +) { + const tokenUrl = new URL('/auth/token', externalUrl ?? instanceUrl) + const response = await fetch(tokenUrl, { + body: buildTokenRequestBody({ + client_id: clientId, + code, + grant_type: 'authorization_code', + }), + cache: 'no-store', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }) + + return readTokenResponse(response) +} + +export async function refreshHomeAssistantAccessToken( + instanceUrl: string, + clientId: string, + refreshToken: string, +) { + const tokenUrl = new URL('/auth/token', instanceUrl) + const response = await fetch(tokenUrl, { + body: buildTokenRequestBody({ + client_id: clientId, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + cache: 'no-store', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }) + + return readTokenResponse(response) +} diff --git a/apps/editor/app/_lib/home-assistant-discovery.ts b/apps/editor/app/_lib/home-assistant-discovery.ts new file mode 100644 index 000000000..c1019174a --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-discovery.ts @@ -0,0 +1,1179 @@ +import { createHash } from 'node:crypto' +import dgram from 'node:dgram' +import type { + HomeAssistantActionKind, + HomeAssistantAvailableAction, + HomeAssistantAvailableActionField, + HomeAssistantActionPresentation, + HomeAssistantCapabilityCategory, + HomeAssistantDiscoveredDevice, + HomeAssistantServiceTargetFilter, +} from '../../../../packages/editor/src/lib/home-assistant' +import { + getHomeAssistantAvailableActionPresentation, + getHomeAssistantCapabilityCategory, +} from '../../../../packages/editor/src/lib/home-assistant' +import { + type HomeAssistantEntityState, + type HomeAssistantServiceDescription, + type HomeAssistantServiceRegistryEntry, + type HomeAssistantServerConfig, + hasHomeAssistantServerConfig, + listEntityStates, + listServices, + readCastEntityFriendlyName, +} from './home-assistant-server' + +const MDNS_GROUP = '224.0.0.251' +const MDNS_PORT = 5353 +const SSDP_GROUP = '239.255.255.250' +const SSDP_PORT = 1900 +const DISCOVERY_TIMEOUT_MS = 1800 + +const MDNS_SERVICES: Array<{ + actionKind: HomeAssistantActionKind + actionLabel: string + deviceType: string + serviceType: string +}> = [ + { + actionKind: 'connect', + actionLabel: 'Connect', + deviceType: 'Google Cast', + serviceType: '_googlecast._tcp.local', + }, + { + actionKind: 'power', + actionLabel: 'Power', + deviceType: 'ESPHome Device', + serviceType: '_esphomelib._tcp.local', + }, + { + actionKind: 'power', + actionLabel: 'Power', + deviceType: 'HomeKit Device', + serviceType: '_hap._tcp.local', + }, + { + actionKind: 'power', + actionLabel: 'Power', + deviceType: 'Matter Device', + serviceType: '_matter._tcp.local', + }, +] + +const HA_DISCOVERABLE_DOMAINS = new Set([ + 'climate', + 'cover', + 'fan', + 'light', + 'lock', + 'media_player', + 'switch', + 'vacuum', +]) + +type MdnsRecord = + | { + classCode: number + name: string + ttl: number + type: 1 | 28 + value: string + } + | { + classCode: number + name: string + ttl: number + type: 12 + value: string + } + | { + classCode: number + name: string + port: number + priority: number + target: string + ttl: number + type: 33 + weight: number + } + | { + classCode: number + entries: string[] + name: string + properties: Record + ttl: number + type: 16 + } + +type SsdpResponse = { + headers: Record + location: string | null + server: string | null + st: string | null + usn: string | null +} + +type DeviceDescription = { + deviceType: string | null + friendlyName: string | null + manufacturer: string | null + modelName: string | null +} + +type SsdpClassification = { + actionKind: HomeAssistantActionKind + actionLabel: string + deviceType: string +} + +function stableDeviceId(parts: Array) { + const hash = createHash('sha1') + for (const part of parts) { + if (part) { + hash.update(part) + } + hash.update('|') + } + return hash.digest('hex').slice(0, 16) +} + +function getEntityDomain(entityId: string) { + return entityId.split('.')[0] ?? '' +} + +function encodeDnsName(name: string) { + const parts = name.replace(/\.$/, '').split('.') + return Buffer.concat([ + ...parts.map((part) => Buffer.concat([Buffer.from([part.length]), Buffer.from(part, 'utf8')])), + Buffer.from([0]), + ]) +} + +function buildMdnsQuery(serviceTypes: string[]) { + const header = Buffer.alloc(12) + header.writeUInt16BE(0, 0) + header.writeUInt16BE(0, 2) + header.writeUInt16BE(serviceTypes.length, 4) + header.writeUInt16BE(0, 6) + header.writeUInt16BE(0, 8) + header.writeUInt16BE(0, 10) + + const questions = serviceTypes.map((serviceType) => + Buffer.concat([ + encodeDnsName(serviceType), + Buffer.from([0x00, 0x0c]), + Buffer.from([0x00, 0x01]), + ]), + ) + + return Buffer.concat([header, ...questions]) +} + +function decodeDnsName( + buffer: Buffer, + offset: number, + visited = new Set(), +): { name: string; offset: number } { + if (visited.has(offset)) { + return { name: '', offset: offset + 1 } + } + visited.add(offset) + + const labels: string[] = [] + let currentOffset = offset + let finalOffset = offset + let jumped = false + + while (currentOffset < buffer.length) { + const length = buffer[currentOffset] ?? 0 + if (length === 0) { + finalOffset = jumped ? finalOffset : currentOffset + 1 + break + } + + if ((length & 0xc0) === 0xc0) { + const pointer = ((length & 0x3f) << 8) | (buffer[currentOffset + 1] ?? 0) + const decoded = decodeDnsName(buffer, pointer, visited) + if (decoded.name) { + labels.push(decoded.name) + } + finalOffset = jumped ? finalOffset : currentOffset + 2 + jumped = true + break + } + + const labelStart = currentOffset + 1 + const labelEnd = labelStart + length + labels.push(buffer.toString('utf8', labelStart, labelEnd)) + currentOffset = labelEnd + if (!jumped) { + finalOffset = currentOffset + } + } + + return { name: labels.filter(Boolean).join('.'), offset: finalOffset } +} + +function parseTxtRecord(data: Buffer) { + const entries: string[] = [] + const properties: Record = {} + let offset = 0 + + while (offset < data.length) { + const length = data[offset] + offset += 1 + if (!length || offset + length > data.length) { + continue + } + const entry = data.toString('utf8', offset, offset + length) + entries.push(entry) + const separatorIndex = entry.indexOf('=') + if (separatorIndex >= 0) { + properties[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1) + } else { + properties[entry] = '' + } + offset += length + } + + return { entries, properties } +} + +function parseMdnsPacket(message: Buffer) { + if (message.length < 12) { + return [] as MdnsRecord[] + } + + const questionCount = message.readUInt16BE(4) + const answerCount = message.readUInt16BE(6) + const authorityCount = message.readUInt16BE(8) + const additionalCount = message.readUInt16BE(10) + + let offset = 12 + for (let index = 0; index < questionCount; index += 1) { + const decoded = decodeDnsName(message, offset) + offset = decoded.offset + 4 + } + + const recordCount = answerCount + authorityCount + additionalCount + const records: MdnsRecord[] = [] + + for (let index = 0; index < recordCount; index += 1) { + const nameDecoded = decodeDnsName(message, offset) + offset = nameDecoded.offset + if (offset + 10 > message.length) { + break + } + + const type = message.readUInt16BE(offset) + const classCode = message.readUInt16BE(offset + 2) & 0x7fff + const ttl = message.readUInt32BE(offset + 4) + const dataLength = message.readUInt16BE(offset + 8) + const dataOffset = offset + 10 + const dataEnd = dataOffset + dataLength + if (dataEnd > message.length) { + break + } + + if (type === 12) { + const value = decodeDnsName(message, dataOffset).name + records.push({ classCode, name: nameDecoded.name, ttl, type: 12, value }) + } else if (type === 33) { + const priority = message.readUInt16BE(dataOffset) + const weight = message.readUInt16BE(dataOffset + 2) + const port = message.readUInt16BE(dataOffset + 4) + const target = decodeDnsName(message, dataOffset + 6).name + records.push({ classCode, name: nameDecoded.name, port, priority, target, ttl, type: 33, weight }) + } else if (type === 16) { + const parsed = parseTxtRecord(message.subarray(dataOffset, dataEnd)) + records.push({ classCode, entries: parsed.entries, name: nameDecoded.name, properties: parsed.properties, ttl, type: 16 }) + } else if (type === 1) { + const value = Array.from(message.subarray(dataOffset, dataEnd)).join('.') + records.push({ classCode, name: nameDecoded.name, ttl, type: 1, value }) + } else if (type === 28) { + const groups: string[] = [] + for (let groupIndex = 0; groupIndex < dataLength; groupIndex += 2) { + groups.push(message.readUInt16BE(dataOffset + groupIndex).toString(16)) + } + records.push({ classCode, name: nameDecoded.name, ttl, type: 28, value: groups.join(':') }) + } + + offset = dataEnd + } + + return records +} + +function collectUdpMessages({ + onMessage, + send, + timeoutMs, +}: { + onMessage: (message: Buffer, info: dgram.RemoteInfo) => void + send: (socket: dgram.Socket) => void + timeoutMs: number +}) { + return new Promise((resolve) => { + const socket = dgram.createSocket({ reuseAddr: true, type: 'udp4' }) + socket.on('error', () => { + socket.close() + resolve() + }) + socket.on('message', (message, info) => onMessage(message, info)) + socket.bind(0, () => { + try { + send(socket) + } catch { + socket.close() + resolve() + } + }) + setTimeout(() => { + socket.close() + resolve() + }, timeoutMs) + }) +} + +async function discoverMdnsDevices( + config: HomeAssistantServerConfig, + castFriendlyName: string | null, +) { + const records: MdnsRecord[] = [] + const query = buildMdnsQuery(MDNS_SERVICES.map((entry) => entry.serviceType)) + + await collectUdpMessages({ + onMessage: (message) => { + records.push(...parseMdnsPacket(message)) + }, + send: (socket) => { + socket.setMulticastTTL(255) + socket.send(query, MDNS_PORT, MDNS_GROUP) + }, + timeoutMs: DISCOVERY_TIMEOUT_MS, + }) + + const devices = new Map() + + for (const service of MDNS_SERVICES) { + const ptrRecords = records.filter( + (record): record is Extract => + record.type === 12 && record.name === service.serviceType, + ) + + for (const ptrRecord of ptrRecords) { + const instanceName = ptrRecord.value + const srvRecord = records.find( + (record): record is Extract => + record.type === 33 && record.name === instanceName, + ) + const txtRecord = records.find( + (record): record is Extract => + record.type === 16 && record.name === instanceName, + ) + + const hostName = srvRecord?.target ?? instanceName + const addressRecord = records.find( + (record): record is Extract => + (record.type === 1 || record.type === 28) && record.name === hostName, + ) + + const friendlyName = + txtRecord?.properties.fn ?? + txtRecord?.properties.name ?? + instanceName.replace(`.${service.serviceType}`, '') + const manufacturer = txtRecord?.properties.mf ?? null + const model = txtRecord?.properties.md ?? null + const ip = addressRecord?.value ?? null + const isCastDevice = service.serviceType === '_googlecast._tcp.local' + const actionable = + isCastDevice && + Boolean(config.castEntityId) && + (!castFriendlyName || !friendlyName || castFriendlyName === friendlyName) + + const description = `${service.deviceType} via mDNS (${service.serviceType})` + const id = `mdns-${stableDeviceId([service.serviceType, friendlyName, ip, txtRecord?.properties.id])}` + + devices.set(id, { + actionable, + attributes: null, + availableActions: [], + enabledActionCategories: [], + defaultActionKey: null, + defaultServiceData: {}, + description, + deviceType: service.deviceType, + haEntityId: actionable ? config.castEntityId : null, + id, + ip, + manufacturer, + model, + name: friendlyName, + protocol: 'mdns', + serviceType: service.serviceType, + supportedFeatures: null, + }) + } + } + + return Array.from(devices.values()) +} + +function parseSsdpResponse(message: Buffer) { + const lines = message + .toString('utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + const headers: Record = {} + for (const line of lines.slice(1)) { + const separatorIndex = line.indexOf(':') + if (separatorIndex <= 0) { + continue + } + const key = line.slice(0, separatorIndex).trim().toLowerCase() + headers[key] = line.slice(separatorIndex + 1).trim() + } + + return { + headers, + location: headers.location ?? null, + server: headers.server ?? null, + st: headers.st ?? null, + usn: headers.usn ?? null, + } satisfies SsdpResponse +} + +function buildSsdpSearch(st: string) { + return Buffer.from( + [ + 'M-SEARCH * HTTP/1.1', + `HOST: ${SSDP_GROUP}:${SSDP_PORT}`, + 'MAN: "ssdp:discover"', + 'MX: 1', + `ST: ${st}`, + '', + '', + ].join('\r\n'), + ) +} + +function matchXmlTag(xml: string, tagName: string) { + const match = new RegExp(`<${tagName}>([^<]+)`, 'i').exec(xml) + return match?.[1]?.trim() ?? null +} + +async function fetchDeviceDescription(location: string | null) { + if (!location) { + return null + } + + try { + const response = await fetch(location, { + cache: 'no-store', + signal: AbortSignal.timeout(1200), + }) + if (!response.ok) { + return null + } + + const xml = await response.text() + return { + deviceType: matchXmlTag(xml, 'deviceType'), + friendlyName: matchXmlTag(xml, 'friendlyName'), + manufacturer: matchXmlTag(xml, 'manufacturer'), + modelName: matchXmlTag(xml, 'modelName'), + } satisfies DeviceDescription + } catch { + return null + } +} + +function classifySsdpResponse( + response: SsdpResponse, + description: DeviceDescription | null, +): SsdpClassification | null { + const haystack = [ + response.st, + response.usn, + response.server, + description?.deviceType, + description?.friendlyName, + description?.manufacturer, + description?.modelName, + response.location, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (haystack.includes('roku')) { + return { + actionKind: 'connect', + actionLabel: 'Connect', + deviceType: 'Roku Device', + } + } + + if ( + haystack.includes('google cast') || + haystack.includes('chromecast') || + haystack.includes('mediarenderer') || + haystack.includes('sonos') + ) { + return { + actionKind: + haystack.includes('chromecast') || haystack.includes('google cast') ? 'connect' : 'play', + actionLabel: + haystack.includes('chromecast') || haystack.includes('google cast') ? 'Connect' : 'Play', + deviceType: + haystack.includes('chromecast') || haystack.includes('google cast') + ? 'Google Cast' + : 'Media Renderer', + } + } + + if ( + haystack.includes('wemo') || + haystack.includes('tplink') || + haystack.includes('tapo') || + haystack.includes('shelly') || + haystack.includes('switch') || + haystack.includes('smart plug') || + haystack.includes('plug') || + haystack.includes('light') + ) { + return { + actionKind: 'power', + actionLabel: 'Power', + deviceType: 'Smart Power Device', + } + } + + return null +} + +async function discoverSsdpDevices( + config: HomeAssistantServerConfig, + castFriendlyName: string | null, +) { + const responses = new Map() + const searchTargets = ['ssdp:all', 'roku:ecp', 'urn:schemas-upnp-org:device:MediaRenderer:1'] + + await collectUdpMessages({ + onMessage: (message) => { + const response = parseSsdpResponse(message) + const key = + response.usn ?? + response.location ?? + response.st ?? + stableDeviceId([message.toString('utf8')]) + responses.set(key, response) + }, + send: (socket) => { + for (const searchTarget of searchTargets) { + socket.send(buildSsdpSearch(searchTarget), SSDP_PORT, SSDP_GROUP) + } + }, + timeoutMs: DISCOVERY_TIMEOUT_MS, + }) + + const devices: HomeAssistantDiscoveredDevice[] = [] + + for (const response of responses.values()) { + const description = await fetchDeviceDescription(response.location) + const classification = classifySsdpResponse(response, description) + if (!classification) { + continue + } + + const name = description?.friendlyName ?? response.server ?? response.st ?? 'Unknown device' + const ip = response.location?.match(/https?:\/\/([^/:]+)/i)?.[1] ?? null + const isCastDevice = classification.deviceType === 'Google Cast' + const actionable = + isCastDevice && + Boolean(config.castEntityId) && + (!castFriendlyName || !name || castFriendlyName === name) + + devices.push({ + actionable, + attributes: null, + availableActions: [], + enabledActionCategories: [], + defaultActionKey: null, + defaultServiceData: {}, + description: `${classification.deviceType} via SSDP`, + deviceType: classification.deviceType, + haEntityId: actionable ? config.castEntityId : null, + id: `ssdp-${stableDeviceId([response.usn, response.location, name, ip])}`, + ip, + manufacturer: description?.manufacturer ?? null, + model: description?.modelName ?? null, + name, + protocol: 'ssdp', + serviceType: response.st, + supportedFeatures: null, + }) + } + + return devices +} + +function readStringAttribute( + attributes: Record | undefined, + ...keys: string[] +) { + for (const key of keys) { + const value = attributes?.[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + + return null +} + +function titleCase(value: string) { + return value + .split(/[_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function normalizeSupportedFeatureGroups( + supportedFeatures: Array | undefined, +): Array { + if (!Array.isArray(supportedFeatures)) { + return [] + } + + return supportedFeatures.filter((entry) => + Array.isArray(entry) + ? entry.every((item) => typeof item === 'number') + : typeof entry === 'number', + ) +} + +function matchesSupportedFeatures( + entitySupportedFeatures: number | null, + requiredFeatures: Array, +) { + if (requiredFeatures.length === 0) { + return true + } + + if (entitySupportedFeatures === null) { + return false + } + + return requiredFeatures.some((entry) => + Array.isArray(entry) + ? entry.every((feature) => (entitySupportedFeatures & feature) === feature) + : (entitySupportedFeatures & entry) === entry, + ) +} + +function matchesAttributeFilter( + attributes: Record | undefined, + filterAttribute: Record | undefined, +) { + if (!filterAttribute) { + return true + } + + return Object.entries(filterAttribute).every(([attributeName, allowedValues]) => { + const value = attributes?.[attributeName] + if (Array.isArray(value)) { + return value.some((item) => allowedValues.some((allowedValue) => allowedValue === item)) + } + + return allowedValues.some((allowedValue) => allowedValue === value) + }) +} + +function normalizeEntityTargetFilters( + serviceDescription: HomeAssistantServiceDescription, +): HomeAssistantServiceTargetFilter[] { + const entityTarget = serviceDescription.target?.entity + if (Array.isArray(entityTarget)) { + return entityTarget + } + + return entityTarget ? [entityTarget] : [] +} + +function matchesEntityTarget( + domain: string, + entitySupportedFeatures: number | null, + serviceDescription: HomeAssistantServiceDescription, +) { + const targetFilters = normalizeEntityTargetFilters(serviceDescription) + if (targetFilters.length === 0) { + return false + } + + return targetFilters.some((filter) => { + const matchesDomain = !filter.domain || filter.domain.includes(domain) + if (!matchesDomain) { + return false + } + + return matchesSupportedFeatures( + entitySupportedFeatures, + normalizeSupportedFeatureGroups(filter.supported_features), + ) + }) +} + +function deriveActionKind( + serviceDomain: string, + serviceName: string, + state: HomeAssistantEntityState, + config: HomeAssistantServerConfig, +): HomeAssistantActionKind { + if ( + state.entity_id === config.castEntityId && + serviceDomain === 'media_player' && + serviceName === 'play_media' + ) { + return 'connect' + } + + switch (serviceName) { + case 'turn_on': + return 'turn_on' + case 'turn_off': + return 'turn_off' + case 'toggle': + return 'power' + case 'media_play': + case 'media_play_pause': + case 'play_media': + case 'repeat_set': + case 'shuffle_set': + case 'media_seek': + case 'select_source': + case 'clear_playlist': + case 'join': + case 'unjoin': + return 'play' + case 'media_pause': + return 'pause' + case 'media_stop': + return 'stop' + case 'media_next_track': + return 'next' + case 'media_previous_track': + return 'previous' + case 'volume_up': + case 'volume_down': + case 'volume_set': + case 'volume_mute': + case 'select_sound_mode': + return 'volume' + case 'lock': + return 'lock' + case 'unlock': + return 'unlock' + case 'open_cover': + return 'open' + case 'close_cover': + return 'close' + default: + return serviceDomain === 'lock' ? 'lock' : 'custom' + } +} + +function buildActionLabel( + serviceDomain: string, + serviceName: string, + state: HomeAssistantEntityState, + config: HomeAssistantServerConfig, + actionKind: HomeAssistantActionKind, +) { + const actionPresentation = getHomeAssistantAvailableActionPresentation({ + actionKind, + domain: serviceDomain, + label: + state.entity_id === config.castEntityId && + serviceDomain === 'media_player' && + serviceName === 'play_media' + ? 'Connect' + : titleCase(serviceName), + service: serviceName, + }) + + return actionPresentation.label +} + +function buildActionFields( + serviceDescription: HomeAssistantServiceDescription, + state: HomeAssistantEntityState, + entitySupportedFeatures: number | null, +) { + return Object.entries(serviceDescription.fields ?? {}).reduce( + (fields, [fieldKey, fieldDescription]) => { + const supportedFeatureFilter = normalizeSupportedFeatureGroups( + fieldDescription.filter?.supported_features, + ) + const passesSupportedFeatures = matchesSupportedFeatures( + entitySupportedFeatures, + supportedFeatureFilter, + ) + const passesAttributeFilter = matchesAttributeFilter( + state.attributes, + fieldDescription.filter?.attribute, + ) + + if (!passesSupportedFeatures || !passesAttributeFilter) { + return fields + } + + fields.push({ + advanced: Boolean(fieldDescription.advanced), + defaultValue: fieldDescription.default ?? null, + example: fieldDescription.example ?? null, + filterAttribute: fieldDescription.filter?.attribute ?? null, + filterSupportedFeatures: + supportedFeatureFilter.length > 0 ? supportedFeatureFilter : null, + key: fieldKey, + label: titleCase(fieldKey), + required: Boolean(fieldDescription.required), + selector: fieldDescription.selector ?? null, + }) + + return fields + }, + [], + ) +} + +function buildAvailableActions( + state: HomeAssistantEntityState, + config: HomeAssistantServerConfig, + services: HomeAssistantServiceRegistryEntry[], +) { + const domain = getEntityDomain(state.entity_id) + const entitySupportedFeatures = + typeof state.attributes?.supported_features === 'number' ? state.attributes.supported_features : null + + const actionsByDisplayKey = new Map< + string, + { + action: HomeAssistantAvailableAction + presentation: HomeAssistantActionPresentation + score: number + } + >() + + function scoreActionCandidate(action: HomeAssistantAvailableAction) { + let score = action.domain === domain ? 100 : action.domain === 'homeassistant' ? 10 : 40 + + switch (action.service) { + case 'media_play': + case 'media_pause': + case 'media_stop': + case 'media_next_track': + case 'media_previous_track': + case 'turn_on': + case 'turn_off': + case 'toggle': + case 'volume_up': + case 'volume_down': + case 'volume_set': + case 'volume_mute': + case 'select_source': + case 'select_sound_mode': + case 'repeat_set': + case 'shuffle_set': + case 'media_seek': + case 'set_cover_position': + case 'set_cover_tilt_position': + case 'set_temperature': + case 'set_humidity': + case 'set_hvac_mode': + case 'set_fan_mode': + case 'set_swing_mode': + case 'set_swing_horizontal_mode': + case 'set_percentage': + case 'set_direction': + case 'oscillate': + case 'set_preset_mode': + case 'start': + case 'return_to_base': + case 'locate': + case 'clean_spot': + case 'clean_area': + case 'lock': + case 'unlock': + case 'open_cover': + case 'close_cover': + case 'open_cover_tilt': + case 'close_cover_tilt': + case 'stop_cover': + case 'stop_cover_tilt': + score += 25 + break + case 'media_play_pause': + score += 15 + break + case 'play_media': + score += action.actionKind === 'connect' ? 30 : 5 + break + default: + score += 5 + break + } + + return score + } + + for (const serviceRegistryEntry of services) { + for (const [serviceName, serviceDescription] of Object.entries(serviceRegistryEntry.services)) { + if (serviceDescription.response && serviceDescription.response.optional === false) { + continue + } + + if (!matchesEntityTarget(domain, entitySupportedFeatures, serviceDescription)) { + continue + } + + const actionKind = deriveActionKind(serviceRegistryEntry.domain, serviceName, state, config) + const action: HomeAssistantAvailableAction = { + actionKind, + description: `${serviceRegistryEntry.domain}.${serviceName}`, + domain: serviceRegistryEntry.domain, + fields: buildActionFields(serviceDescription, state, entitySupportedFeatures), + key: `${serviceRegistryEntry.domain}.${serviceName}`, + label: buildActionLabel(serviceRegistryEntry.domain, serviceName, state, config, actionKind), + service: serviceName, + } + const presentation = getHomeAssistantAvailableActionPresentation(action) + const score = scoreActionCandidate(action) + const existingEntry = actionsByDisplayKey.get(presentation.displayKey) + + if (!existingEntry || score > existingEntry.score) { + actionsByDisplayKey.set(presentation.displayKey, { + action, + presentation, + score, + }) + } + } + } + + return Array.from(actionsByDisplayKey.values()).map((entry) => ({ + ...entry.action, + label: entry.presentation.label, + })) +} + +function pickDefaultActionKey( + state: HomeAssistantEntityState, + config: HomeAssistantServerConfig, + actions: HomeAssistantAvailableAction[], +) { + const preferredServiceKeys = + state.entity_id === config.castEntityId + ? ['media_player.play_media', 'media_player.turn_on', 'media_player.toggle'] + : [ + `${getEntityDomain(state.entity_id)}.toggle`, + `${getEntityDomain(state.entity_id)}.turn_on`, + 'media_player.media_play', + 'media_player.play_media', + actions[0]?.key ?? '', + ] + + return preferredServiceKeys.find((serviceKey) => actions.some((action) => action.key === serviceKey)) ?? null +} + +function classifyHomeAssistantEntity( + state: HomeAssistantEntityState, + config: HomeAssistantServerConfig, +) { + const domain = getEntityDomain(state.entity_id) + if (!HA_DISCOVERABLE_DOMAINS.has(domain) || state.state === 'unavailable') { + return null + } + + if (domain === 'media_player') { + const isConfiguredCast = state.entity_id === config.castEntityId + return { + deviceType: isConfiguredCast ? 'Google Cast' : 'Media Player', + } + } + + const deviceTypeByDomain: Record = { + climate: 'Climate Device', + cover: 'Smart Cover', + fan: 'Smart Fan', + light: 'Smart Light', + lock: 'Smart Lock', + switch: 'Smart Switch', + vacuum: 'Robot Vacuum', + } + + return { + deviceType: deviceTypeByDomain[domain] ?? 'Smart Device', + } +} + +async function discoverHomeAssistantEntityDevices( + config: HomeAssistantServerConfig, + castFriendlyName: string | null, + services: HomeAssistantServiceRegistryEntry[], +) { + if (!(config.baseUrl && config.accessToken)) { + return [] as HomeAssistantDiscoveredDevice[] + } + + let states: HomeAssistantEntityState[] + try { + states = await listEntityStates(config) + } catch { + return [] as HomeAssistantDiscoveredDevice[] + } + + const devices: HomeAssistantDiscoveredDevice[] = [] + + for (const state of states) { + const classification = classifyHomeAssistantEntity(state, config) + if (!classification) { + continue + } + + const availableActions = buildAvailableActions(state, config, services) + if (availableActions.length === 0) { + continue + } + + const friendlyName = + readStringAttribute(state.attributes, 'friendly_name') ?? state.entity_id.replace(/_/g, ' ') + const manufacturer = readStringAttribute( + state.attributes, + 'manufacturer', + 'device_manufacturer', + 'hw_version', + ) + const model = readStringAttribute( + state.attributes, + 'model', + 'model_name', + 'device_model', + 'sw_version', + ) + const ip = readStringAttribute(state.attributes, 'ip_address', 'ip') + const isConfiguredCast = + state.entity_id === config.castEntityId || (!!castFriendlyName && friendlyName === castFriendlyName) + const supportedFeatures = + typeof state.attributes?.supported_features === 'number' ? state.attributes.supported_features : null + const enabledActionCategories = Array.from( + new Set( + availableActions.map((action) => getHomeAssistantCapabilityCategory(action.actionKind)), + ), + ) + + devices.push({ + actionable: true, + attributes: state.attributes ?? null, + availableActions, + enabledActionCategories, + defaultActionKey: pickDefaultActionKey(state, config, availableActions), + defaultServiceData: {}, + description: `${classification.deviceType} via Home Assistant`, + deviceType: isConfiguredCast ? 'Google Cast' : classification.deviceType, + haEntityId: state.entity_id, + id: `ha-${stableDeviceId([state.entity_id, friendlyName, manufacturer, model])}`, + ip, + manufacturer, + model, + name: friendlyName, + protocol: 'home-assistant', + serviceType: null, + supportedFeatures, + }) + } + + return devices +} + +function shouldPreferDiscoveredDevice( + nextDevice: HomeAssistantDiscoveredDevice, + existingDevice: HomeAssistantDiscoveredDevice | undefined, +) { + if (!existingDevice) { + return true + } + + if (existingDevice.protocol === 'home-assistant' && nextDevice.protocol !== 'home-assistant') { + return true + } + + if (!existingDevice.ip && nextDevice.ip) { + return true + } + + return false +} + +function mergeDiscoveredDevice( + nextDevice: HomeAssistantDiscoveredDevice, + existingDevice: HomeAssistantDiscoveredDevice | undefined, +) { + if (!existingDevice) { + return nextDevice + } + + const preferredDevice = shouldPreferDiscoveredDevice(nextDevice, existingDevice) + ? nextDevice + : existingDevice + const fallbackDevice = preferredDevice === nextDevice ? existingDevice : nextDevice + + return { + ...preferredDevice, + attributes: preferredDevice.attributes ?? fallbackDevice.attributes, + availableActions: + preferredDevice.availableActions.length > 0 + ? preferredDevice.availableActions + : fallbackDevice.availableActions, + enabledActionCategories: + preferredDevice.enabledActionCategories.length > 0 + ? preferredDevice.enabledActionCategories + : fallbackDevice.enabledActionCategories, + defaultActionKey: preferredDevice.defaultActionKey ?? fallbackDevice.defaultActionKey, + supportedFeatures: preferredDevice.supportedFeatures ?? fallbackDevice.supportedFeatures, + } +} + +export async function discoverHomeAssistantDevices(config: HomeAssistantServerConfig) { + const castFriendlyName = hasHomeAssistantServerConfig(config) + ? await readCastEntityFriendlyName(config) + : null + const services = hasHomeAssistantServerConfig(config) + ? await listServices(config).catch(() => [] as HomeAssistantServiceRegistryEntry[]) + : [] + + const [mdnsDevices, ssdpDevices, homeAssistantEntityDevices] = await Promise.all([ + discoverMdnsDevices(config, castFriendlyName), + discoverSsdpDevices(config, castFriendlyName), + discoverHomeAssistantEntityDevices(config, castFriendlyName, services), + ]) + + const uniqueDevices = new Map() + for (const device of [...mdnsDevices, ...ssdpDevices, ...homeAssistantEntityDevices]) { + if (!device.actionable) { + continue + } + + const key = device.haEntityId ?? `${device.deviceType}:${device.name}:${device.ip ?? ''}` + uniqueDevices.set(key, mergeDiscoveredDevice(device, uniqueDevices.get(key))) + } + + return Array.from(uniqueDevices.values()) + .filter((device) => device.availableActions.length > 0) + .sort((left, right) => left.name.localeCompare(right.name)) +} diff --git a/apps/editor/app/_lib/home-assistant-imports.ts b/apps/editor/app/_lib/home-assistant-imports.ts new file mode 100644 index 000000000..f1ec9649a --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-imports.ts @@ -0,0 +1,111 @@ +import type { + CollectionCapability, + CollectionHomeAssistantAction, + HomeAssistantResourceKind, +} from '@pascal-app/core/schema' +import type { HomeAssistantImportedResource } from '../../../../packages/editor/src/lib/home-assistant-collections' +import { toImportedEntityResource } from '../../../../packages/editor/src/lib/home-assistant-collections' +import { discoverHomeAssistantDevices } from './home-assistant-discovery' +import type { HomeAssistantEntityState, HomeAssistantServerConfig } from './home-assistant-server' +import { listEntityStates } from './home-assistant-server' + +const IMPORTABLE_TRIGGER_DOMAINS: Array<{ + capability: CollectionCapability + kind: HomeAssistantResourceKind + service: string + stateEntityDomain: string +}> = [ + { + capability: 'trigger', + kind: 'scene', + service: 'turn_on', + stateEntityDomain: 'scene', + }, + { + capability: 'trigger', + kind: 'script', + service: 'turn_on', + stateEntityDomain: 'script', + }, + { + capability: 'trigger', + kind: 'automation', + service: 'trigger', + stateEntityDomain: 'automation', + }, +] + +function createTriggerAction( + domain: string, + label: string, + service: string, +): CollectionHomeAssistantAction { + return { + capability: 'trigger', + domain, + fields: [], + key: `${domain}.${service}`, + label, + service, + } +} + +function toTriggerResource( + state: HomeAssistantEntityState, + domainConfig: (typeof IMPORTABLE_TRIGGER_DOMAINS)[number], +): HomeAssistantImportedResource { + const label = + typeof state.attributes?.friendly_name === 'string' && state.attributes.friendly_name.trim().length > 0 + ? state.attributes.friendly_name.trim() + : state.entity_id + + return { + actions: [createTriggerAction(domainConfig.stateEntityDomain, label, domainConfig.service)], + capabilities: [domainConfig.capability], + defaultActionKey: `${domainConfig.stateEntityDomain}.${domainConfig.service}`, + description: `${domainConfig.kind} imported from Home Assistant`, + domain: domainConfig.stateEntityDomain, + entityId: state.entity_id, + id: state.entity_id, + kind: domainConfig.kind, + label, + state: state.state, + } +} + +function getTriggerResources(states: HomeAssistantEntityState[]) { + return states.flatMap((state) => { + const domain = state.entity_id.split('.')[0] ?? '' + const domainConfig = IMPORTABLE_TRIGGER_DOMAINS.find( + (entry) => entry.stateEntityDomain === domain, + ) + if (!domainConfig) { + return [] + } + + return [toTriggerResource(state, domainConfig)] + }) +} + +export async function listImportableHomeAssistantResources( + config: HomeAssistantServerConfig, +): Promise { + const [devices, states] = await Promise.all([ + discoverHomeAssistantDevices(config), + listEntityStates(config), + ]) + + const resources = [ + ...devices.map((device) => toImportedEntityResource(device)), + ...getTriggerResources(states), + ] + + const uniqueResources = new Map() + for (const resource of resources) { + uniqueResources.set(resource.id, resource) + } + + return Array.from(uniqueResources.values()).sort((left, right) => + left.label.localeCompare(right.label), + ) +} diff --git a/apps/editor/app/_lib/home-assistant-linked-profile.ts b/apps/editor/app/_lib/home-assistant-linked-profile.ts new file mode 100644 index 000000000..da8e18f64 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-linked-profile.ts @@ -0,0 +1,127 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { promises as fs } from 'node:fs' + +const STORAGE_DIRECTORY_NAME = '.pascal-home-assistant' +const STORAGE_FILE_NAME = 'linked-instance.enc' +const STORAGE_KEY_FILE_NAME = 'storage-key.txt' +const STORAGE_VERSION = 1 +const IV_LENGTH = 12 + +export type HomeAssistantLinkedProfile = { + accessToken: string + accessTokenExpiresAt: string + clientId: string + externalUrl: string | null + instanceUrl: string + linkedAt: string + refreshToken: string +} + +type StoredHomeAssistantLinkedProfile = { + profile: HomeAssistantLinkedProfile | null + version: number +} + +function getStorageDirectory() { + return path.join(os.homedir(), STORAGE_DIRECTORY_NAME) +} + +function getStorageFilePath() { + return path.join(getStorageDirectory(), STORAGE_FILE_NAME) +} + +function getStorageKeyFilePath() { + return path.join(getStorageDirectory(), STORAGE_KEY_FILE_NAME) +} + +async function ensureStorageDirectory() { + await fs.mkdir(getStorageDirectory(), { recursive: true }) +} + +async function readOrCreateStorageKey() { + await ensureStorageDirectory() + + try { + const existing = await fs.readFile(getStorageKeyFilePath(), 'utf8') + const decoded = Buffer.from(existing.trim(), 'base64') + if (decoded.length === 32) { + return decoded + } + } catch {} + + const nextKey = randomBytes(32) + await fs.writeFile(getStorageKeyFilePath(), nextKey.toString('base64'), 'utf8') + return nextKey +} + +async function encryptPayload(payload: StoredHomeAssistantLinkedProfile) { + const key = await readOrCreateStorageKey() + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const plaintext = Buffer.from(JSON.stringify(payload), 'utf8') + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + + return JSON.stringify({ + ciphertext: encrypted.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64'), + }) +} + +async function decryptPayload(encryptedPayload: string): Promise { + const key = await readOrCreateStorageKey() + const parsed = JSON.parse(encryptedPayload) as { + ciphertext?: string + iv?: string + tag?: string + } + + if (!(parsed.ciphertext && parsed.iv && parsed.tag)) { + throw new Error('Invalid linked Home Assistant payload.') + } + + const decipher = createDecipheriv( + 'aes-256-gcm', + key, + Buffer.from(parsed.iv, 'base64'), + ) + decipher.setAuthTag(Buffer.from(parsed.tag, 'base64')) + + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(parsed.ciphertext, 'base64')), + decipher.final(), + ]) + + return JSON.parse(decrypted.toString('utf8')) as StoredHomeAssistantLinkedProfile +} + +export async function readLinkedHomeAssistantProfile() { + try { + const encryptedPayload = await fs.readFile(getStorageFilePath(), 'utf8') + const parsed = await decryptPayload(encryptedPayload) + if (parsed.version !== STORAGE_VERSION) { + return null + } + return parsed.profile + } catch { + return null + } +} + +export async function writeLinkedHomeAssistantProfile(profile: HomeAssistantLinkedProfile) { + await ensureStorageDirectory() + const encryptedPayload = await encryptPayload({ + profile, + version: STORAGE_VERSION, + }) + await fs.writeFile(getStorageFilePath(), encryptedPayload, 'utf8') +} + +export async function clearLinkedHomeAssistantProfile() { + try { + await fs.unlink(getStorageFilePath()) + } catch {} +} diff --git a/apps/editor/app/_lib/home-assistant-server.ts b/apps/editor/app/_lib/home-assistant-server.ts new file mode 100644 index 000000000..ce3b7ea52 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-server.ts @@ -0,0 +1,843 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { + CollectionHomeAssistantActionRequest, + CollectionHomeAssistantBinding, +} from '@pascal-app/core/schema' +import type { HomeAssistantActionKind, HomeAssistantLink } from '../../../../packages/editor/src/lib/home-assistant' +import { refreshHomeAssistantAccessToken } from './home-assistant-auth' +import { + clearLinkedHomeAssistantProfile, + readLinkedHomeAssistantProfile, + writeLinkedHomeAssistantProfile, +} from './home-assistant-linked-profile' + +const DEFAULT_TEST_MEDIA_URL = + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4' +const DEFAULT_TEST_MEDIA_TYPE = 'video/mp4' +const DEFAULT_TEST_DURATION_SECONDS = 5 +const DEFAULT_WAKE_DELAY_MS = 5000 +const execFileAsync = promisify(execFile) + +const CHROMECAST_RELEASE_SCRIPT = String.raw` +import json +import sys +import time + +import pychromecast + +target_host = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != '__none__' else None +target_name = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != '__none__' else None + +if not target_host and not target_name: + raise RuntimeError('Missing Chromecast host or name.') + +chromecasts = [] +browser = None +target = None + +try: + chromecasts, browser = pychromecast.get_chromecasts(timeout=8) + + if target_host: + for chromecast in chromecasts: + host = getattr(getattr(chromecast, 'cast_info', None), 'host', None) + if host == target_host: + target = chromecast + break + + if target is None and target_name: + for chromecast in chromecasts: + if getattr(chromecast, 'name', None) == target_name: + target = chromecast + break + + if target is None: + raise RuntimeError(f'Could not find Chromecast target for host={target_host!r} name={target_name!r}.') + + target.wait(timeout=10) + + before = { + 'app_display_name': getattr(target, 'app_display_name', None), + 'app_id': getattr(target, 'app_id', None), + 'is_idle': bool(getattr(target, 'is_idle', False)), + } + + target.quit_app() + for _ in range(12): + if getattr(target, 'app_display_name', None) == 'Backdrop': + break + time.sleep(1) + + after = { + 'app_display_name': getattr(target, 'app_display_name', None), + 'app_id': getattr(target, 'app_id', None), + 'is_idle': bool(getattr(target, 'is_idle', False)), + } + + print(json.dumps({'before': before, 'after': after})) +finally: + if target is not None: + try: + target.disconnect() + except Exception: + pass + + if browser is not None: + pychromecast.discovery.stop_discovery(browser) +` + +export type HomeAssistantServerConfig = { + accessToken: string + baseUrl: string + baseUrlCandidates: string[] + castEntityId: string + clientId?: string + externalUrl?: string | null + instanceUrl?: string + mode?: 'linked-session' | 'local-env' + testDurationSeconds: number + testMediaType: string + testMediaUrl: string + wakeDelayMs: number +} + +export type HomeAssistantEntityState = { + attributes?: Record + entity_id: string + state: string +} + +type ChromecastReleaseResult = { + afterAppDisplayName: string | null + beforeAppDisplayName: string | null + released: boolean +} + +export type HomeAssistantServiceFieldDescription = { + advanced?: boolean + default?: unknown + example?: unknown + filter?: { + attribute?: Record + supported_features?: Array + } + required?: boolean + selector?: Record +} + +export type HomeAssistantServiceDescription = { + fields?: Record + response?: { + optional: boolean + } + target?: { + entity?: + | { + domain?: string[] + supported_features?: Array + } + | Array<{ + domain?: string[] + supported_features?: Array + }> + } +} + +export type HomeAssistantServiceRegistryEntry = { + domain: string + services: Record +} + +export type HomeAssistantDeviceActionResponse = { + actionKind: HomeAssistantActionKind + availableAfterAction: boolean + deviceName: string + finalState: string + initialFriendlyName: string | null + initialState: string + itemName: string + message: string + observedAppNames: string[] + success: boolean + timeline: Array<{ + appName: string | null + mediaTitle: string | null + second: number + state: string + }> +} + +export type HomeAssistantConnectionStatus = { + baseUrl: string | null + castEntityId: string | null + castFriendlyName: string | null + clientId: string | null + entityCount: number + externalUrl: string | null + instanceUrl: string | null + linked: boolean + message: string + mode: 'linked-session' | 'local-env' | 'unlinked' + success: boolean +} + +export type HomeAssistantCollectionActionResponse = { + collectionName: string + message: string + results: Array<{ + entityId: string | null + finalState: string | null + resourceId: string + }> + success: boolean +} + +function parsePositiveInt(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value?.trim() ?? `${fallback}`, 10) + return Number.isFinite(parsed) ? Math.max(1, parsed) : fallback +} + +function parseNonNegativeInt(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value?.trim() ?? `${fallback}`, 10) + return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback +} + +export function readHomeAssistantServerConfig(): HomeAssistantServerConfig { + const baseUrl = process.env.NEXT_PUBLIC_HA_BASE_URL?.trim() ?? '' + const accessToken = process.env.NEXT_PUBLIC_HA_ACCESS_TOKEN?.trim() ?? '' + const castEntityId = process.env.NEXT_PUBLIC_HA_CAST_ENTITY_ID?.trim() ?? '' + + return { + accessToken, + baseUrl: baseUrl.replace(/\/$/, ''), + baseUrlCandidates: baseUrl ? [baseUrl.replace(/\/$/, '')] : [], + castEntityId, + clientId: undefined, + externalUrl: null, + instanceUrl: baseUrl.replace(/\/$/, ''), + mode: 'local-env', + testDurationSeconds: parsePositiveInt( + process.env.NEXT_PUBLIC_HA_TEST_DURATION_SECONDS, + DEFAULT_TEST_DURATION_SECONDS, + ), + testMediaType: process.env.NEXT_PUBLIC_HA_TEST_MEDIA_TYPE?.trim() ?? DEFAULT_TEST_MEDIA_TYPE, + testMediaUrl: process.env.NEXT_PUBLIC_HA_TEST_MEDIA_URL?.trim() ?? DEFAULT_TEST_MEDIA_URL, + wakeDelayMs: parseNonNegativeInt(process.env.NEXT_PUBLIC_HA_WAKE_DELAY_MS, DEFAULT_WAKE_DELAY_MS), + } +} + +export function hasHomeAssistantServerConfig(config: HomeAssistantServerConfig) { + return Boolean(config.baseUrl && config.accessToken) +} + +function getAccessTokenExpiresAt(expiresInSeconds: number) { + return new Date(Date.now() + expiresInSeconds * 1000).toISOString() +} + +function getLinkedProfileBaseUrlCandidates(linkedProfile: { + externalUrl?: string | null + instanceUrl: string +}) { + return Array.from( + new Set([linkedProfile.externalUrl, linkedProfile.instanceUrl].filter(Boolean)), + ) as string[] +} + +async function refreshLinkedProfileAccessToken(linkedProfile: { + clientId: string + externalUrl?: string | null + instanceUrl: string + refreshToken: string +}) { + const candidates = getLinkedProfileBaseUrlCandidates(linkedProfile) + let lastError: unknown = null + + for (const candidate of candidates) { + try { + return await refreshHomeAssistantAccessToken( + candidate, + linkedProfile.clientId, + linkedProfile.refreshToken, + ) + } catch (error) { + lastError = error + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Failed to refresh the linked Home Assistant session.') +} + +export async function resolveHomeAssistantServerConfig() { + const linkedProfile = await readLinkedHomeAssistantProfile() + if (!linkedProfile) { + return readHomeAssistantServerConfig() + } + + let accessToken = linkedProfile.accessToken + let accessTokenExpiresAt = Date.parse(linkedProfile.accessTokenExpiresAt) + + if (!Number.isFinite(accessTokenExpiresAt) || accessTokenExpiresAt <= Date.now() + 60_000) { + try { + const refreshedTokens = await refreshLinkedProfileAccessToken(linkedProfile) + accessToken = refreshedTokens.access_token + accessTokenExpiresAt = Date.parse(getAccessTokenExpiresAt(refreshedTokens.expires_in)) + + await writeLinkedHomeAssistantProfile({ + ...linkedProfile, + accessToken, + accessTokenExpiresAt: new Date(accessTokenExpiresAt).toISOString(), + }) + } catch (error) { + await clearLinkedHomeAssistantProfile() + throw error instanceof Error + ? error + : new Error('Failed to refresh the linked Home Assistant session.') + } + } + + const fallbackConfig = readHomeAssistantServerConfig() + const baseUrlCandidates = getLinkedProfileBaseUrlCandidates(linkedProfile) + const preferredBaseUrl = baseUrlCandidates[0] ?? linkedProfile.instanceUrl + + return { + accessToken, + baseUrl: preferredBaseUrl, + baseUrlCandidates, + castEntityId: fallbackConfig.castEntityId, + clientId: linkedProfile.clientId, + externalUrl: linkedProfile.externalUrl, + instanceUrl: linkedProfile.instanceUrl, + mode: 'linked-session', + testDurationSeconds: fallbackConfig.testDurationSeconds, + testMediaType: fallbackConfig.testMediaType, + testMediaUrl: fallbackConfig.testMediaUrl, + wakeDelayMs: fallbackConfig.wakeDelayMs, + } satisfies HomeAssistantServerConfig +} + +function getRequestBaseUrlCandidates(config: HomeAssistantServerConfig) { + return Array.from( + new Set([config.baseUrl, ...config.baseUrlCandidates].filter(Boolean)), + ) +} + +function shouldRetryHomeAssistantRequest(error: unknown) { + if (error instanceof DOMException && error.name === 'AbortError') { + return true + } + + if (error instanceof TypeError) { + return true + } + + if (error instanceof Error && /fetch failed/i.test(error.message)) { + return true + } + + return false +} + +export async function haRequest( + config: HomeAssistantServerConfig, + path: string, + init?: RequestInit, +): Promise { + const candidates = getRequestBaseUrlCandidates(config) + let lastError: unknown = null + + for (const baseUrl of candidates) { + try { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${config.accessToken}`, + ...(init?.headers ?? {}), + }, + cache: 'no-store', + signal: init?.signal ?? AbortSignal.timeout(8000), + }) + + if (!response.ok) { + const body = await response.text() + const error = new Error(`HA ${response.status} ${response.statusText}: ${body || 'request failed'}`) + + if ([502, 503, 504].includes(response.status) && baseUrl !== candidates.at(-1)) { + lastError = error + continue + } + + throw error + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } catch (error) { + lastError = error + if (!shouldRetryHomeAssistantRequest(error) || baseUrl === candidates.at(-1)) { + throw error + } + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Home Assistant request failed.') +} + +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function runPythonReleaseScript(host: string | null, name: string | null) { + const normalizedHost = host && host.trim().length > 0 ? host.trim() : '__none__' + const normalizedName = name && name.trim().length > 0 ? name.trim() : '__none__' + const candidates = [ + { command: 'python', args: ['-c', CHROMECAST_RELEASE_SCRIPT, normalizedHost, normalizedName] }, + { command: 'py', args: ['-3', '-c', CHROMECAST_RELEASE_SCRIPT, normalizedHost, normalizedName] }, + ] as const + + let lastError: unknown = null + for (const candidate of candidates) { + try { + const result = await execFileAsync(candidate.command, candidate.args, { + timeout: 30000, + }) + return result.stdout + } catch (error) { + lastError = error + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Unable to run the Chromecast cleanup helper.') +} + +export async function releaseChromecastReceiver( + host: string | null, + name: string | null, +): Promise { + const stdout = await runPythonReleaseScript(host, name) + const payload = JSON.parse(stdout.trim()) as { + after?: { app_display_name?: unknown } + before?: { app_display_name?: unknown } + } + + const beforeAppDisplayName = + typeof payload.before?.app_display_name === 'string' ? payload.before.app_display_name : null + const afterAppDisplayName = + typeof payload.after?.app_display_name === 'string' ? payload.after.app_display_name : null + + return { + afterAppDisplayName, + beforeAppDisplayName, + released: + afterAppDisplayName === 'Backdrop' || + afterAppDisplayName === null, + } +} + +export function getEntityState(config: HomeAssistantServerConfig, entityId: string) { + return haRequest(config, `/api/states/${entityId}`) +} + +export function listEntityStates(config: HomeAssistantServerConfig) { + return haRequest(config, '/api/states') +} + +export function listServices(config: HomeAssistantServerConfig) { + return haRequest(config, '/api/services') +} + +export function callService( + config: HomeAssistantServerConfig, + domain: string, + service: string, + data: Record, +) { + return haRequest(config, `/api/services/${domain}/${service}`, { + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) +} + +function getResourceActionForRequest( + binding: CollectionHomeAssistantBinding, + resource: CollectionHomeAssistantBinding['resources'][number], + request: CollectionHomeAssistantActionRequest, +) { + const actions = resource.actions ?? [] + const defaultAction = + (resource.defaultActionKey + ? actions.find((action) => action.key === resource.defaultActionKey) + : null) ?? + actions[0] ?? + null + + if (request.kind === 'trigger') { + return defaultAction + } + + if (request.kind === 'toggle') { + const desiredServices = request.value ? ['turn_on', 'open_cover'] : ['turn_off', 'close_cover'] + return ( + actions.find((action) => desiredServices.includes(action.service)) ?? + actions.find((action) => action.service === 'toggle') ?? + defaultAction + ) + } + + const serviceCandidatesByCapability: Record< + Extract['capability'], + string[] + > = { + brightness: ['turn_on', 'set_percentage'], + speed: ['set_percentage', 'set_fan_speed'], + temperature: ['set_temperature'], + volume: ['volume_set'], + } + + return ( + actions.find((action) => + serviceCandidatesByCapability[request.capability].includes(action.service), + ) ?? defaultAction + ) +} + +function normalizeRangeValueForField(fieldKey: string, value: number) { + if (fieldKey === 'brightness_pct' || fieldKey === 'percentage') { + if (value <= 1) { + return Math.max(0, Math.min(100, Math.round(value * 100))) + } + return Math.max(0, Math.min(100, Math.round(value))) + } + + if (fieldKey === 'brightness') { + if (value <= 1) { + return Math.max(0, Math.min(255, Math.round(value * 255))) + } + if (value <= 100) { + return Math.max(0, Math.min(255, Math.round((value / 100) * 255))) + } + return Math.max(0, Math.min(255, Math.round(value))) + } + + if (fieldKey === 'volume_level') { + if (value <= 1) { + return Math.max(0, Math.min(1, value)) + } + return Math.max(0, Math.min(1, value / 100)) + } + + return value +} + +function buildCollectionServiceData( + action: NonNullable>, + request: CollectionHomeAssistantActionRequest, +) { + if (request.kind !== 'range') { + return {} + } + + const fieldKeys = (action.fields ?? []).map((field) => field.key) + const preferredFieldKeyByCapability: Record< + Extract['capability'], + string[] + > = { + brightness: ['brightness_pct', 'brightness'], + speed: ['percentage', 'fan_speed'], + temperature: ['temperature'], + volume: ['volume_level'], + } + + const targetFieldKey = + preferredFieldKeyByCapability[request.capability].find((fieldKey) => fieldKeys.includes(fieldKey)) ?? + fieldKeys[0] + + return targetFieldKey + ? { + [targetFieldKey]: normalizeRangeValueForField(targetFieldKey, request.value), + } + : {} +} + +export async function readCastEntityFriendlyName(config: HomeAssistantServerConfig) { + if (!hasHomeAssistantServerConfig(config) || !config.castEntityId) { + return null + } + + try { + const state = await getEntityState(config, config.castEntityId) + const friendlyName = state.attributes?.friendly_name + return typeof friendlyName === 'string' && friendlyName.trim().length > 0 + ? friendlyName.trim() + : null + } catch { + return null + } +} + +export async function validateHomeAssistantConnection( + config: HomeAssistantServerConfig, +): Promise { + if (!hasHomeAssistantServerConfig(config)) { + return { + baseUrl: null, + castEntityId: null, + castFriendlyName: null, + clientId: null, + entityCount: 0, + externalUrl: null, + instanceUrl: null, + linked: false, + message: 'Home Assistant is not linked yet.', + mode: 'unlinked', + success: false, + } + } + + const states = await listEntityStates(config) + let castFriendlyName: string | null = null + if (config.castEntityId) { + try { + const castState = await getEntityState(config, config.castEntityId) + const friendlyName = castState.attributes?.friendly_name + castFriendlyName = + typeof friendlyName === 'string' && friendlyName.trim().length > 0 ? friendlyName.trim() : null + } catch { + castFriendlyName = null + } + } + + const isLinkedSession = config.mode === 'linked-session' + + return { + baseUrl: config.baseUrl, + castEntityId: config.castEntityId, + castFriendlyName, + clientId: config.clientId ?? null, + entityCount: states.length, + externalUrl: config.externalUrl ?? null, + instanceUrl: config.instanceUrl ?? config.baseUrl ?? null, + linked: true, + message: isLinkedSession + ? `Connected to Home Assistant at ${config.baseUrl}.` + : `Connected to local Home Assistant at ${config.baseUrl}.`, + mode: isLinkedSession ? 'linked-session' : 'local-env', + success: true, + } +} + +export async function runHomeAssistantDeviceAction( + config: HomeAssistantServerConfig, + itemName: string, + link: HomeAssistantLink, +): Promise { + if (!hasHomeAssistantServerConfig(config)) { + throw new Error('Home Assistant is not linked yet.') + } + + const entityId = link.haEntityId ?? config.castEntityId + if (!entityId) { + throw new Error(`No Home Assistant entity is mapped for ${link.deviceName}.`) + } + + if ( + link.actionKind === 'connect' && + link.serviceDomain === 'media_player' && + link.serviceName === 'play_media' && + Object.keys(link.serviceData).length === 0 + ) { + const initial = await getEntityState(config, entityId) + if (initial.state === 'unavailable') { + throw new Error(`Entity ${entityId} is unavailable in Home Assistant.`) + } + + await callService(config, 'media_player', 'play_media', { + entity_id: entityId, + media_content_id: config.testMediaUrl, + media_content_type: config.testMediaType, + }) + + const timeline: HomeAssistantDeviceActionResponse['timeline'] = [] + for (let second = 1; second <= config.testDurationSeconds; second += 1) { + await delay(1000) + const sample = await getEntityState(config, entityId) + timeline.push({ + appName: typeof sample.attributes?.app_name === 'string' ? sample.attributes.app_name : null, + mediaTitle: + typeof sample.attributes?.media_title === 'string' ? sample.attributes.media_title : null, + second, + state: sample.state, + }) + } + + await callService(config, 'media_player', 'media_stop', { + entity_id: entityId, + }) + + const releaseResult = await releaseChromecastReceiver(link.ip, link.deviceName) + await delay(1500) + const finalState = await getEntityState(config, entityId) + const observedAppNames = Array.from( + new Set( + timeline + .map((entry) => entry.appName?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ) + const availableAfterAction = releaseResult.released || finalState.state === 'off' + const friendlyName = initial.attributes?.friendly_name + + return { + actionKind: link.actionKind, + availableAfterAction, + deviceName: link.deviceName, + finalState: finalState.state, + initialFriendlyName: + typeof friendlyName === 'string' && friendlyName.trim().length > 0 ? friendlyName : null, + initialState: initial.state, + itemName, + message: availableAfterAction + ? `${itemName} responded through Home Assistant and the Chromecast was returned to its normal idle state.` + : `${itemName} responded through Home Assistant, but the Chromecast did not return to its normal idle state.`, + observedAppNames, + success: observedAppNames.length > 0 && availableAfterAction, + timeline, + } + } + + if (link.actionKind === 'power' && link.serviceDomain === 'homeassistant' && link.serviceName === 'toggle') { + const initial = await getEntityState(config, entityId) + const initiallyOn = initial.state !== 'off' + const service = initiallyOn ? 'turn_off' : 'turn_on' + + await callService(config, 'homeassistant', service, { + entity_id: entityId, + }) + await delay(1500) + + const finalState = await getEntityState(config, entityId) + const friendlyName = initial.attributes?.friendly_name + + return { + actionKind: 'power', + availableAfterAction: true, + deviceName: link.deviceName, + finalState: finalState.state, + initialFriendlyName: + typeof friendlyName === 'string' && friendlyName.trim().length > 0 ? friendlyName : null, + initialState: initial.state, + itemName, + message: `${itemName} toggled ${link.deviceName} through Home Assistant.`, + observedAppNames: [], + success: initial.state !== finalState.state, + timeline: [], + } + } + + const initial = await getEntityState(config, entityId) + if (initial.state === 'unavailable') { + throw new Error(`Entity ${entityId} is unavailable in Home Assistant.`) + } + + await callService(config, link.serviceDomain, link.serviceName, { + entity_id: entityId, + ...link.serviceData, + }) + await delay(1500) + const finalState = await getEntityState(config, entityId) + const friendlyName = initial.attributes?.friendly_name + + return { + actionKind: link.actionKind, + availableAfterAction: true, + deviceName: link.deviceName, + finalState: finalState.state, + initialFriendlyName: + typeof friendlyName === 'string' && friendlyName.trim().length > 0 ? friendlyName : null, + initialState: initial.state, + itemName, + message: `${itemName} ran ${link.actionLabel} on ${link.deviceName} through Home Assistant.`, + observedAppNames: [], + success: finalState.state !== 'unavailable', + timeline: [], + } +} + +export async function runHomeAssistantCollectionAction( + config: HomeAssistantServerConfig, + collectionName: string, + binding: CollectionHomeAssistantBinding, + request: CollectionHomeAssistantActionRequest, +): Promise { + if (!hasHomeAssistantServerConfig(config)) { + throw new Error('Home Assistant is not linked yet.') + } + + const resources = binding.resources ?? [] + if (resources.length === 0) { + throw new Error(`No Home Assistant resources are linked to ${collectionName}.`) + } + + const shouldUsePrimaryOnly = + binding.aggregation === 'primary' || binding.aggregation === 'single' + const primaryResourceId = + binding.primaryResourceId ?? resources[0]?.id ?? null + const targetResources = shouldUsePrimaryOnly + ? resources.filter((resource) => resource.id === primaryResourceId).slice(0, 1) + : resources + + const results: HomeAssistantCollectionActionResponse['results'] = [] + + for (const resource of targetResources) { + const action = getResourceActionForRequest(binding, resource, request) + if (!action) { + continue + } + + const entityId = resource.entityId ?? null + const serviceData = buildCollectionServiceData(action, request) + const payload = + entityId && action.domain !== 'scene' + ? { entity_id: entityId, ...serviceData } + : entityId + ? { entity_id: entityId, ...serviceData } + : { ...serviceData } + + await callService(config, action.domain, action.service, payload) + + let finalState: string | null = null + if (entityId) { + try { + const state = await getEntityState(config, entityId) + finalState = state.state + } catch { + finalState = null + } + } + + results.push({ + entityId, + finalState, + resourceId: resource.id, + }) + } + + return { + collectionName, + message: `Ran Home Assistant action for ${collectionName}.`, + results, + success: results.length > 0, + } +} diff --git a/apps/editor/app/api/home-assistant/connect/route.ts b/apps/editor/app/api/home-assistant/connect/route.ts new file mode 100644 index 000000000..5445c2741 --- /dev/null +++ b/apps/editor/app/api/home-assistant/connect/route.ts @@ -0,0 +1,32 @@ +import { + resolveHomeAssistantServerConfig, + validateHomeAssistantConnection, +} from '../../../_lib/home-assistant-server' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const result = await validateHomeAssistantConnection(await resolveHomeAssistantServerConfig()) + return Response.json(result, { status: result.success ? 200 : 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Home Assistant.' + return Response.json( + { + baseUrl: null, + castEntityId: null, + castFriendlyName: null, + clientId: null, + entityCount: 0, + error: message, + externalUrl: null, + instanceUrl: null, + linked: false, + message, + mode: 'unlinked', + success: false, + }, + { status: 500 }, + ) + } +} diff --git a/apps/editor/app/api/home-assistant/connection-status/route.ts b/apps/editor/app/api/home-assistant/connection-status/route.ts new file mode 100644 index 000000000..5445c2741 --- /dev/null +++ b/apps/editor/app/api/home-assistant/connection-status/route.ts @@ -0,0 +1,32 @@ +import { + resolveHomeAssistantServerConfig, + validateHomeAssistantConnection, +} from '../../../_lib/home-assistant-server' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const result = await validateHomeAssistantConnection(await resolveHomeAssistantServerConfig()) + return Response.json(result, { status: result.success ? 200 : 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Home Assistant.' + return Response.json( + { + baseUrl: null, + castEntityId: null, + castFriendlyName: null, + clientId: null, + entityCount: 0, + error: message, + externalUrl: null, + instanceUrl: null, + linked: false, + message, + mode: 'unlinked', + success: false, + }, + { status: 500 }, + ) + } +} diff --git a/apps/editor/app/api/home-assistant/device-action/route.ts b/apps/editor/app/api/home-assistant/device-action/route.ts new file mode 100644 index 000000000..077190e6e --- /dev/null +++ b/apps/editor/app/api/home-assistant/device-action/route.ts @@ -0,0 +1,70 @@ +import type { + CollectionHomeAssistantActionRequest, + CollectionHomeAssistantBinding, +} from '@pascal-app/core/schema' +import { getHomeAssistantLink } from '../../../../../../packages/editor/src/lib/home-assistant' +import { + resolveHomeAssistantServerConfig, + runHomeAssistantCollectionAction, + runHomeAssistantDeviceAction, +} from '../../../_lib/home-assistant-server' + +export const runtime = 'nodejs' + +type DeviceActionRequestBody = { + binding?: CollectionHomeAssistantBinding + collectionName?: string + itemName?: string + link?: unknown + request?: CollectionHomeAssistantActionRequest +} + +export async function POST(request: Request) { + try { + const body = (await request.json()) as DeviceActionRequestBody + if ( + body.binding && + typeof body.binding === 'object' && + body.request && + typeof body.request === 'object' + ) { + const collectionName = + typeof body.collectionName === 'string' && body.collectionName.trim().length > 0 + ? body.collectionName.trim() + : 'Linked collection' + + const result = await runHomeAssistantCollectionAction( + await resolveHomeAssistantServerConfig(), + collectionName, + body.binding, + body.request, + ) + return Response.json(result) + } + + const itemName = + typeof body.itemName === 'string' && body.itemName.trim().length > 0 + ? body.itemName.trim() + : 'Linked item' + const link = getHomeAssistantLink({ + homeAssistantLink: body.link, + }) + + if (!link) { + return Response.json( + { error: 'Missing or invalid Home Assistant link payload.' }, + { status: 400 }, + ) + } + + const result = await runHomeAssistantDeviceAction( + await resolveHomeAssistantServerConfig(), + itemName, + link, + ) + return Response.json(result) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Home Assistant action error.' + return Response.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/home-assistant/discover-devices/route.ts b/apps/editor/app/api/home-assistant/discover-devices/route.ts new file mode 100644 index 000000000..826dd4f4b --- /dev/null +++ b/apps/editor/app/api/home-assistant/discover-devices/route.ts @@ -0,0 +1,37 @@ +import { discoverHomeAssistantDevices } from '../../../_lib/home-assistant-discovery' +import { + hasHomeAssistantServerConfig, + resolveHomeAssistantServerConfig, +} from '../../../_lib/home-assistant-server' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const config = await resolveHomeAssistantServerConfig() + if (!hasHomeAssistantServerConfig(config)) { + return Response.json( + { + devices: [], + error: 'Home Assistant is not linked yet.', + }, + { status: 412 }, + ) + } + + const devices = await discoverHomeAssistantDevices(config) + return Response.json({ + devices, + scannedAt: new Date().toISOString(), + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Home Assistant discovery error.' + return Response.json( + { + devices: [], + error: message, + }, + { status: 500 }, + ) + } +} diff --git a/apps/editor/app/api/home-assistant/import-resources/route.ts b/apps/editor/app/api/home-assistant/import-resources/route.ts new file mode 100644 index 000000000..216673240 --- /dev/null +++ b/apps/editor/app/api/home-assistant/import-resources/route.ts @@ -0,0 +1,37 @@ +import { listImportableHomeAssistantResources } from '../../../_lib/home-assistant-imports' +import { + hasHomeAssistantServerConfig, + resolveHomeAssistantServerConfig, +} from '../../../_lib/home-assistant-server' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const config = await resolveHomeAssistantServerConfig() + if (!hasHomeAssistantServerConfig(config)) { + return Response.json( + { + error: 'Home Assistant is not linked yet.', + resources: [], + }, + { status: 412 }, + ) + } + + const resources = await listImportableHomeAssistantResources(config) + return Response.json({ + importedAt: new Date().toISOString(), + resources, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Home Assistant import error.' + return Response.json( + { + error: message, + resources: [], + }, + { status: 500 }, + ) + } +} diff --git a/apps/editor/app/api/home-assistant/oauth/callback/route.ts b/apps/editor/app/api/home-assistant/oauth/callback/route.ts new file mode 100644 index 000000000..1c75d35e2 --- /dev/null +++ b/apps/editor/app/api/home-assistant/oauth/callback/route.ts @@ -0,0 +1,80 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + HOME_ASSISTANT_OAUTH_COOKIE, + exchangeAuthorizationCode, +} from '../../../../_lib/home-assistant-auth' +import { writeLinkedHomeAssistantProfile } from '../../../../_lib/home-assistant-linked-profile' + +export const runtime = 'nodejs' + +function buildRedirectUrl(base: string, status: 'success' | 'error', message?: string) { + const redirectUrl = new URL('/', base) + redirectUrl.searchParams.set('ha_link', status) + if (message) { + redirectUrl.searchParams.set('ha_message', message) + } + return redirectUrl +} + +export async function GET(request: NextRequest) { + const oauthCookie = request.cookies.get(HOME_ASSISTANT_OAUTH_COOKIE)?.value + const fallbackBase = request.nextUrl.origin + + if (!oauthCookie) { + return NextResponse.redirect( + buildRedirectUrl(fallbackBase, 'error', 'Missing Home Assistant OAuth state.'), + ) + } + + try { + const oauthState = JSON.parse(oauthCookie) as { + clientId?: string + externalUrl?: string | null + instanceUrl?: string + state?: string + } + const code = request.nextUrl.searchParams.get('code') + const state = request.nextUrl.searchParams.get('state') + + if (!(oauthState.clientId && oauthState.instanceUrl && oauthState.state && code && state)) { + throw new Error('Missing OAuth callback parameters.') + } + + if (state !== oauthState.state) { + throw new Error('Home Assistant OAuth state did not match.') + } + + const tokens = await exchangeAuthorizationCode( + oauthState.instanceUrl, + oauthState.clientId, + code, + oauthState.externalUrl, + ) + + await writeLinkedHomeAssistantProfile({ + accessToken: tokens.access_token, + accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), + clientId: oauthState.clientId, + externalUrl: + typeof oauthState.externalUrl === 'string' && oauthState.externalUrl.trim().length > 0 + ? oauthState.externalUrl + : null, + instanceUrl: oauthState.instanceUrl, + linkedAt: new Date().toISOString(), + refreshToken: tokens.refresh_token ?? '', + }) + + const response = NextResponse.redirect(buildRedirectUrl(oauthState.clientId, 'success')) + response.cookies.delete(HOME_ASSISTANT_OAUTH_COOKIE) + return response + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to complete Home Assistant sign-in.' + const parsedCookie = JSON.parse(oauthCookie) as { clientId?: string } + const response = NextResponse.redirect( + buildRedirectUrl(parsedCookie.clientId ?? fallbackBase, 'error', message), + ) + response.cookies.delete(HOME_ASSISTANT_OAUTH_COOKIE) + return response + } +} diff --git a/apps/editor/app/api/home-assistant/oauth/start/route.ts b/apps/editor/app/api/home-assistant/oauth/start/route.ts new file mode 100644 index 000000000..0de548b5e --- /dev/null +++ b/apps/editor/app/api/home-assistant/oauth/start/route.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + HOME_ASSISTANT_OAUTH_COOKIE, + buildHomeAssistantAuthorizeUrl, + buildHomeAssistantOauthState, + normalizeOptionalHomeAssistantUrl, +} from '../../../../_lib/home-assistant-auth' + +export const runtime = 'nodejs' + +type StartOauthRequestBody = { + externalUrl?: string + instanceUrl?: string +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as StartOauthRequestBody + const instanceUrl = normalizeOptionalHomeAssistantUrl(body.instanceUrl) + const externalUrl = normalizeOptionalHomeAssistantUrl(body.externalUrl) + const resolvedInstanceUrl = instanceUrl ?? externalUrl + + if (!resolvedInstanceUrl) { + return Response.json( + { error: 'A Home Assistant local or remote URL is required.' }, + { status: 400 }, + ) + } + + const oauthState = buildHomeAssistantOauthState( + request, + resolvedInstanceUrl, + externalUrl, + ) + + const response = NextResponse.json({ + authorizeUrl: buildHomeAssistantAuthorizeUrl(oauthState), + }) + + response.cookies.set(HOME_ASSISTANT_OAUTH_COOKIE, JSON.stringify(oauthState), { + httpOnly: true, + maxAge: 10 * 60, + path: '/', + sameSite: 'lax', + secure: request.nextUrl.protocol === 'https:', + }) + + return response + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start Home Assistant sign-in.' + return Response.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/home-assistant/unlink/route.ts b/apps/editor/app/api/home-assistant/unlink/route.ts new file mode 100644 index 000000000..de05a6182 --- /dev/null +++ b/apps/editor/app/api/home-assistant/unlink/route.ts @@ -0,0 +1,13 @@ +import { clearLinkedHomeAssistantProfile } from '../../../_lib/home-assistant-linked-profile' + +export const runtime = 'nodejs' + +export async function DELETE() { + try { + await clearLinkedHomeAssistantProfile() + return Response.json({ success: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to unlink Home Assistant.' + return Response.json({ error: message, success: false }, { status: 500 }) + } +} diff --git a/bun.lock b/bun.lock index faa9b5693..6e04a7d40 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "editor", @@ -176,6 +175,7 @@ "@pascal/typescript-config": "*", "@types/node": "^25.5.0", "@types/react": "^19.2.2", + "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "5.9.3", }, @@ -184,6 +184,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", + "react-dom": "^18 || ^19", "three": "^0.184", }, }, diff --git a/docs/ha-rts-architecture-migration-plan.md b/docs/ha-rts-architecture-migration-plan.md new file mode 100644 index 000000000..6e87df81c --- /dev/null +++ b/docs/ha-rts-architecture-migration-plan.md @@ -0,0 +1,905 @@ +# HA + RTS Production Integration Migration Plan + +## Purpose + +This document is the implementation plan for the production Home Assistant + RTS integration in Pascal. + +It is written to be: + +- a clear development history layout +- file-by-file +- dependency-aware +- explicit enough that, if implemented one-to-one, the result should be PR-ready for `main` + +This plan covers only Home Assistant and RTS control work. It does **not** cover robot/navigation features. + +This plan does **not** authorize pushing a branch or submitting a pull request. + +Its purpose is narrower: + +- define the work required to become PR-ready +- define the evidence required to reach that state +- forbid push/PR submission before those conditions are met and separately approved + +## Product Goal + +The shipped product should let a person: + +1. connect Pascal to the Home Assistant instance they already use at home +2. import the smart-home things they already have in Home Assistant +3. link those imported things to virtual Pascal items or item groups +4. see Pascal RTS-style controls appear automatically in the virtual house +5. click those controls to affect the real home through Home Assistant + +## Core Model + +There are four separate responsibilities: + +- **Home Assistant** + - owns the real devices, their current state, and real-world actions +- **Pascal scene graph** + - owns the virtual house, rooms, and virtual items +- **Collections** + - define what one Pascal control means + - link imported HA things to Pascal items +- **RTS UI** + - renders controls and handles user interaction + - does not define durable control meaning + +Short version: + +- `ItemNode` = visual object in the virtual house +- `Collection` = durable Pascal control object +- Home Assistant = real smart-home truth +- RTS = runtime presentation of those controls + +## What Counts As PR-Ready + +This migration is only PR-ready when all of the following are true: + +- Pascal can connect to a real Home Assistant instance +- Pascal can import the right first-class HA things: + - devices/entities + - scripts + - scenes + - selected triggerable automations + - meaningful HA groupings/helpers if supported +- The imported HA list is refreshable app data, not copied wholesale into saved scene collections +- Collections are the only durable control model +- Users can link imported HA things to Pascal items or item groups through the editor UI +- RTS buttons are rendered from collections, not viewer-local grouping state +- RTS button position is computed from linked Pascal geometry +- Empty rooms do not show control panels +- Pascal uses exactly one collection-based command path to Home Assistant +- Home Assistant state flows back into Pascal and updates visible control state +- Reloading the scene preserves bindings correctly +- The old item-direct prototype path is removed or fully unreachable +- The implementation is proven against a real HA instance and a real Pascal scene + +If any of those are missing, the work is **not** ready for `main`. + +PR-ready in this document means: + +- the implementation has reached the internal quality bar for review +- the branch is eligible for a future push and PR decision + +It does **not** mean: + +- push now +- open a PR now +- merge now + +## Locked Decisions For This PR + +To keep the implementation deterministic, these decisions are fixed for this merge: + +- `Collection` remains the only durable Pascal control object +- the HA import list stays refreshable app-level data +- full imported HA payloads are not embedded into scene collections +- RTS placement is computed from Pascal geometry in this PR +- the runtime action route remains at the existing `device-action` path for this PR, but the payload and behavior become collection-only +- `packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx` is removed or converted into a thin wrapper with no separate behavior so the shipped UX has one HA authoring flow +- `apps/editor/app/api/home-assistant/discover-devices/route.ts` is removed from the final merged state and `import-resources` becomes the only public import surface +- `packages/editor/src/components/ui/panels/lazy-navigation-panel.tsx` is part of this migration because the final HA panel must stay reachable in the shipped editor shell +- browser-local RTS grouping is removed as durable behavior in this PR +- demo validation is performed first against fake/demo/template/helper-backed HA resources inside a real HA instance +- physical-device smoke checks are optional extra proof, not the main development loop for this PR + +## Scope Priorities + +### Must be first-class in this migration + +- devices/entities: + - lights + - fans + - media players / TVs + - switches + - covers + - climate devices +- callable HA actions: + - scripts + - scenes + - selected automations + - useful existing HA groupings/helpers + +### Secondary, not blocking `main` + +- areas +- floors +- labels +- manual RTS placement overrides +- deep custom service-map authoring +- dedicated standalone HA integration package extraction + +Those can come later. They should not block the first production merge. + +## Durable vs Refreshable vs Runtime Data + +### Durable scene data + +Saved with the Pascal scene: + +- collections +- collection membership (`nodeIds`) +- `controlNodeId` +- collection kind/capabilities +- collection presentation metadata +- stable references to imported HA resources + +### Refreshable app data + +Fetched from Home Assistant and refreshed over time: + +- imported HA resource list +- current labels/domains/capabilities from HA +- live entity/action state cache +- auth/session state +- discovery state + +### Runtime-only UI state + +Ephemeral only: + +- which RTS panel is open +- hover state +- drag state +- temporary edit mode state +- overlay suppression flags + +## Canonical Data Direction + +### Collection + +Collections remain the main Pascal control object. + +Suggested direction: + +```ts +type ControlCollection = Collection & { + kind: 'device' | 'group' | 'automation' + controlNodeId?: AnyNodeId + zoneIds?: ZoneNode['id'][] + capabilities: Array<'power' | 'brightness' | 'speed' | 'temperature' | 'media' | 'volume' | 'trigger'> + presentation?: { + label?: string + icon?: string + rtsOrder?: number + } + homeAssistant?: { + importIds: string[] + primaryImportId?: string + resourceKind: 'entity' | 'scene' | 'script' | 'automation' | 'group' + aggregation: 'single' | 'group' | 'any_on' | 'all_on' | 'trigger_only' + serviceMap?: Record + } +} +``` + +### Imported Home Assistant resource + +This is app-side integration data, not full scene data. + +```ts +type ImportedHaResource = { + id: string + kind: 'entity' | 'scene' | 'script' | 'automation' | 'group' + haId: string + domain?: string + label: string + capabilities: string[] + defaultAction?: { domain: string; service: string } +} +``` + +Key rule: + +- collections store references to imported HA things +- collections do **not** store the full imported HA payload + +## Development History Layout + +The safest path to a mergeable implementation is the following ordered history. + +Each phase below is intended to be one logical commit group or tightly related set of commits inside the final PR. + +--- + +## Phase 1: Lock The Durable Collection Contract + +### Why this phase comes first + +Nothing else is stable until the saved scene model is correct. + +The editor UI, RTS renderer, and backend routes all need one durable collection shape to target. + +### Files + +#### `packages/core/src/schema/collections.ts` + +Change: + +- finalize collection fields for: + - control kind + - capabilities + - presentation + - HA reference-based binding +- remove any dependence on embedding the full imported HA payload + +#### `packages/core/src/schema/index.ts` + +Change: + +- export the final collection and HA reference types needed by editor/app code + +#### `packages/core/package.json` + +Change: + +- keep the package export surface aligned with the final schema import path used by editor/app/server code +- preserve server-safe schema imports for route-layer code + +#### `packages/core/src/store/use-scene.ts` + +Change: + +- normalize collections consistently on create/update/load +- add stable selectors/helpers for: + - collections linked to item(s) + - collections visible in a room + - collection primary node resolution + +#### `packages/editor/src/lib/scene.ts` + +Change: + +- persist collections as part of the app scene graph +- preserve them on scene load/apply + +#### `packages/editor/src/hooks/use-auto-save.ts` + +Change: + +- autosave collections +- do not autosave runtime RTS state + +### Interdependencies + +- Phase 2 cannot be final until this phase is correct +- Phase 3 UI should bind to this contract, not invent another one +- Phase 4 RTS runtime must read this contract directly + +### Exit criteria + +- collections survive save/reload +- collection updates normalize cleanly +- no full imported HA payload is required to keep a saved scene valid + +### PR-readiness review after Phase 1 + +Still **not PR-ready**. + +Why: + +- no import flow yet +- no editor linking flow yet +- no runtime collection-driven RTS yet +- no live HA execution path proven yet + +So continue. + +--- + +## Phase 2: Build The Refreshable HA Import Layer + +### Why this phase comes second + +Once the durable binding target exists, Pascal needs a clean list of what Home Assistant currently has. + +This list must be refreshable and separate from the scene file. + +### Files + +#### `apps/editor/app/_lib/home-assistant-imports.ts` + +Change: + +- normalize the import list around first-class supported things: + - entities/devices + - scripts + - scenes + - selected automations + - useful HA groupings/helpers +- keep areas/floors/labels as optional secondary metadata only + +#### `apps/editor/app/_lib/home-assistant-discovery.ts` + +Change: + +- support import discovery cleanly +- remove item-centric assumptions + +#### `apps/editor/app/_lib/home-assistant-auth.ts` + +Keep: + +- auth/session only + +#### `apps/editor/app/_lib/home-assistant-linked-profile.ts` + +Keep: + +- linked profile/session data only +- no secrets in scene data + +#### `apps/editor/app/api/home-assistant/connect/route.ts` + +Change: + +- after connect, make the app able to fetch imports immediately + +#### `apps/editor/app/api/home-assistant/connection-status/route.ts` + +Keep: + +- normalized connection state + +#### `apps/editor/app/api/home-assistant/import-resources/route.ts` + +Change: + +- return the normalized import list +- clearly separate import-list data from durable scene data + +#### `apps/editor/app/api/home-assistant/discover-devices/route.ts` + +Change: + +- remove this route from the final merged state +- move all supported import callers to `import-resources` + +### Interdependencies + +- Phase 3 UI depends on this import list existing +- Phase 5 action execution should target resources described by this same import model + +### Exit criteria + +- connect succeeds +- import route returns a normalized list of supported HA things +- refresh works without mutating the saved scene model + +### PR-readiness review after Phase 2 + +Still **not PR-ready**. + +Why: + +- users still cannot cleanly link imports to Pascal objects +- RTS still does not necessarily render from collections +- execution path not unified yet + +So continue. + +--- + +## Phase 3: Rebuild The Editor Around Connect -> Import -> Link + +### Why this phase comes third + +Once imports and durable collections exist, the editor must let the user bind them together simply. + +This is the key first-time user workflow. + +### Files + +#### `packages/editor/src/lib/home-assistant-collections.ts` + +Change: + +- make this the canonical binding helper layer between: + - imported HA resources + - editor selection + - collection schema +- create/update collections using HA references only + +#### `packages/editor/src/lib/home-assistant-controls.ts` + +Change: + +- derive UI-facing controls from imported resources + collection capabilities +- support both: + - stateful entity-backed controls + - trigger-only controls + +#### `packages/editor/src/lib/home-assistant-connect.ts` + +Change: + +- reduce this to connect/import/link flow helpers +- remove item-name heuristics and old item-centric assumptions + +#### `packages/editor/src/components/ui/panels/home-assistant-panel.tsx` + +Change: + +- make this the main UX surface: + - `Connect Home Assistant` + - `Refresh imports` + - browse imported resources + - link selected resource to selected Pascal item(s) + - auto-create/update collection +- keep advanced controls behind a secondary layer + +#### `packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx` + +Change: + +- remove its separate authoring behavior +- move its responsibilities into `home-assistant-panel.tsx` +- remove the file from the final merged state +- the shipped HA binding flow lives in `home-assistant-panel.tsx` + +#### `packages/editor/src/components/ui/home-assistant-action-icon.tsx` + +Change: + +- drive icons from collection/import semantics, not item-link semantics + +#### `packages/editor/src/components/editor/floating-action-menu.tsx` + +Change: + +- open binding flow +- do not execute HA actions directly + +#### `packages/editor/src/components/editor/node-action-menu.tsx` + +Change: + +- keep generic node action role only +- any HA entry point should open binding UI, not own business logic + +#### `packages/editor/src/components/editor/floorplan-panel.tsx` + +Change: + +- support selecting Pascal item(s)/groups for linking +- do not become a second HA business-logic center + +#### `packages/editor/src/store/use-editor.tsx` + +Change: + +- keep only ephemeral editor UI state +- move from item-level HA UI state to collection-oriented UI state + +#### `packages/editor/src/components/ui/panels/panel-manager.tsx` + +Change: + +- make the HA/collection panel a first-class routed panel + +#### `packages/editor/src/components/ui/panels/lazy-navigation-panel.tsx` + +Change: + +- keep the final HA panel reachable in the real shipped shell +- remove any temporary HA wiring that no longer matches the final panel structure + +### Interdependencies + +- depends on Phases 1 and 2 +- Phase 4 runtime depends on the resulting collections being created cleanly + +### Exit criteria + +- a user can connect +- imports show up +- a selected Pascal item/group can be linked to an imported HA thing +- a collection is created/updated correctly + +### PR-readiness review after Phase 3 + +Still **not PR-ready**. + +Why: + +- RTS runtime may still be using viewer-local grouping +- old runtime/action path may still exist +- live state sync still not fully proven + +So continue. + +--- + +## Phase 4: Make RTS Runtime Fully Collection-Driven + +### Why this phase comes fourth + +The editor can now create correct collections. The viewer must render directly from those collections and nothing else. + +### Files + +#### `packages/viewer/src/systems/interactive/interactive-system.tsx` + +Change: + +- make collections the only durable source of control meaning +- derive control tiles from collections, not raw item-local prototypes +- compute button/panel positions from linked Pascal geometry +- support: + - entity-backed controls + - trigger-only controls +- remove browser-local grouping as canonical state +- hide rooms that have no controls + +#### `packages/viewer/src/store/use-viewer.ts` + +Change: + +- keep runtime-only RTS state only +- no durable grouping/control model here + +#### `packages/viewer/src/store/use-viewer.d.ts` + +Change: + +- follow the runtime-only split + +#### `packages/viewer/src/hooks/use-node-events.ts` + +Change: + +- ensure RTS interactions do not leak into scene selection + +#### `packages/viewer/src/components/viewer/selection-manager.tsx` + +Change: + +- highlight collection-linked Pascal items +- do not resolve HA business rules here + +#### `packages/editor/src/components/editor/selection-manager.tsx` + +Change: + +- coordinate selection suppression with RTS overlay use + +#### `packages/editor/src/components/systems/zone/zone-system.tsx` + +Change: + +- keep room labels from competing with RTS control labels + +#### `packages/editor/src/components/viewer-zone-system.tsx` + +Change: + +- same as above + +#### `packages/editor/src/components/editor/index.tsx` + +Change: + +- compose the final systems only +- no business rules here + +#### `packages/editor/src/index.tsx` + +Change: + +- keep package entry behavior aligned with the final HA panel/export surface +- do not expose prototype-only HA helpers as public package API + +#### `packages/viewer/src/index.ts` + +Change: + +- keep package entry behavior aligned with the final collection-driven RTS runtime +- do not expose prototype-only HA runtime helpers as public package API + +### Interdependencies + +- depends on collections being created correctly in Phase 3 +- depends on selection/panel state integration from editor-side files + +### Exit criteria + +- RTS overlays render from collections only +- no local browser storage is needed as the durable grouping model +- empty rooms do not show controls +- control placement follows linked Pascal geometry + +### PR-readiness review after Phase 4 + +Still **not PR-ready**. + +Why: + +- action execution path may still be split +- HA state sync and real-world proof may still be incomplete + +So continue. + +--- + +## Phase 5: Unify Runtime Execution And HA State Sync + +### Why this phase comes fifth + +Now that viewer controls are collection-driven, every runtime action must go through one collection-based backend path. + +### Files + +#### `apps/editor/app/_lib/home-assistant-server.ts` + +Change: + +- keep only collection-based action execution +- translate collection actions into HA service calls +- support both: + - stateful entity updates + - trigger-only actions +- remove the old item-direct execution path + +#### `apps/editor/app/api/home-assistant/device-action/route.ts` + +Change: + +- make it collection-action only +- remove old item-based payload handling +- keep the route path for this PR to reduce churn, but the semantics must be collection-only + +#### `packages/editor/src/lib/home-assistant-controls.ts` + +Change: + +- align control generation with the final server action contract + +#### `packages/viewer/src/systems/interactive/interactive-system.tsx` + +Change: + +- ensure tile interactions post only collection-action requests +- remove any remaining direct HA business logic from the viewer + +### Interdependencies + +- depends on Phase 4 runtime rendering using collections +- depends on Phase 2 import model and Phase 3 bindings + +### Exit criteria + +- every Pascal RTS action resolves through one collection-based backend path +- old item-direct execution cannot be triggered anymore + +### PR-readiness review after Phase 5 + +Still **not PR-ready**. + +Why: + +- the code may now be architecturally correct, but it is not yet proven against real HA + real scene reload behavior +- cleanup and final verification are still required + +So continue. + +--- + +## Phase 6: Cleanup, Docs Alignment, And Real Proof + +### Why this phase is last + +This is the merge gate. The architecture may be correct before this phase, but it is not safe for `main` until the prototype leftovers are removed and the live flow is proven. + +### Files + +#### `docs/home-assistant-integration.md` + +Change: + +- align background integration notes with the final implemented architecture + +#### `apps/editor/app/_components/home-assistant-connection-test.tsx` + +Change: + +- keep it as a dev-only diagnostic surface only +- remove it from the shipped user flow and from any production dependency chain + +#### Any remaining touched HA/RTS files + +Change: + +- remove dead prototype branches, unused helpers, and obsolete local-storage assumptions +- remove unreachable item-link compatibility code +- ensure no auth/session data leaks into durable scene data +- remove duplicate HA authoring entry points if they still exist + +### Required proof before merge + +The final PR must prove all of the following: + +1. Connect Pascal to a real Home Assistant instance +2. Import at least: + - one fake/demo/template-backed HA device/entity inside that real HA instance + - one fake/demo/template-backed script/scene/automation-style action inside that real HA instance +3. Link one imported HA thing to one Pascal item +4. Link one imported HA thing to multiple Pascal items +5. See the RTS control appear in the correct computed position +6. Trigger the control and affect the fake/demo/template-backed Home Assistant thing through the real HA instance +7. Refresh/reload Pascal and confirm the binding persists +8. Confirm empty rooms do not show controls +9. Confirm unlink/reconnect/import refresh still behave correctly +10. Confirm TypeScript/build checks are clean for touched packages + +Optional extra proof if a real physical device is available: + +- repeat one smoke test against a real HA-backed physical device such as a Chromecast + +### Recommended proof set + +- package typechecks +- touched package builds +- app route health checks +- real browser proof on the live app +- real Home Assistant command/state proof using the fake/demo/template-backed HA fixture +- optional extra physical-device smoke proof if one is available + +### PR-readiness review after Phase 6 + +If all previous phases were implemented exactly as written and all proof above passes, the migration is **PR-ready for `main`**. + +At that point the result should satisfy the merge bar because: + +- the data model is clean +- the UI flow is clean +- the RTS runtime is collection-driven +- the execution path is unified +- the persistence model is durable +- the implementation is proven live + +--- + +## File-by-File End State Summary + +This section is the short reference for the expected final state of each file. + +### Core + +- `packages/core/src/schema/collections.ts` + - final durable collection schema with HA references, not embedded HA payloads +- `packages/core/src/schema/index.ts` + - exports final types +- `packages/core/package.json` + - stable schema export path for app/server imports +- `packages/core/src/store/use-scene.ts` + - canonical normalized collection store + selectors + +### Editor persistence + helpers + +- `packages/editor/src/lib/scene.ts` + - persists collections +- `packages/editor/src/hooks/use-auto-save.ts` + - autosaves collections only, not runtime RTS state +- `packages/editor/src/lib/home-assistant-collections.ts` + - builds collection bindings from imports + selection +- `packages/editor/src/lib/home-assistant-controls.ts` + - UI-facing control/action helpers aligned to collection model +- `packages/editor/src/lib/home-assistant-connect.ts` + - connect/import/link helpers only + +### Editor UI + +- `packages/editor/src/components/ui/panels/home-assistant-panel.tsx` + - main `Connect -> Import -> Link` UI +- `packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx` + - deleted or reduced to a thin wrapper with no separate HA behavior +- `packages/editor/src/components/ui/home-assistant-action-icon.tsx` + - presentation-only +- `packages/editor/src/components/editor/floating-action-menu.tsx` + - opens HA binding flow, does not execute HA directly +- `packages/editor/src/components/editor/node-action-menu.tsx` + - generic node actions only +- `packages/editor/src/components/editor/floorplan-panel.tsx` + - supports selection-based linking, not HA business logic ownership +- `packages/editor/src/store/use-editor.tsx` + - ephemeral collection-oriented UI state only +- `packages/editor/src/components/ui/panels/panel-manager.tsx` + - first-class HA panel routing +- `packages/editor/src/components/ui/panels/lazy-navigation-panel.tsx` + - final HA panel entry point wiring in the shipped shell +- `packages/editor/src/components/editor/selection-manager.tsx` + - selection safety only +- `packages/editor/src/components/systems/zone/zone-system.tsx` + - room label suppression only +- `packages/editor/src/components/viewer-zone-system.tsx` + - same +- `packages/editor/src/components/editor/index.tsx` + - composition only +- `packages/editor/src/index.tsx` + - stable package entrypoint with no prototype-only HA API leakage + +### Viewer + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + - collection-driven RTS rendering and interaction only +- `packages/viewer/src/index.ts` + - stable package entrypoint with no prototype-only HA runtime API leakage +- `packages/viewer/src/store/use-viewer.ts` + - runtime-only RTS state +- `packages/viewer/src/store/use-viewer.d.ts` + - matching runtime-only typing +- `packages/viewer/src/hooks/use-node-events.ts` + - overlay event suppression only +- `packages/viewer/src/components/viewer/selection-manager.tsx` + - runtime highlight/suppression only + +### App shell / routes + +- `apps/editor/app/_lib/home-assistant-imports.ts` + - normalized import-list builder +- `apps/editor/app/_lib/home-assistant-server.ts` + - collection-action to HA-service translation only +- `apps/editor/app/_lib/home-assistant-auth.ts` + - auth/session only +- `apps/editor/app/_lib/home-assistant-discovery.ts` + - discovery support for import model +- `apps/editor/app/_lib/home-assistant-linked-profile.ts` + - linked profile/session only +- `apps/editor/app/api/home-assistant/connect/route.ts` + - connection bootstrap +- `apps/editor/app/api/home-assistant/connection-status/route.ts` + - connection health +- `apps/editor/app/api/home-assistant/import-resources/route.ts` + - import list endpoint +- `apps/editor/app/api/home-assistant/device-action/route.ts` + - collection-action endpoint only +- `apps/editor/app/api/home-assistant/discover-devices/route.ts` + - removed from the final merged state +- `apps/editor/app/api/home-assistant/oauth/start/route.ts` + - OAuth start +- `apps/editor/app/api/home-assistant/oauth/callback/route.ts` + - OAuth callback +- `apps/editor/app/api/home-assistant/unlink/route.ts` + - unlink without destroying scene collections +- `apps/editor/app/_components/home-assistant-connection-test.tsx` + - dev-only diagnostic surface or removed from the final merged state + +## Final Push And PR Rule + +Do **not**: + +- push this branch because it matches the plan +- submit a PR because the implementation is "PR-ready" +- treat PR-readiness as permission to merge + +This document only defines the condition where the work becomes ready for a later push/PR decision. + +A branch may be considered PR-ready only when: + +- the implementation matches this document +- the proof list in Phase 6 passes +- the code and migration document are still aligned + +Even then, pushing or submitting a PR remains a separate explicit decision and is outside the scope of this plan. diff --git a/docs/ha-rts-demo1-plan.md b/docs/ha-rts-demo1-plan.md new file mode 100644 index 000000000..420777a6b --- /dev/null +++ b/docs/ha-rts-demo1-plan.md @@ -0,0 +1,268 @@ +# HA + RTS Demo 1 Plan + +## Purpose + +This document defines the **first real end-to-end milestone** for the Home Assistant + RTS work. + +It is intentionally narrow. + +The goal is not to prove the whole migration. The goal is to prove the smallest believable slice of the final product. + +This document does **not** authorize a push or a PR. It only defines the first milestone the implementation should reach. + +## Demo 1 Definition + +**Demo 1** means: + +- one fake Home Assistant light exists inside the real local HA instance +- Pascal can connect to that HA instance +- Pascal can import that fake light +- one `Dining room` ceiling lamp in the Pascal editor can be linked to it +- one collection is created for that link +- one RTS control appears at the linked lamp position +- clicking that RTS control affects the HA-backed light +- Pascal reflects the returned HA state +- reloading the editor keeps the same binding and the same RTS control + +If any of those are missing, Demo 1 is **not** complete. + +## Exact Target For Demo 1 + +### Home Assistant side + +Use one fake/demo/template-backed HA entity: + +- `light.pascal_dining_single` + +This light should be: + +- importable +- toggleable +- brightness-capable if practical + +### Pascal side + +Use one real item on the default layout: + +- one `Dining room` `Ceiling Lamp` + +The current default layout is served from: + +- [route.ts](/C:/Users/briss/.codex/worktrees/610d/editor/apps/editor/app/api/default-layout/route.ts) + +which currently reads: + +- `C:\Users\briss\Downloads\layout_2026-04-08.json` + +## Why Demo 1 Comes First + +Demo 1 proves the real backbone of the product: + +- HA connection +- HA import +- Pascal collection binding +- RTS spatial rendering +- HA action execution +- HA state coming back +- reload persistence + +It avoids broader complexity such as: + +- grouped controls +- multiple linked items +- fans +- scripts/scenes/automations +- unlink flow +- room-wide empty-state cleanup beyond what is necessary + +So Demo 1 is the fastest path to something real instead of theoretical. + +## Demo 1 Success Criteria + +Demo 1 is done only when all of these are true: + +1. Pascal connects to the local Home Assistant instance successfully. +2. The import list shows `light.pascal_dining_single`. +3. One `Dining room` ceiling lamp can be selected in Pascal and linked to that light. +4. A collection is created or updated to hold that link. +5. The `Dining room` RTS control appears at the linked lamp location. +6. Clicking that RTS control sends the collection-based action path to HA. +7. The HA light changes state. +8. Pascal updates to reflect the returned HA-backed state. +9. Reloading the editor preserves the binding and restores the RTS control. + +## Out Of Scope For Demo 1 + +These are **not** required for Demo 1: + +- grouped light control +- multi-item collection linking +- fan control +- script/scene/automation trigger tiles +- unlink UI +- import refresh edge cases +- multiple HA instances +- manual RTS placement overrides + +Those come after Demo 1. + +## Required Fixture Before Starting + +Before any Pascal implementation work is counted toward Demo 1, the following HA fixture must exist: + +- one fake/demo/template-backed light: + - `light.pascal_dining_single` + +Recommended construction: + +- real Home Assistant instance +- helper-backed or template-backed fake light state +- no physical device required + +The important point is: + +- **Home Assistant is real** +- **the test light is fake** + +That keeps the dev loop realistic without depending on a real apartment device. + +## Implementation Order + +Proceed in this exact order. + +### Step 1. Build the HA fixture + +Create `light.pascal_dining_single` in the local Home Assistant instance. + +Exit condition: + +- the light exists in HA and can be toggled there + +### Step 2. Prove connect + import + +Make sure Pascal can: + +- connect to HA +- import `light.pascal_dining_single` + +Exit condition: + +- the light shows up in the Pascal import list + +### Step 3. Finish the single-item link flow + +In the editor: + +- select one `Dining room` ceiling lamp +- link it to `light.pascal_dining_single` + +Exit condition: + +- a collection exists for that link + +### Step 4. Finish single-control RTS rendering + +Render one RTS control from that collection only. + +Requirements: + +- it should appear at the linked lamp position +- it should not rely on viewer-local durable grouping state + +Exit condition: + +- the linked `Dining room` lamp has a visible RTS control in the right place + +### Step 5. Finish the collection-based action path + +Clicking that RTS control must: + +- resolve the collection +- call the collection-based backend route +- trigger the HA action + +Exit condition: + +- the old item-direct path is not used for this flow + +### Step 6. Finish state coming back from HA + +After the action fires: + +- Pascal must reflect the new HA-backed state + +Exit condition: + +- the RTS/UI state and Pascal visuals match the updated HA state + +### Step 7. Finish reload persistence + +Reload the editor and verify: + +- the collection still exists +- the link still exists +- the RTS control still appears in the same place + +Exit condition: + +- the Demo 1 flow survives reload + +## Demo 1 Validation Walkthrough + +This is the human-facing check once implementation is in place. + +### Validation A. Connect and import + +Expected result: + +- `light.pascal_dining_single` is visible in the HA import list + +### Validation B. Link one lamp + +Expected result: + +- one `Dining room` ceiling lamp is linked to the imported light + +### Validation C. See the RTS control + +Expected result: + +- one RTS control appears over that linked lamp + +### Validation D. Click it + +Expected result: + +- the HA-backed light changes state +- Pascal reflects the change + +### Validation E. Reload + +Expected result: + +- the same binding and RTS control come back after reload + +## Evidence Required Before Calling Demo 1 Done + +The minimum proof set is: + +- app route proof that HA connection works +- import proof showing `light.pascal_dining_single` +- editor proof that the `Dining room` lamp is linked +- runtime proof that the RTS control appears in the correct place +- runtime proof that clicking it changes HA state +- reload proof that the binding persists + +## What Comes Immediately After Demo 1 + +Once Demo 1 is complete, the next best sequence is: + +1. grouped dining-room light demo +2. master-bedroom fan demo +3. trigger-only living-room script/scene demo +4. unlink demo +5. import refresh and edge-case cleanup + +## One-Sentence Summary + +Demo 1 is: **one fake HA light imported into Pascal, linked to one `Dining room` lamp, shown as one RTS control in the right place, clickable through the collection path, and still there after reload.** diff --git a/docs/ha-rts-detailed-tasklist.md b/docs/ha-rts-detailed-tasklist.md new file mode 100644 index 000000000..1eb9d5c5f --- /dev/null +++ b/docs/ha-rts-detailed-tasklist.md @@ -0,0 +1,1623 @@ +# HA + RTS Detailed High-Level Task List + +## Purpose + +This file is the execution task list derived from [ha-rts-architecture-migration-plan.md](/C:/Users/briss/.codex/worktrees/610d/editor/docs/ha-rts-architecture-migration-plan.md). + +It is intentionally: + +- detailed +- high-level +- dependency-aware +- organized so the work can be executed phase by phase without skipping hidden prerequisites + +This file does **not** authorize a push or a PR. + +Its job is to break the approved architecture plan into implementation work that can be completed and validated until the branch becomes PR-ready. + +## Global Rules + +- Do not push a branch because a phase is complete. +- Do not submit a PR because this task list is complete. +- Treat every phase as unfinished until its validation items pass. +- If the implementation deviates from the migration plan, update the plan first, then continue. +- If a task reveals a conflicting architecture decision, stop and resolve the document before continuing. + +## Fixture-First Validation Rule + +Because the current Pascal layout is not the user's real apartment and the current Home Assistant instance does not have a full real-device setup, all demo validation must be done against a **fake but real Home Assistant fixture** first. + +That means: + +- Home Assistant itself is real +- the imported entities/actions used for testing are fake/demo/template/helper-backed +- every feature demo must be proven against that fixture before it is shown as validated + +Optional later smoke checks against a real physical device are useful, but they are not the main development loop for this migration. + +## HA Sandbox Fixture + +This fixture is the required Home Assistant-side test bed for the Pascal demos. + +It should be created in the local Home Assistant instance and kept stable while the Pascal work is being implemented. + +### Fixture Goal + +Provide a deterministic set of fake HA resources that match the Pascal demo layout rooms and let us test: + +- single light linking +- grouped light linking +- brightness control +- fan control +- trigger-only actions +- import refresh +- grouping behavior + +### Fixture Resource Map + +These are the target HA resources the Pascal demos should use. + +- `light.pascal_dining_single` + - fake dimmable light + - used for: + - `F3` + - `F7` + - `F11` + - `F14` +- `light.pascal_dining_group` + - fake grouped dimmable light + - used for: + - `F4` + - `F8` + - `F12` +- `fan.pascal_master_bedroom` + - fake controllable fan + - used for: + - `F5` + - `F9` + - `F13` +- `script.pascal_living_room_demo` + - fake trigger-only action + - used for: + - `F6` + - `F15` +- `scene.pascal_living_room_evening` + - optional second trigger-only import for scene coverage + - used as backup/extra proof for: + - `F6` + - `F15` + +### Recommended Fixture Construction + +Build the fixture in Home Assistant using: + +- helpers for raw fake state + - `input_boolean` + - `input_number` + - `input_button` +- template entities for real HA-like domains + - template `light` + - template `fan` +- scripts/scenes for trigger-only imports +- HA `group` if grouped entity behavior needs to be exercised as an HA-side grouped control + +### Fixture Creation Tasks + +#### H0.1 Create helper state for the fake dining single light + +Create: + +- one `input_boolean` for on/off +- one `input_number` for brightness + +Target output: + +- helper state backing `light.pascal_dining_single` + +#### H0.2 Create helper state for the fake dining grouped light + +Create: + +- one `input_boolean` for grouped on/off +- one `input_number` for grouped brightness + +Target output: + +- helper state backing `light.pascal_dining_group` + +#### H0.3 Create helper state for the fake master bedroom fan + +Create: + +- one `input_boolean` for fan on/off +- one `input_number` for fan speed/percentage + +Target output: + +- helper state backing `fan.pascal_master_bedroom` + +#### H0.4 Create template entities + +Create: + +- `light.pascal_dining_single` +- `light.pascal_dining_group` +- `fan.pascal_master_bedroom` + +Requirements: + +- the lights must be controllable and expose brightness +- the fan must be controllable and expose speed/percentage if supported by the template choice + +#### H0.5 Create trigger-only imports + +Create: + +- `script.pascal_living_room_demo` +- optionally `scene.pascal_living_room_evening` + +Requirements: + +- at least one trigger-only HA thing must be importable without pretending to be a toggle + +#### H0.6 Confirm import visibility + +After fixture creation, confirm the HA import layer can surface: + +- `light.pascal_dining_single` +- `light.pascal_dining_group` +- `fan.pascal_master_bedroom` +- `script.pascal_living_room_demo` +- optional `scene.pascal_living_room_evening` + +### Fixture Completion Check + +Do not count any Pascal demo as valid until: + +- the fixture exists in HA +- the import route can see the fixture resources +- the fixture resources are stable enough to be reused across demos + +## Editor Demo Contract + +Every feature in this task list must be verifiable inside the Pascal editor, on the default layout map currently served by: + +- `apps/editor/app/api/default-layout/route.ts` +- backing file: `C:\Users\briss\Downloads\layout_2026-04-08.json` + +Every validation slice must be: + +- demonstration-ready in the editor +- anchored to a real room/item on that layout +- no more than 4 clicks per feature + +For the connect/import demos, assume these real-life prerequisites are already true before counting feature clicks: + +- Home Assistant is running and reachable +- the editor is already open on the default layout +- the browser already has a valid Home Assistant login session if the connect flow redirects through HA auth +- the HA sandbox fixture described above has already been created + +### Click Budget Rule + +For this document: + +- a click means a deliberate UI click or press on a product control +- a drag on a slider counts as one pointer action +- camera orbit/pan/zoom is not counted against the feature click budget +- keyboard shortcuts are allowed only when explicitly called out, but the preferred path is still click-first + +### Demo Rooms And Item Anchors + +Use these real layout anchors when validating features: + +- `Dining room` + - 3 `Ceiling Lamp` items + - 1 `Recessed Light` + - use for: + - single-light link demo + - multi-item light-group demo + - grouped RTS placement demo +- `Kicthen` + - note: this room is spelled `Kicthen` in the current layout data + - 2 `Ceiling Lamp` items + - multiple `Recessed Light` items + - use for: + - dense-light room demo + - brightness/slider demo +- `Master bedroom` + - 1 `Ceiling fan` + - 4 `Recessed Light` items + - use for: + - fan link demo + - fan RTS action demo +- `Living room` + - 2 `Table Lamp` items + - use for: + - trigger-only action link demo + - single/group placement demo when needed +- `Garage 2` + - use for: + - empty-room-hidden demo + +For all RTS visibility demos (`F7` through `F16`), first move the camera until the named room is visible and its RTS pill can appear on screen. Camera motion is part of the real-life demo flow, but it does not count against the click budget. + +For item-link demos (`F3` through `F6`, plus `F17`), first move the camera until the named item is visible and easy to click in the editor. Camera motion is part of the real-life demo flow, but it does not count against the click budget. + +## Feature Validation Map + +Each feature below is a mandatory editor-visible validation slice. + +Unless a feature explicitly says otherwise, use the named HA sandbox fixture resources in these demos rather than ad-hoc imports. + +### F1. Connect Home Assistant + +Goal: + +- prove the user can connect from the editor + +Starting state: + +- editor open on the default layout +- HA panel closed +- Home Assistant reachable +- browser already authenticated with Home Assistant if the auth redirect is used + +Steps: + +1. Click the Home Assistant entry point in the editor shell. +2. Click `Connect Home Assistant`. + +Expected result: + +- connection status becomes connected +- import controls become available +- the connection targets the single linked HA instance configured through the panel URL fields, not a multi-instance picker + +Click budget: + +- 2 clicks + +### F2. Refresh Imports + +Goal: + +- prove the user can fetch current HA imports + +Starting state: + +- HA panel already open +- HA already connected + +Steps: + +1. Click `Refresh imports`. + +Expected result: + +- imported HA rows refresh in place +- no scene data is mutated just by refreshing +- the refreshed list includes the sandbox fixture rows: + - `light.pascal_dining_single` + - `light.pascal_dining_group` + - `fan.pascal_master_bedroom` + - `script.pascal_living_room_demo` + +Click budget: + +- 1 click + +### F3. Link One HA Light To One Pascal Item + +Goal: + +- prove one imported HA entity can be linked to one Pascal item + +Layout anchor: + +- one `Dining room` `Ceiling Lamp` + +Starting state: + +- HA panel open +- imports loaded +- use the imported row `script.pascal_living_room_demo` +- use the imported row `fan.pascal_master_bedroom` +- use the imported row `light.pascal_dining_group` +- use the imported row `light.pascal_dining_single` + +Steps: + +1. Click one `Dining room` ceiling lamp in the editor. +2. Click the direct link action on one imported light row. + +Expected result: + +- a collection is created or updated +- the selected lamp is now represented by that collection + +Click budget: + +- 2 clicks + +### F4. Link One HA Light To A Multi-Item Pascal Group + +Goal: + +- prove one imported HA entity can be linked to a group of Pascal items + +Layout anchor: + +- 3 `Dining room` `Ceiling Lamp` items + +Starting state: + +- HA panel open +- imports loaded + +Steps: + +1. Click the left `Dining room` ceiling lamp. +2. `Ctrl`+click the middle `Dining room` ceiling lamp. +3. `Ctrl`+click the right `Dining room` ceiling lamp. +4. Click the direct link action on one imported light row. + +Expected result: + +- one collection represents the selected lamp group +- that group can later render as one RTS control centered on the group + +Click budget: + +- 4 clicks + +### F5. Link One HA Fan To One Pascal Fan + +Goal: + +- prove a fan-capable HA entity can be linked cleanly + +Layout anchor: + +- `Master bedroom` `Ceiling fan` + +Starting state: + +- HA panel open +- imports loaded + +Steps: + +1. Click the `Master bedroom` ceiling fan. +2. Click the direct link action on one imported fan row. + +Expected result: + +- a collection is created or updated for the ceiling fan +- the collection exposes fan-appropriate control capability + +Click budget: + +- 2 clicks + +### F6. Link One Trigger-Only HA Action + +Goal: + +- prove a script/scene/automation-style import can be linked even when it is not an on/off entity + +Layout anchor: + +- one `Living room` `Table Lamp` + +Starting state: + +- HA panel open +- imports loaded + +Steps: + +1. Click one `Living room` table lamp. +2. Click the direct link action on one imported script, scene, or automation row. + +Expected result: + +- a trigger-only collection is created or updated +- the collection can later render as an action tile instead of a persistent state tile + +Click budget: + +- 2 clicks + +### F7. See A Single RTS Control In The Correct Place + +Goal: + +- prove a single-item-linked collection renders in the right spot + +Layout anchor: + +- the single linked `Dining room` ceiling lamp from `F3` + +Starting state: + +- `F3` already completed + +Steps: + +1. Click the `Dining room` RTS pill if the room panel is collapsed. + +Expected result: + +- the control appears anchored over the linked lamp position, not at an arbitrary room point + +Click budget: + +- 1 click + +### F8. See A Grouped RTS Control In The Correct Place + +Goal: + +- prove a grouped collection renders at the center of its linked Pascal item group + +Layout anchor: + +- the 3 linked `Dining room` ceiling lamps from `F4` + +Starting state: + +- `F4` already completed + +Steps: + +1. Click the `Dining room` RTS pill if needed. + +Expected result: + +- the grouped control appears centered on the 3-lamp group +- it is not centered on the whole room unless that happens to be the true group center + +Click budget: + +- 1 click + +### F9. See A Fan RTS Control In The Correct Place + +Goal: + +- prove a fan-linked collection renders at the fan location + +Layout anchor: + +- the linked `Master bedroom` ceiling fan from `F5` + +Starting state: + +- `F5` already completed + +Steps: + +1. Click the `Master bedroom` RTS pill if needed. + +Expected result: + +- the fan control appears at the ceiling fan location + +Click budget: + +- 1 click + +### F10. Confirm Empty Rooms Stay Hidden + +Goal: + +- prove unlinked rooms do not show RTS controls + +Layout anchor: + +- `Garage 2` + +Starting state: + +- there is no linked collection for `Garage 2` + +Steps: + +1. Move the camera so `Garage 2` is visible. + +Expected result: + +- no RTS room pill or control panel appears for `Garage 2` + +Click budget: + +- 0 clicks + +### F11. Trigger A Single Linked Light + +Goal: + +- prove a single linked entity-backed control can affect HA from the RTS UI + +Layout anchor: + +- the single linked `Dining room` lamp from `F3` + +Starting state: + +- `F3` and `F7` already completed +- the `Dining room` panel is still open from `F7` + +Steps: + +1. Click the single linked `Dining room` light control. + +Expected result: + +- the linked HA light action fires +- Pascal visual state updates to match the new HA-backed state + +Click budget: + +- 1 click + +### F12. Trigger A Grouped Light Control + +Goal: + +- prove one grouped control affects all linked Pascal items through one HA-backed collection + +Layout anchor: + +- the grouped `Dining room` lamp collection from `F4` + +Starting state: + +- `F4` and `F8` already completed +- the `Dining room` panel is still open from `F8` + +Steps: + +1. Click the grouped `Dining room` control. + +Expected result: + +- the grouped HA-backed control fires +- all linked lamps reflect the resulting state + +Click budget: + +- 1 click + +### F13. Trigger A Fan Control + +Goal: + +- prove a fan collection can execute from the RTS UI + +Layout anchor: + +- the linked `Master bedroom` fan from `F5` + +Starting state: + +- `F5` and `F9` already completed +- the `Master bedroom` panel is still open from `F9` + +Steps: + +1. Click the `Master bedroom` fan control. + +Expected result: + +- the fan HA action fires +- Pascal reflects the fan state correctly + +Click budget: + +- 1 click + +### F14. Use A Room Slider + +Goal: + +- prove a slider-capable collection can be adjusted from the RTS UI + +Layout anchor: + +- the single linked `Dining room` light collection from `F3` + +Starting state: + +- `F3` and `F7` already completed +- the linked HA light from `F3` supports brightness +- the `Dining room` panel is still open from `F7` + +Steps: + +1. Drag the brightness slider on the linked `Dining room` light tile. + +Expected result: + +- the HA brightness action fires +- Pascal updates the visible intensity state + +Click budget: + +- 1 pointer action + +### F15. Trigger A Script/Scene/Automation Tile + +Goal: + +- prove a trigger-only collection can execute without pretending to be an on/off entity + +Layout anchor: + +- the trigger-only `Living room` collection from `F6` + +Starting state: + +- `F6` already completed +- the trigger-only import used here is `script.pascal_living_room_demo` unless the optional scene is being used for extra coverage + +Steps: + +1. Click the `Living room` RTS pill. +2. Click the trigger-only RTS tile. + +Expected result: + +- the imported script/scene/automation action fires +- no fake persistent toggle state is required + +Click budget: + +- 2 clicks + +### F16. Reload And Keep The Same Bindings + +Goal: + +- prove collection bindings survive a reload + +Starting state: + +- at least `F3`, `F4`, and `F5` already completed + +Steps: + +1. Reload the editor page. +2. Click the `Dining room` RTS pill. +3. Click the `Master bedroom` RTS pill. + +Expected result: + +- previously linked controls still appear +- grouped and single-item bindings are preserved + +Click budget: + +- 3 actions + +### F17. Unlink One Existing Binding + +Goal: + +- prove the user can remove a binding cleanly + +Layout anchor: + +- one already linked `Dining room` or `Living room` item + +Starting state: + +- HA panel open +- at least one binding exists + +Steps: + +1. Click the linked Pascal item. +2. Click the `Unlink` action in the HA panel. + +Expected result: + +- the collection link is removed or updated appropriately +- the corresponding RTS control disappears or updates after runtime refresh + +Click budget: + +- 2 clicks + +## Final Outcome This Task List Is Driving Toward + +When all tasks here are done, Pascal should be able to: + +1. connect to an existing Home Assistant instance +2. import existing Home Assistant devices and callable actions +3. let the user link imported HA things to Pascal items or groups of items +4. save those links durably through collections +5. render RTS controls from those collections +6. send all actions through one collection-based HA execution path +7. restore the same bindings after reload +8. hide empty rooms and avoid viewer-local durable grouping logic + +## Work Sequence Overview + +The execution order is: + +0. HA sandbox fixture +1. Durable collection contract +2. HA import layer +3. Editor connect/import/link flow +4. Collection-driven RTS runtime +5. Unified HA action execution and state sync +6. Cleanup and proof + +Do not reorder those phases unless the architecture plan changes. + +--- + +## Phase 0 Task List: Provision The HA Sandbox Fixture + +### Goal + +Build the fake-but-real Home Assistant entities and actions needed to test the Pascal demos before showing them as validated. + +### Files And Systems In Scope + +- Home Assistant helper configuration +- Home Assistant template entities +- Home Assistant scripts/scenes/groups +- Pascal import route output as the verification surface + +### Tasks + +#### 0.1 Create the helper-backed fake state + +Tasks: + +- create helper state for the dining single light +- create helper state for the dining grouped light +- create helper state for the master bedroom fan +- create any trigger helper needed by the demo script + +Validation: + +- helper state can be changed in HA and is visible there before Pascal is involved + +#### 0.2 Create the fake HA entities + +Tasks: + +- create `light.pascal_dining_single` +- create `light.pascal_dining_group` +- create `fan.pascal_master_bedroom` + +Validation: + +- all three entities exist in HA +- the lights expose brightness +- the fan exposes usable control state + +#### 0.3 Create the trigger-only HA actions + +Tasks: + +- create `script.pascal_living_room_demo` +- optionally create `scene.pascal_living_room_evening` + +Validation: + +- at least one trigger-only import exists and can be manually run in HA + +#### 0.4 Verify Pascal can see the fixture + +Tasks: + +- connect Pascal to HA +- refresh imports +- confirm the fixture rows appear in the editor import list + +Validation: + +- the import list includes: + - `light.pascal_dining_single` + - `light.pascal_dining_group` + - `fan.pascal_master_bedroom` + - `script.pascal_living_room_demo` + +### Phase 0 Stop Conditions + +Stop and fix before moving on if: + +- the fixture entities do not exist in HA +- the fixture entities are not controllable enough for the demos +- Pascal cannot import the fixture rows + +### Phase 0 Completion Check + +- fixture exists +- fixture is controllable in HA +- Pascal import can see the fixture +- demos now have stable fake HA targets + +If not all four are true, do not start Phase 1. + +--- + +## Phase 1 Task List: Lock The Durable Collection Contract + +### Goal + +Make the saved Pascal scene capable of carrying the final control model safely. + +### Files In Scope + +- `packages/core/src/schema/collections.ts` +- `packages/core/src/schema/index.ts` +- `packages/core/package.json` +- `packages/core/src/store/use-scene.ts` +- `packages/editor/src/lib/scene.ts` +- `packages/editor/src/hooks/use-auto-save.ts` + +### Tasks + +#### 1.1 Finalize collection schema + +File: + +- `packages/core/src/schema/collections.ts` + +Tasks: + +- add or finalize the durable collection fields for: + - `kind` + - `capabilities` + - `presentation` + - `homeAssistant.importIds` + - `homeAssistant.primaryImportId` + - `homeAssistant.resourceKind` + - `homeAssistant.aggregation` + - `homeAssistant.serviceMap` +- ensure the schema is reference-based, not payload-based +- remove or stop using any field pattern that assumes the full imported HA payload lives in the collection +- keep `nodeIds` and `controlNodeId` as core fields + +Validation: + +- schema type is expressive enough for: + - single entity-backed control + - grouped entity-backed control + - trigger-only action control + +#### 1.2 Export the stable schema surface + +Files: + +- `packages/core/src/schema/index.ts` +- `packages/core/package.json` + +Tasks: + +- export all final schema types needed by: + - editor code + - app route code + - server-side helpers +- ensure the schema import path used by server-side code is stable and safe + +Validation: + +- app/editor/server code can import the final schema types without going through unstable/public barrels + +#### 1.3 Normalize collection storage behavior + +File: + +- `packages/core/src/store/use-scene.ts` + +Tasks: + +- normalize collections on: + - create + - update + - scene load + - scene replace/reset +- add helpers/selectors for: + - collections by Pascal item id + - collections by room/zone context + - collection primary node resolution + +Validation: + +- collection data shape is consistent after CRUD operations +- selectors are sufficient for editor UI and RTS runtime + +#### 1.4 Persist collections through the app scene graph + +Files: + +- `packages/editor/src/lib/scene.ts` +- `packages/editor/src/hooks/use-auto-save.ts` + +Tasks: + +- make app scene load/apply carry collections through +- make autosave include collections +- confirm runtime-only UI state is not included + +Validation: + +- save -> reload restores collections +- re-applying a scene does not lose collection data + +### Phase 1 Stop Conditions + +Stop and fix before moving on if: + +- collections disappear on reload +- collections require embedded HA payloads to remain usable +- server/editor code still needs unstable schema import paths + +### Phase 1 Completion Check + +- collections persist correctly +- collections are normalized +- collections are reference-based +- this phase unlocks the data safety required before any demo features can be trusted + +If not all three are true, do not start Phase 2. + +--- + +## Phase 2 Task List: Build The Refreshable HA Import Layer + +### Goal + +Expose one normalized, refreshable import surface for the Home Assistant things Pascal cares about. + +### Files In Scope + +- `apps/editor/app/_lib/home-assistant-imports.ts` +- `apps/editor/app/_lib/home-assistant-discovery.ts` +- `apps/editor/app/_lib/home-assistant-auth.ts` +- `apps/editor/app/_lib/home-assistant-linked-profile.ts` +- `apps/editor/app/api/home-assistant/connect/route.ts` +- `apps/editor/app/api/home-assistant/connection-status/route.ts` +- `apps/editor/app/api/home-assistant/import-resources/route.ts` +- `apps/editor/app/api/home-assistant/discover-devices/route.ts` + +### Tasks + +#### 2.1 Normalize the import list model + +File: + +- `apps/editor/app/_lib/home-assistant-imports.ts` + +Tasks: + +- define the normalized imported resource shape used by the app +- make the import list include: + - supported entities/devices + - scripts + - scenes + - selected automations + - supported grouped/helper resources if meaningful +- keep areas/floors/labels only as optional secondary metadata + +Validation: + +- import list has one consistent shape regardless of original HA source type + +#### 2.2 Align discovery to the import model + +File: + +- `apps/editor/app/_lib/home-assistant-discovery.ts` + +Tasks: + +- remove item-centric or older prototype assumptions +- make discovery support the import-list builder rather than bypassing it + +Validation: + +- discovery output can feed normalized import building directly + +#### 2.3 Keep auth/session concerns isolated + +Files: + +- `apps/editor/app/_lib/home-assistant-auth.ts` +- `apps/editor/app/_lib/home-assistant-linked-profile.ts` + +Tasks: + +- confirm these files remain auth/profile/session concerns only +- ensure no scene-level persistence leaks auth data + +Validation: + +- auth/session data is not serialized into collections or scene saves + +#### 2.4 Make connect bootstrap imports immediately + +Files: + +- `apps/editor/app/api/home-assistant/connect/route.ts` +- `apps/editor/app/api/home-assistant/connection-status/route.ts` +- `apps/editor/app/api/home-assistant/import-resources/route.ts` + +Tasks: + +- make connect establish the session needed for imports +- make status route return what the UI needs to know if import is possible +- make import route return normalized imports only + +Validation: + +- connect works +- status reflects connected vs unlinked states cleanly +- import route returns normalized data once connected + +#### 2.5 Remove the extra public import surface + +File: + +- `apps/editor/app/api/home-assistant/discover-devices/route.ts` + +Tasks: + +- move any remaining callers to `import-resources` +- remove this route from the final merged state + +Validation: + +- there is exactly one public import surface for HA resources + +### Phase 2 Stop Conditions + +Stop and fix before moving on if: + +- imports are still split across multiple incompatible routes +- imports mutate scene data +- import responses still depend on item-level assumptions + +### Phase 2 Completion Check + +- connect works +- import works +- refresh works +- imports are normalized +- only one public import surface remains +- demo features unlocked: + - `F1` + - `F2` + +If not all five are true, do not start Phase 3. + +--- + +## Phase 3 Task List: Rebuild The Editor Around Connect -> Import -> Link + +### Goal + +Give the user one clean flow to bind imported HA things to Pascal items through collections. + +### Files In Scope + +- `packages/editor/src/lib/home-assistant-collections.ts` +- `packages/editor/src/lib/home-assistant-controls.ts` +- `packages/editor/src/lib/home-assistant-connect.ts` +- `packages/editor/src/components/ui/panels/home-assistant-panel.tsx` +- `packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx` +- `packages/editor/src/components/ui/home-assistant-action-icon.tsx` +- `packages/editor/src/components/editor/floating-action-menu.tsx` +- `packages/editor/src/components/editor/node-action-menu.tsx` +- `packages/editor/src/components/editor/floorplan-panel.tsx` +- `packages/editor/src/store/use-editor.tsx` +- `packages/editor/src/components/ui/panels/panel-manager.tsx` +- `packages/editor/src/components/ui/panels/lazy-navigation-panel.tsx` + +### Tasks + +#### 3.1 Build the collection-binding helper layer + +File: + +- `packages/editor/src/lib/home-assistant-collections.ts` + +Tasks: + +- convert editor selection + imported HA resource into a collection update +- create collections when none exist +- update existing collections when one already represents the selected Pascal item/group +- ensure only stable HA references are written + +Validation: + +- linking the same HA thing twice does not create inconsistent collection state + +#### 3.2 Align editor-side control helpers + +Files: + +- `packages/editor/src/lib/home-assistant-controls.ts` +- `packages/editor/src/lib/home-assistant-connect.ts` + +Tasks: + +- derive UI-facing control affordances from collection capabilities and imported resource kind +- remove item-name or item-type heuristics that bypass the collection model +- make connect/import/link helper logic point to the new flow only + +Validation: + +- the UI can distinguish: + - stateful controls + - trigger-only controls + +#### 3.3 Make one main HA authoring panel + +File: + +- `packages/editor/src/components/ui/panels/home-assistant-panel.tsx` + +Tasks: + +- implement the main flow: + - connect + - refresh imports + - browse imports + - link to selected Pascal item(s) + - create/update collection +- keep advanced metadata editing secondary + +Validation: + +- a user can complete the first-time flow from this panel alone + +#### 3.4 Remove the duplicate HA authoring surface + +File: + +- `packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx` + +Tasks: + +- move any needed behavior into the main HA panel +- remove the file from the final merged state + +Validation: + +- there is one HA authoring flow in the shipped editor + +#### 3.5 Align HA entry points across the editor + +Files: + +- `packages/editor/src/components/ui/home-assistant-action-icon.tsx` +- `packages/editor/src/components/editor/floating-action-menu.tsx` +- `packages/editor/src/components/editor/node-action-menu.tsx` +- `packages/editor/src/components/editor/floorplan-panel.tsx` +- `packages/editor/src/store/use-editor.tsx` +- `packages/editor/src/components/ui/panels/panel-manager.tsx` +- `packages/editor/src/components/ui/panels/lazy-navigation-panel.tsx` + +Tasks: + +- make action entry points open the HA binding flow +- remove direct HA execution from UI affordances +- keep only collection-oriented ephemeral UI state +- ensure the HA panel is actually reachable in the shipped shell + +Validation: + +- all HA UI entry points lead into the same binding flow + +### Phase 3 Stop Conditions + +Stop and fix before moving on if: + +- there are still two HA authoring flows +- the main panel cannot complete connect/import/link alone +- UI actions still bypass collections and target items directly + +### Phase 3 Completion Check + +- one main HA panel exists +- imports can be linked to Pascal selection +- collections are created/updated correctly +- duplicate HA authoring surface is gone +- demo features unlocked: + - `F3` + - `F4` + - `F5` + - `F6` + - `F17` + +If not all four are true, do not start Phase 4. + +--- + +## Phase 4 Task List: Make RTS Runtime Fully Collection-Driven + +### Goal + +Make the viewer render and interact only through durable collections. + +### Files In Scope + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` +- `packages/viewer/src/store/use-viewer.ts` +- `packages/viewer/src/store/use-viewer.d.ts` +- `packages/viewer/src/hooks/use-node-events.ts` +- `packages/viewer/src/components/viewer/selection-manager.tsx` +- `packages/editor/src/components/editor/selection-manager.tsx` +- `packages/editor/src/components/systems/zone/zone-system.tsx` +- `packages/editor/src/components/viewer-zone-system.tsx` +- `packages/editor/src/components/editor/index.tsx` +- `packages/editor/src/index.tsx` +- `packages/viewer/src/index.ts` + +### Tasks + +#### 4.1 Replace prototype RTS derivation with collection derivation + +File: + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + +Tasks: + +- derive room controls from collections only +- remove raw item-local control derivation as the canonical source +- support entity-backed and trigger-only collection controls + +Validation: + +- control tiles are explainable entirely from collection data + +#### 4.2 Compute placement from Pascal geometry + +File: + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + +Tasks: + +- compute control position from linked Pascal items/groups +- do not depend on durable viewer-local placement data +- hide rooms that have no controls + +Validation: + +- linked single item -> correct centered button +- linked group -> correct group-centered button +- empty room -> no control shown + +#### 4.3 Remove viewer-local durable grouping + +Files: + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` +- `packages/viewer/src/store/use-viewer.ts` +- `packages/viewer/src/store/use-viewer.d.ts` + +Tasks: + +- remove local-storage grouping as authoritative state +- keep only runtime UI state in viewer store + +Validation: + +- reloading the page does not rely on local viewer state to recover the real control model + +#### 4.4 Keep selection and labels clean + +Files: + +- `packages/viewer/src/hooks/use-node-events.ts` +- `packages/viewer/src/components/viewer/selection-manager.tsx` +- `packages/editor/src/components/editor/selection-manager.tsx` +- `packages/editor/src/components/systems/zone/zone-system.tsx` +- `packages/editor/src/components/viewer-zone-system.tsx` + +Tasks: + +- prevent RTS interactions from selecting underlying scene nodes accidentally +- highlight the correct linked Pascal items +- stop zone labels from competing with RTS labels + +Validation: + +- clicking RTS controls does not open unrelated item-selection UI +- room labels are not duplicated + +#### 4.5 Clean package entrypoints + +Files: + +- `packages/editor/src/components/editor/index.tsx` +- `packages/editor/src/index.tsx` +- `packages/viewer/src/index.ts` + +Tasks: + +- keep composition/entrypoint behavior aligned with the final model +- avoid exposing prototype-only HA runtime helpers as stable API + +Validation: + +- package surfaces match the final runtime architecture + +### Phase 4 Stop Conditions + +Stop and fix before moving on if: + +- RTS still depends on viewer-local durable grouping +- empty rooms still show controls +- placements are not computed from linked Pascal geometry + +### Phase 4 Completion Check + +- RTS is collection-driven +- placement is geometry-driven +- empty rooms are hidden +- viewer state is runtime-only +- demo features unlocked: + - `F7` + - `F8` + - `F9` + - `F10` + +If not all four are true, do not start Phase 5. + +--- + +## Phase 5 Task List: Unify Runtime Execution And HA State Sync + +### Goal + +Ensure every runtime action and state update follows one collection-based path. + +### Files In Scope + +- `apps/editor/app/_lib/home-assistant-server.ts` +- `apps/editor/app/api/home-assistant/device-action/route.ts` +- `packages/editor/src/lib/home-assistant-controls.ts` +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + +### Tasks + +#### 5.1 Make server execution collection-only + +Files: + +- `apps/editor/app/_lib/home-assistant-server.ts` +- `apps/editor/app/api/home-assistant/device-action/route.ts` + +Tasks: + +- translate collection actions into HA service calls +- support: + - stateful entity-backed actions + - trigger-only actions +- remove item-direct payload handling + +Validation: + +- every action can be explained as: + - RTS tile + - collection + - backend route + - HA service call + +#### 5.2 Align control helpers to the final action contract + +File: + +- `packages/editor/src/lib/home-assistant-controls.ts` + +Tasks: + +- align generated control intents with the final collection-action route contract + +Validation: + +- editor-generated control behavior matches what the backend accepts + +#### 5.3 Align the viewer to the final action contract + +File: + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + +Tasks: + +- make tile interactions post only collection-action requests +- remove remaining direct HA logic from the viewer + +Validation: + +- viewer cannot trigger the old item-based HA path + +#### 5.4 Confirm HA state flows back into Pascal cleanly + +Primary files: + +- `apps/editor/app/_lib/home-assistant-server.ts` +- any runtime state/helper files touched during implementation + +Tasks: + +- confirm HA state updates reflect into the collection-backed runtime model +- confirm Pascal visual state follows collection state + +Validation: + +- toggling or triggering through HA is reflected back in Pascal runtime state + +### Phase 5 Stop Conditions + +Stop and fix before moving on if: + +- item-direct execution is still reachable +- stateful and trigger-only actions do not share the same collection path +- HA state changes do not reflect back to Pascal cleanly + +### Phase 5 Completion Check + +- one collection-action path exists +- old item path is gone or unreachable +- HA state updates come back into Pascal +- demo features unlocked: + - `F11` + - `F12` + - `F13` + - `F14` + - `F15` + +If not all three are true, do not start Phase 6. + +--- + +## Phase 6 Task List: Cleanup, Documentation Alignment, And Real Proof + +### Goal + +Remove prototype leftovers and prove the completed flow in the real app. + +### Files In Scope + +- `docs/home-assistant-integration.md` +- `apps/editor/app/_components/home-assistant-connection-test.tsx` +- any remaining touched HA/RTS files with leftover prototype logic + +### Tasks + +#### 6.1 Align the supporting docs + +File: + +- `docs/home-assistant-integration.md` + +Tasks: + +- align background explanation with the implemented architecture +- keep the doc descriptive, not contradictory to the migration plan + +Validation: + +- docs no longer describe an older item-centric or payload-centric model + +#### 6.2 Remove leftover prototype paths + +Files: + +- all remaining touched HA/RTS files that still contain transitional logic + +Tasks: + +- remove dead prototype code +- remove obsolete local-storage assumptions +- remove unreachable item-link compatibility +- remove duplicate HA authoring entry points +- keep auth/session data out of scene saves + +Validation: + +- no dead prototype path remains reachable from the shipped app + +#### 6.3 Keep diagnostics out of the shipped flow + +File: + +- `apps/editor/app/_components/home-assistant-connection-test.tsx` + +Tasks: + +- keep it as dev-only diagnostics only +- ensure production flow does not depend on it + +Validation: + +- user-facing flow does not depend on debug/test components + +#### 6.4 Run final real-world proof + +Tasks: + +- connect Pascal to a real HA instance +- import: + - one real device/entity + - one real script/scene/automation-style action +- link one imported HA thing to one Pascal item +- link one imported HA thing to multiple Pascal items +- verify the RTS control appears in the correct computed position +- trigger the control and affect the real HA-backed thing +- reload and verify bindings persist +- confirm empty rooms stay hidden +- confirm unlink/reconnect/import refresh works + +Validation: + +- all real-world proof items pass without manual data patching + +#### 6.5 Run final technical proof + +Tasks: + +- run touched package typechecks +- run touched package builds +- run app route health checks +- capture real browser proof on the live app + +Validation: + +- all required technical checks pass + +### Phase 6 Stop Conditions + +Stop and fix before declaring the branch PR-ready if: + +- any live HA proof fails +- any persistence proof fails +- any old path remains reachable +- any touched package typecheck/build fails + +### Phase 6 Completion Check + +- docs align +- prototype leftovers are removed +- real HA proof passes +- technical proof passes +- demo features revalidated: + - `F1` through `F17` + +If not all four are true, the branch is not PR-ready. + +--- + +## Final PR-Ready Gate + +The branch becomes PR-ready only when: + +- Phase 0 is complete +- Phases 1 through 6 are complete +- every phase completion check passed before moving on +- the final real-world proof passed +- the final technical proof passed +- the migration plan and this task list still match the implementation + +## Explicit Non-Authorization Rule + +Even when every task here is complete: + +- do not push the branch automatically +- do not submit a PR automatically +- do not treat task completion as merge authorization + +This file defines the work needed to become ready for a later push/PR decision. It does not grant that decision. diff --git a/docs/home-assistant-integration.md b/docs/home-assistant-integration.md new file mode 100644 index 000000000..6a951716c --- /dev/null +++ b/docs/home-assistant-integration.md @@ -0,0 +1,316 @@ +# Pascal x Home Assistant + +Research date: 2026-04-15 + +## Goal + +Understand how Home Assistant works, how the `ha-floorplan` "Floorplanner Home" example is put together, and what the clean replacement path looks like for Pascal's 2D/3D editor. + +## What Home Assistant Is + +Home Assistant (HA) is a home automation platform with three parts that matter here: + +1. A backend that owns entities, states, attributes, automations, and services. +2. A frontend that renders dashboards and panels. +3. APIs that let clients read live state and call services. + +For a Pascal integration, HA is not the renderer. It is the source of truth for automation state and the command target for actions such as `light.turn_on`, `light.toggle`, `fan.set_percentage`, or `climate.set_temperature`. + +## How the `ha-floorplan` Example Works + +The reference example at `https://experiencelovelace.github.io/ha-floorplan/docs/example-floorplanner-home/` is fundamentally a binding layer between HA entities and SVG elements: + +- The background is an SVG exported from Floorplanner. +- YAML maps HA entities like `light.kitchen` to SVG element ids like `area.kitchen`. +- `tap_action` triggers HA services such as `light.toggle`. +- `state_action` mutates presentation by setting CSS classes or text. +- CSS turns those classes into visual behavior like room glow, fan spin, and temperature labels. + +That means `ha-floorplan` is mostly: + +1. Entity-to-element mapping. +2. Live state subscription. +3. Service-call dispatch on interaction. +4. CSS-driven visual state. + +The Pascal replacement should preserve those four capabilities, but use Pascal's scene graph and render systems instead of SVG + CSS as the visual layer. + +## Relevant Pascal Extension Points + +### 1. Arbitrary node metadata already exists + +`packages/core/src/schema/base.ts` defines `BaseNode.metadata` as JSON. This is the easiest place to attach HA bindings such as: + +```ts +{ + ha: { + entityId: "light.kitchen", + tapAction: { domain: "light", service: "toggle" }, + presentAs: "room-light" + } +} +``` + +The repo already uses metadata for runtime visual state (`navigationMoveVisual`), so HA mapping fits the existing pattern. + +### 2. Item interactivity is already modeled + +`packages/core/src/schema/nodes/item.ts` already supports: + +- interactive controls: `toggle`, `slider`, `temperature` +- interactive effects: `animation`, `light` + +This is a strong fit for HA entities: + +- `light.*` -> toggle + brightness slider + light effect +- `fan.*` -> toggle + spinning animation +- `climate.*` -> temperature control +- `switch.*` -> toggle + +### 3. Runtime control state already exists + +`packages/core/src/store/use-interactive.ts` stores per-item control values at runtime. + +Today those values are local UI state. For HA, the same store can become the view-model that is driven by HA entity state and also pushes service calls back to HA when the user interacts. + +### 4. Viewer systems already render interactive behavior + +The viewer already contains the main pieces needed for visual feedback: + +- `packages/viewer/src/systems/interactive/interactive-system.tsx` + Renders in-scene controls for interactive items. +- `packages/viewer/src/systems/item-light/item-light-system.tsx` + Converts interactive light effects into actual Three.js point lights. +- `packages/viewer/src/components/renderers/item/item-renderer.tsx` + Applies animation and light registrations based on interactive effects. +- `packages/viewer/src/hooks/use-node-events.ts` + Emits click, move, enter, leave, context-menu, and double-click events for nodes. + +This means Pascal already has most of the "visual reaction" side of a HA floorplan replacement. + +### 5. Pascal already has a native 2D floorplan surface + +`packages/editor/src/components/editor/floorplan-panel.tsx` is important. Pascal does not need SVG to offer a floorplan view; it already has a first-party 2D floorplan representation tied to the scene graph. + +That gives us two possible presentation modes: + +- 2D floorplan view backed by Pascal geometry. +- 3D viewer backed by Pascal geometry. + +Both can point at the same HA entity bindings. + +### 6. Packaging matters + +The repo is split in a useful way: + +- `@pascal-app/core` and `@pascal-app/viewer` are reusable packages. +- `@pascal-app/editor` is closer to the product shell and expects a Next/React environment. +- `apps/editor` is the full Next.js app. + +This matters because Home Assistant frontend extensions are web-component oriented, while Pascal is React/Next based. Reusing `core` + `viewer` is easier than trying to inject the full app shell into Lovelace immediately. + +## How HA Relates to Pascal + +The clean mental model is: + +- HA owns device truth. +- Pascal owns spatial truth. +- A mapping layer joins them. + +More concretely: + +- HA entity ids map to Pascal nodes, zones, rooms, or items. +- HA state changes update Pascal runtime state. +- Pascal interactions call HA services. +- Pascal renderers translate HA state into 2D/3D visuals. + +So Pascal is not replacing Home Assistant. Pascal is replacing the rendering/binding approach used by SVG-based floorplans. + +## Chosen Integration Path + +The plan is Solution A only: + +- Pascal runs as its own app. +- Home Assistant runs as the server. +- Pascal connects to HA the same way the official phone app conceptually does: as an authenticated client of the HA instance. + +Transport plan: + +- WebSocket API for live entity updates. +- REST or WebSocket service calls for commands. + +Why this is the active plan: + +- It matches the current React/Next architecture. +- It lets us reuse `@pascal-app/core` and `@pascal-app/viewer` with minimal HA-specific shell code. +- It avoids any dependency on Lovelace or HA frontend packaging. +- It is enough to prove replacement parity with `ha-floorplan`. + +Out of scope for this phase: + +- embedding Pascal as a Home Assistant panel +- embedding Pascal as a Lovelace card + +## Recommended Architecture + +### 1. Add an HA binding schema on top of node metadata + +Start with metadata instead of hard-wiring new node types. + +Suggested shape: + +```ts +type HaBinding = { + entityId: string + tapAction?: + | { type: "toggle" } + | { type: "service"; domain: string; service: string; data?: Record } + presentation?: { + kind: "room-light" | "device-light" | "fan" | "temperature-label" | "occupancy" | "generic" + stateClassMap?: Record + textTemplate?: string + } +} +``` + +Attach it under `node.metadata.ha`. + +### 2. Add an HA client adapter + +Create a small adapter layer that exposes: + +- `connect()` +- `subscribeEntities(entityIds, callback)` +- `callService(domain, service, data)` +- `getStates()` + +This should be isolated from rendering code so we can support: + +- direct browser-to-HA connections +- local-network and remote-internet HA URLs +- a proxy if needed later + +## Remote Access Model + +Remote access is a first-class requirement for Solution A. + +Pascal should support connecting to: + +- a local HA instance such as `http://homeassistant.local:8123` +- a remote HA instance such as `https://ha.example.com` + +Conceptually this should behave like the HA phone app model: + +- the user points Pascal at an HA server URL +- Pascal authenticates as a client of that HA instance +- Pascal keeps a live connection open for entity updates +- Pascal sends commands back to that same HA instance + +Phase 1 should assume: + +- one configurable HA base URL +- one authenticated user session or token +- reconnect and resubscribe behavior when the socket drops +- a clear disconnected/auth-failed state in the UI + +### 3. Add a sync system between HA state and Pascal runtime state + +The sync layer should: + +- pull initial HA state +- subscribe to updates +- write derived values into `useInteractive` +- optionally mark scene nodes dirty when visuals depend on HA state + +Example mappings: + +- `light.kitchen.state === "on"` -> interactive toggle `true` +- `light.kitchen.attributes.brightness` -> slider value +- `fan.office.state === "on"` -> animation active +- `sensor.livingroom_temperature.state` -> text label content + +### 4. Use Pascal presentation instead of SVG CSS tricks + +Replace SVG/CSS behaviors with Pascal-native rendering: + +- room lighting -> zone overlay opacity/material/emissive changes +- fans -> GLTF animation or transform animation +- sensor labels -> 2D floorplan labels or 3D HTML overlays +- device active state -> item glow, icon swap, light effect, or material change + +### 5. Keep 2D and 3D on the same binding model + +Do not build separate HA bindings for floorplan and 3D. + +The binding should target scene nodes, and both: + +- `floorplan-panel.tsx` +- the 3D viewer + +should read from the same mapped runtime state. + +## Discovery Notes + +For the "real smart device" picker, the correct model is not "scan arbitrary Wi-Fi clients and guess." Home Assistant discovery is integration-driven. + +- For finding the HA server itself, the native-app model is zeroconf discovery of `_home-assistant._tcp.local`, with manual URL entry as the fallback when zeroconf is not available. +- For finding local devices, Home Assistant integrations prefer discovery protocols such as zeroconf/mDNS and SSDP/UPnP, and may also use domain-specific mechanisms like HomeKit, DHCP, Bluetooth, USB, or Matter. +- For Pascal, that means the best practical picker is: + 1. Try HA-style LAN discovery signals first, especially mDNS/zeroconf and SSDP for local smart devices. + 2. Fall back to the connected HA instance's known entities/devices, because HA is already the normalization layer the user trusts. + +This is the approach now used in the editor spike: LAN discovery first, then HA-managed entity discovery as the authoritative fallback. In the current setup, the Chromecast device is surfaced through the connected HA entity when raw LAN broadcast discovery on the laptop is incomplete. + +## First Feature Slice + +The smallest useful parity slice with `ha-floorplan` is: + +1. Bind one `light.*` entity to one Pascal room/zone. +2. Click the room in 2D and 3D to call `light.toggle`. +3. Subscribe to HA state and change zone overlay/emissive intensity when the light turns on/off. +4. Bind one `sensor.*` entity to a label in the floorplan. +5. Bind one `fan.*` entity to an item animation. + +If those three bindings work, Pascal has already replaced the core value of the SVG floorplan example. + +## Practical Recommendation + +Build the first spike as a Pascal-hosted app that talks directly to a user-selected HA server, with remote access supported from the start. + +Reason: + +- fastest path to a working prototype +- least conflict with the current React/Next architecture +- best reuse of `@pascal-app/core` and `@pascal-app/viewer` +- same basic client/server model users already understand from the HA phone app + +## Immediate Next Steps + +1. Add a small HA binding type and metadata helpers in `@pascal-app/core`. +2. Create a new HA adapter package or app-local module using `home-assistant-js-websocket`. +3. Implement a `useHomeAssistant` store for connection state, auth state, entity cache, and service calls. +4. Add connection settings for HA base URL plus credential/session input. +5. Implement reconnect and resubscribe behavior for dropped sockets. +6. Drive one zone and one item from real HA state. +7. Expose binding controls in the editor so nodes can be assigned entity ids. + +## Source Notes + +- HA floorplan reference: + `https://experiencelovelace.github.io/ha-floorplan/docs/example-floorplanner-home/` +- HA WebSocket API: + `https://developers.home-assistant.io/docs/api/websocket/` +- HA REST API: + `https://developers.home-assistant.io/docs/api/rest/` +- HA frontend architecture: + `https://developers.home-assistant.io/docs/frontend/architecture/` +- HA custom cards: + `https://developers.home-assistant.io/docs/frontend/custom-ui/custom-card/` +- HA JS WebSocket client: + `https://github.com/home-assistant/home-assistant-js-websocket` +- HA native app connection setup: + `https://developers.home-assistant.io/docs/api/native-app-integration/setup/` +- HA networking and discovery: + `https://developers.home-assistant.io/docs/network_discovery/` +- HA discovery manifest guidance: + `https://developers.home-assistant.io/docs/creating_integration_manifest` diff --git a/packages/core/package.json b/packages/core/package.json index 05551fe47..b5f5ebfab 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "import": "./dist/schema/index.js", + "default": "./dist/schema/index.js" + }, "./clone-scene-graph": { "types": "./dist/utils/clone-scene-graph.d.ts", "import": "./dist/utils/clone-scene-graph.js", diff --git a/packages/core/src/schema/collections.ts b/packages/core/src/schema/collections.ts index 32fb01c8b..1b4f465d6 100644 --- a/packages/core/src/schema/collections.ts +++ b/packages/core/src/schema/collections.ts @@ -2,13 +2,233 @@ import { generateId } from './base' import type { AnyNodeId } from './types' export type CollectionId = `collection_${string}` +export type CollectionKind = 'automation' | 'device' | 'group' +export type CollectionCapability = + | 'brightness' + | 'media' + | 'power' + | 'speed' + | 'temperature' + | 'trigger' + | 'volume' +export type CollectionZoneId = `zone_${string}` +export type HomeAssistantResourceKind = 'automation' | 'entity' | 'scene' | 'script' +export type CollectionHomeAssistantAggregation = + | 'all' + | 'any_on' + | 'primary' + | 'single' + | 'trigger_only' + +export type CollectionHomeAssistantActionField = { + defaultValue?: unknown + key: string + label: string + required: boolean + selector?: Record | null +} + +export type CollectionHomeAssistantAction = { + capability: CollectionCapability + domain: string + fields?: CollectionHomeAssistantActionField[] + key: string + label: string + service: string +} + +export type CollectionHomeAssistantResourceBinding = { + actions: CollectionHomeAssistantAction[] + capabilities: CollectionCapability[] + defaultActionKey?: string | null + entityId?: string | null + id: string + kind: HomeAssistantResourceKind + label: string +} + +export type CollectionHomeAssistantBinding = { + aggregation: CollectionHomeAssistantAggregation + primaryResourceId?: string | null + resources: CollectionHomeAssistantResourceBinding[] +} + +export type CollectionHomeAssistantActionRequest = + | { + capability: Extract + kind: 'range' + value: number + } + | { + kind: 'toggle' + value: boolean + } + | { + kind: 'trigger' + } + +export type CollectionPresentation = { + icon?: string + label?: string + rtsOrder?: number +} export type Collection = { + capabilities?: CollectionCapability[] id: CollectionId + kind?: CollectionKind name: string color?: string nodeIds: AnyNodeId[] controlNodeId?: AnyNodeId + homeAssistant?: CollectionHomeAssistantBinding + presentation?: CollectionPresentation + zoneIds?: CollectionZoneId[] } export const generateCollectionId = (): CollectionId => generateId('collection') + +const COLLECTION_KIND_ORDER: CollectionKind[] = ['device', 'group', 'automation'] + +const dedupeStringArray = (values: T[] | undefined) => + Array.from(new Set((values ?? []).filter((value): value is T => typeof value === 'string'))) + +const normalizeAction = ( + action: CollectionHomeAssistantAction, +): CollectionHomeAssistantAction | null => { + if (!(action && typeof action === 'object')) { + return null + } + + if ( + typeof action.key !== 'string' || + typeof action.label !== 'string' || + typeof action.domain !== 'string' || + typeof action.service !== 'string' || + typeof action.capability !== 'string' + ) { + return null + } + + return { + capability: action.capability, + domain: action.domain, + fields: Array.isArray(action.fields) + ? action.fields + .filter( + (field): field is CollectionHomeAssistantActionField => + Boolean( + field && + typeof field === 'object' && + typeof field.key === 'string' && + typeof field.label === 'string' && + typeof field.required === 'boolean', + ), + ) + .map((field) => ({ + defaultValue: field.defaultValue, + key: field.key, + label: field.label, + required: field.required, + selector: + field.selector && typeof field.selector === 'object' && !Array.isArray(field.selector) + ? field.selector + : null, + })) + : [], + key: action.key, + label: action.label, + service: action.service, + } +} + +const normalizeResource = ( + resource: CollectionHomeAssistantResourceBinding, +): CollectionHomeAssistantResourceBinding | null => { + if (!(resource && typeof resource === 'object')) { + return null + } + + if ( + typeof resource.id !== 'string' || + typeof resource.kind !== 'string' || + typeof resource.label !== 'string' + ) { + return null + } + + return { + actions: Array.isArray(resource.actions) + ? resource.actions + .map((action) => normalizeAction(action)) + .filter((action): action is CollectionHomeAssistantAction => Boolean(action)) + : [], + capabilities: dedupeStringArray(resource.capabilities), + defaultActionKey: + typeof resource.defaultActionKey === 'string' ? resource.defaultActionKey : null, + entityId: typeof resource.entityId === 'string' ? resource.entityId : null, + id: resource.id, + kind: resource.kind, + label: resource.label, + } +} + +export const normalizeCollection = (collection: Collection): Collection => { + const normalizedResources = Array.isArray(collection.homeAssistant?.resources) + ? collection.homeAssistant.resources + .map((resource) => normalizeResource(resource)) + .filter((resource): resource is CollectionHomeAssistantResourceBinding => Boolean(resource)) + : [] + const capabilities = dedupeStringArray([ + ...(collection.capabilities ?? []), + ...normalizedResources.flatMap((resource) => resource.capabilities), + ]) + const kind = + collection.kind && COLLECTION_KIND_ORDER.includes(collection.kind) + ? collection.kind + : normalizedResources.some((resource) => resource.kind !== 'entity') + ? 'automation' + : collection.nodeIds.length > 1 + ? 'group' + : 'device' + + return { + ...collection, + capabilities, + controlNodeId: + typeof collection.controlNodeId === 'string' && collection.nodeIds.includes(collection.controlNodeId) + ? collection.controlNodeId + : collection.nodeIds[0], + homeAssistant: + normalizedResources.length > 0 + ? { + aggregation: + collection.homeAssistant?.aggregation ?? (kind === 'automation' ? 'trigger_only' : 'single'), + primaryResourceId: + typeof collection.homeAssistant?.primaryResourceId === 'string' + ? collection.homeAssistant.primaryResourceId + : normalizedResources[0]?.id ?? null, + resources: normalizedResources, + } + : undefined, + kind, + presentation: + collection.presentation && + typeof collection.presentation === 'object' && + !Array.isArray(collection.presentation) + ? { + icon: + typeof collection.presentation.icon === 'string' ? collection.presentation.icon : undefined, + label: + typeof collection.presentation.label === 'string' + ? collection.presentation.label + : undefined, + rtsOrder: + typeof collection.presentation.rtsOrder === 'number' + ? collection.presentation.rtsOrder + : undefined, + } + : undefined, + zoneIds: dedupeStringArray(collection.zoneIds), + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1383216df..3a44fd5a9 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -3,7 +3,23 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' // Collections -export { type Collection, type CollectionId, generateCollectionId } from './collections' +export { + type Collection, + type CollectionCapability, + type CollectionHomeAssistantAction, + type CollectionHomeAssistantActionField, + type CollectionHomeAssistantActionRequest, + type CollectionHomeAssistantAggregation, + type CollectionHomeAssistantBinding, + type CollectionHomeAssistantResourceBinding, + type CollectionId, + type CollectionKind, + type CollectionPresentation, + type CollectionZoneId, + type HomeAssistantResourceKind, + generateCollectionId, + normalizeCollection, +} from './collections' export type { MaterialMapProperties, MaterialMaps, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 28441f69a..522ea05f5 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -5,7 +5,7 @@ import { temporal } from 'zundo' import { create, type StoreApi, type UseBoundStore } from 'zustand' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' -import { generateCollectionId } from '../schema/collections' +import { generateCollectionId, normalizeCollection } from '../schema/collections' import { LevelNode } from '../schema/nodes/level' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' @@ -368,7 +368,11 @@ export type SceneState = { loadScene: () => void clearScene: () => void unloadScene: () => void - setScene: (nodes: Record, rootNodeIds: AnyNodeId[]) => void + setScene: ( + nodes: Record, + rootNodeIds: AnyNodeId[], + collections?: Record, + ) => void markDirty: (id: AnyNodeId) => void clearDirty: (id: AnyNodeId) => void @@ -396,6 +400,50 @@ type UseSceneStore = UseBoundStore> & { temporal: StoreApi>> } +function normalizeCollectionsRecord( + collections: Record | undefined, + nodes: Record, +) { + const normalizedCollections = Object.fromEntries( + Object.entries(collections ?? {}).flatMap(([id, collection]) => { + const normalizedNodeIds = Array.from( + new Set(collection.nodeIds.filter((nodeId) => Boolean(nodes[nodeId]))), + ) as AnyNodeId[] + + if (normalizedNodeIds.length === 0) { + return [] + } + + return [[id as CollectionId, normalizeCollection({ ...collection, nodeIds: normalizedNodeIds })]] + }), + ) as Record + + if (Object.keys(normalizedCollections).length === 0) { + return normalizedCollections + } + + for (const [collectionId, collection] of Object.entries(normalizedCollections)) { + for (const nodeId of collection.nodeIds) { + const node = nodes[nodeId] + if (!(node && node.type === 'item')) { + continue + } + + const existingIds = Array.isArray(node.collectionIds) ? node.collectionIds : [] + if (existingIds.includes(collectionId as CollectionId)) { + continue + } + + nodes[nodeId] = { + ...node, + collectionIds: [...existingIds, collectionId as CollectionId], + } as AnyNode + } + } + + return normalizedCollections +} + const useScene: UseSceneStore = create()( temporal( (set, get) => ({ @@ -429,7 +477,7 @@ const useScene: UseSceneStore = create()( get().loadScene() // Default scene }, - setScene: (nodes, rootNodeIds) => { + setScene: (nodes, rootNodeIds, collections) => { // Apply backward compatibility migrations const patchedNodes = migrateNodes(nodes) @@ -448,11 +496,13 @@ const useScene: UseSceneStore = create()( } } + const normalizedCollections = normalizeCollectionsRecord(collections, cleanedNodes) + set({ nodes: cleanedNodes, rootNodeIds, dirtyNodes: new Set(), - collections: {}, + collections: normalizedCollections, }) // Mark all nodes as dirty to trigger re-validation Object.values(cleanedNodes).forEach((node) => { @@ -521,7 +571,7 @@ const useScene: UseSceneStore = create()( createCollection: (name, nodeIds = []) => { if (get().readOnly) return '' as CollectionId const id = generateCollectionId() - const collection: Collection = { id, name, nodeIds } + const collection = normalizeCollection({ id, name, nodeIds }) set((state) => { const nextCollections = { ...state.collections, [id]: collection } // Denormalize: stamp collectionId onto each node @@ -563,7 +613,12 @@ const useScene: UseSceneStore = create()( set((state) => { const col = state.collections[id] if (!col) return state - return { collections: { ...state.collections, [id]: { ...col, ...data } } } + return { + collections: { + ...state.collections, + [id]: normalizeCollection({ ...col, ...data }), + }, + } }) }, @@ -574,7 +629,7 @@ const useScene: UseSceneStore = create()( if (!col || col.nodeIds.includes(nodeId)) return state const nextCollections = { ...state.collections, - [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] }, + [id]: normalizeCollection({ ...col, nodeIds: [...col.nodeIds, nodeId] }), } const node = state.nodes[nodeId] if (!node) return { collections: nextCollections } @@ -595,7 +650,7 @@ const useScene: UseSceneStore = create()( if (!col) return state const nextCollections = { ...state.collections, - [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }, + [id]: normalizeCollection({ ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }), } const node = state.nodes[nodeId] if (!(node && 'collectionIds' in node)) return { collections: nextCollections } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a3ab3da86..71e9fc23e 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -23,8 +23,10 @@ import { useFrame } from '@react-three/fiber' import { Move } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { getHomeAssistantLink } from '../../lib/home-assistant' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { HomeAssistantConnectivityPanel } from './home-assistant-connectivity-panel' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -56,6 +58,8 @@ export function FloatingActionMenu() { const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint) const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingFence = useEditor((s) => s.setCurvingFence) + const homeAssistantControlItemId = useEditor((s) => s.homeAssistantControlItemId) + const setHomeAssistantControlItemId = useEditor((s) => s.setHomeAssistantControlItemId) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) @@ -424,6 +428,29 @@ export function FloatingActionMenu() { }, [node?.type, selectedId, setSelection], ) + const handleExtraAction = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (node?.type !== 'item') { + return + } + + const link = getHomeAssistantLink(node.metadata) + if (!link?.haEntityId) { + return + } + + setHomeAssistantControlItemId( + homeAssistantControlItemId === node.id ? null : String(node.id), + ) + }, + [homeAssistantControlItemId, node, setHomeAssistantControlItemId], + ) + const linkedHomeAssistantItem = + node?.type === 'item' ? { item: node, link: getHomeAssistantLink(node.metadata) } : null + const isHomeAssistantControlOpen = + Boolean(linkedHomeAssistantItem?.link?.haEntityId) && + homeAssistantControlItemId === linkedHomeAssistantItem?.item.id if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || @@ -444,23 +471,34 @@ export function FloatingActionMenu() { }} zIndexRange={[100, 0]} > - e.stopPropagation()} - onPointerUp={(e) => e.stopPropagation()} - /> + {isHomeAssistantControlOpen && linkedHomeAssistantItem?.link ? ( + setHomeAssistantControlItemId(null)} + /> + ) : ( + e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + /> + )} {(node?.type === 'wall' || node?.type === 'fence') && ( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e33d856ae..e6740f0f8 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -42,6 +42,7 @@ import { memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -50,6 +51,7 @@ import { } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { getHomeAssistantLink } from '../../lib/home-assistant' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' @@ -76,6 +78,7 @@ import { tools as structureTools } from '../ui/action-menu/structure-tools' import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' +import { HomeAssistantConnectivityPanel } from './home-assistant-connectivity-panel' import { NodeActionMenu } from './node-action-menu' const FALLBACK_VIEW_SIZE = 12 @@ -4756,6 +4759,10 @@ type FloorplanActionMenuEntry = { onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler + onExtraAction?: FloorplanActionMenuHandler + extraActionIcon?: 'connectivity' + extraActionLabel?: string + customContent?: ReactNode } type FloorplanActionMenuLayerProps = { @@ -4799,13 +4806,18 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`, }} > - event.stopPropagation()} - onPointerUp={(event) => event.stopPropagation()} - /> + {entry.customContent ?? ( + event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> + )} ) : null, )} @@ -5604,6 +5616,8 @@ export function FloorplanPanel() { return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null }, [floorplanItemEntries, selectedIds]) + const homeAssistantControlItemId = useEditor((state) => state.homeAssistantControlItemId) + const setHomeAssistantControlItemId = useEditor((state) => state.setHomeAssistantControlItemId) const selectedWallEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -6214,6 +6228,12 @@ export function FloorplanPanel() { : null, [selectedItemEntry, surfaceSize, viewBox], ) + const selectedItemLink = selectedItemEntry + ? getHomeAssistantLink(selectedItemEntry.item.metadata) + : null + const isSelectedItemHomeAssistantControlOpen = + Boolean(selectedItemEntry?.item && selectedItemLink?.haEntityId) && + homeAssistantControlItemId === selectedItemEntry?.item.id const selectedSlabActionMenuPosition = useMemo( () => selectedSlabEntry @@ -8696,6 +8716,24 @@ export function FloorplanPanel() { }, [deleteNode, selectedItemEntry, setSelection], ) + const handleSelectedItemConnect = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const item = selectedItemEntry?.item + if (!item) { + return + } + + const link = getHomeAssistantLink(item.metadata) + if (!link?.haEntityId) { + return + } + + setHomeAssistantControlItemId(homeAssistantControlItemId === item.id ? null : String(item.id)) + }, + [homeAssistantControlItemId, selectedItemEntry, setHomeAssistantControlItemId], + ) const handleSelectedWallMove = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -9861,7 +9899,18 @@ export function FloorplanPanel() { position: selectedItemActionMenuPosition, onDelete: handleSelectedItemDelete, onDuplicate: handleSelectedItemDuplicate, + onExtraAction: selectedItemLink?.haEntityId ? handleSelectedItemConnect : undefined, onMove: handleSelectedItemMove, + extraActionIcon: selectedItemLink?.haEntityId ? 'connectivity' : undefined, + extraActionLabel: selectedItemLink?.haEntityId ? 'Home Assistant' : undefined, + customContent: + isSelectedItemHomeAssistantControlOpen && selectedItemEntry && selectedItemLink ? ( + setHomeAssistantControlItemId(null)} + /> + ) : undefined, }} opening={{ position: selectedOpeningActionMenuPosition, diff --git a/packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx b/packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx new file mode 100644 index 000000000..d6586e8d2 --- /dev/null +++ b/packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx @@ -0,0 +1,757 @@ +'use client' + +import type { ItemNode } from '@pascal-app/core' +import { + ArrowLeft, + Check, + LoaderCircle, + Power, + RefreshCw, + Tv, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import type { + HomeAssistantAvailableAction, + HomeAssistantAvailableActionField, + HomeAssistantCapabilityCategory, + HomeAssistantDiscoveredDevice, + HomeAssistantLink, +} from '../../lib/home-assistant' +import { + getHomeAssistantAvailableActionPresentation, + getHomeAssistantCapabilityCategory, + toHomeAssistantLink, +} from '../../lib/home-assistant' +import { + buildHomeAssistantActionServiceData, + canRunHomeAssistantActionImmediately, + getHomeAssistantActionFieldOptions, + getHomeAssistantActionInitialFieldValue, + getHomeAssistantRenderableFields, + type HomeAssistantFieldOption, + normalizeHomeAssistantDiscoveredDevice, +} from '../../lib/home-assistant-controls' +import { cn } from '../../lib/utils' +import { HomeAssistantActionIconView } from '../ui/home-assistant-action-icon' + +type DeviceLoadState = 'idle' | 'loading' | 'ready' | 'error' + +type HomeAssistantActionResponse = { + actionKind: string + availableAfterAction: boolean + deviceName: string + finalState: string + initialFriendlyName: string | null + initialState: string + itemName: string + message: string + observedAppNames: string[] + success: boolean + timeline: Array<{ + appName: string | null + mediaTitle: string | null + second: number + state: string + }> +} + +const CATEGORY_ORDER: HomeAssistantCapabilityCategory[] = [ + 'power', + 'playback', + 'audio', + 'access', + 'other', +] + +function getCategoryMeta(category: HomeAssistantCapabilityCategory) { + switch (category) { + case 'power': + return { icon: , label: 'Power' } + case 'playback': + return { icon: , label: 'Playback' } + case 'audio': + return { icon: , label: 'Audio' } + case 'access': + return { icon: , label: 'Access' } + case 'other': + default: + return { icon: , label: 'Other' } + } +} + +function buildFallbackDevice(link: HomeAssistantLink): HomeAssistantDiscoveredDevice { + const actionKey = `${link.serviceDomain}.${link.serviceName}` + return { + actionable: Boolean(link.haEntityId), + attributes: null, + availableActions: [ + { + actionKind: link.actionKind, + description: `${link.serviceDomain}.${link.serviceName}`, + domain: link.serviceDomain, + fields: [], + key: actionKey, + label: link.actionLabel, + service: link.serviceName, + }, + ], + defaultActionKey: actionKey, + defaultServiceData: link.serviceData, + description: link.description, + deviceType: link.deviceType, + enabledActionCategories: link.enabledActionCategories, + haEntityId: link.haEntityId, + id: link.deviceId, + ip: link.ip, + manufacturer: link.manufacturer, + model: link.model, + name: link.deviceName, + protocol: link.protocol, + serviceType: link.serviceType, + supportedFeatures: null, + } +} + +function getDeviceKey(device: Pick) { + return device.haEntityId ?? device.id +} + +function getLinkKey(link: Pick) { + return link.haEntityId ?? link.deviceId +} + +type HomeAssistantConnectivityPanelProps = { + item: ItemNode + link: HomeAssistantLink + onClose: () => void +} + +export function HomeAssistantConnectivityPanel({ + item, + link, + onClose, +}: HomeAssistantConnectivityPanelProps) { + const [loadState, setLoadState] = useState('idle') + const [reloadToken, setReloadToken] = useState(0) + const [loadError, setLoadError] = useState('') + const [device, setDevice] = useState(null) + const [selectedCategory, setSelectedCategory] = + useState(null) + const [selectedActionKey, setSelectedActionKey] = useState(null) + const [fieldValues, setFieldValues] = useState>({}) + const [isActionRunning, setIsActionRunning] = useState(false) + const [actionError, setActionError] = useState('') + const [actionResult, setActionResult] = useState(null) + const [statusMessage, setStatusMessage] = useState('Choose a connected feature to run.') + + const enabledCategories = useMemo( + () => + CATEGORY_ORDER.filter((category) => + (link.enabledActionCategories.length > 0 + ? link.enabledActionCategories + : CATEGORY_ORDER + ).includes(category), + ), + [link.enabledActionCategories], + ) + + const visibleActions = useMemo(() => { + if (!device || !selectedCategory) { + return [] as HomeAssistantAvailableAction[] + } + + return device.availableActions.filter( + (action) => + enabledCategories.includes(getHomeAssistantCapabilityCategory(action.actionKind)) && + getHomeAssistantCapabilityCategory(action.actionKind) === selectedCategory, + ) + }, [device, enabledCategories, selectedCategory]) + + const selectedAction = useMemo( + () => visibleActions.find((action) => action.key === selectedActionKey) ?? null, + [selectedActionKey, visibleActions], + ) + + useEffect(() => { + let cancelled = false + + async function loadDevice() { + setLoadState('loading') + setLoadError('') + + try { + const response = await fetch('/api/home-assistant/discover-devices', { + cache: 'no-store', + }) + const payload = (await response.json()) as { + devices?: HomeAssistantDiscoveredDevice[] + error?: string + } + + if (!response.ok) { + throw new Error(payload.error || 'Failed to load Home Assistant device features.') + } + + const devices = Array.isArray(payload.devices) ? payload.devices : [] + const matchedDevice = + devices.find((candidate) => getDeviceKey(candidate) === getLinkKey(link)) ?? null + const nextDevice = normalizeHomeAssistantDiscoveredDevice( + matchedDevice !== null + ? { + ...matchedDevice, + enabledActionCategories: link.enabledActionCategories, + } + : buildFallbackDevice(link), + ) + + if (cancelled) { + return + } + + const nextCategories = CATEGORY_ORDER.filter((category) => + nextDevice.availableActions.some( + (action) => getHomeAssistantCapabilityCategory(action.actionKind) === category, + ) && enabledCategories.includes(category), + ) + const defaultCategory = nextCategories[0] ?? null + + setDevice(nextDevice) + setSelectedCategory(defaultCategory) + setSelectedActionKey(null) + setFieldValues({}) + setStatusMessage( + nextCategories.length > 0 + ? `Choose a ${nextDevice.name} feature to run from ${item.asset.name}.` + : 'No enabled Home Assistant features are available for this item.', + ) + setLoadState('ready') + } catch (error) { + if (cancelled) { + return + } + + const message = + error instanceof Error ? error.message : 'Failed to load Home Assistant device features.' + setDevice(normalizeHomeAssistantDiscoveredDevice(buildFallbackDevice(link))) + setLoadError(message) + setLoadState('error') + } + } + + void loadDevice() + + return () => { + cancelled = true + } + }, [enabledCategories, item.asset.name, link, reloadToken]) + + useEffect(() => { + if (!device || !selectedAction) { + return + } + + setFieldValues((currentValues) => + getHomeAssistantRenderableFields(selectedAction, device).reduce>((values, field) => { + values[field.key] = + currentValues[field.key] ?? + getHomeAssistantActionInitialFieldValue(selectedAction, field, device, link.serviceData) + return values + }, {}), + ) + }, [device, link, selectedAction]) + + function setFieldValue(fieldKey: string, value: unknown) { + setFieldValues((currentValues) => ({ + ...currentValues, + [fieldKey]: value, + })) + setActionError('') + setActionResult(null) + } + + async function runAction( + actionToRun: HomeAssistantAvailableAction | null = selectedAction, + overrideValues?: Record, + ) { + if (!device || !actionToRun) { + return + } + + try { + const serviceData = buildHomeAssistantActionServiceData( + actionToRun, + device, + overrideValues ?? fieldValues, + ) + const actionLink = toHomeAssistantLink( + device, + actionToRun, + serviceData, + enabledCategories, + link.linkedAt, + ) + + setIsActionRunning(true) + setActionError('') + setActionResult(null) + setStatusMessage(`Running ${actionToRun.label} on ${device.name}...`) + + const response = await fetch('/api/home-assistant/device-action', { + body: JSON.stringify({ + itemName: item.asset.name, + link: actionLink, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + const payload = (await response.json()) as HomeAssistantActionResponse & { error?: string } + + if (!response.ok) { + throw new Error(payload.error || 'The Home Assistant action failed.') + } + + setActionResult(payload) + setStatusMessage(payload.message) + } catch (error) { + const message = + error instanceof Error ? error.message : 'The Home Assistant action did not complete.' + setActionError(message) + setStatusMessage('The Home Assistant action did not complete.') + } finally { + setIsActionRunning(false) + } + } + + async function handleActionClick(action: HomeAssistantAvailableAction) { + if (isActionRunning) { + return + } + + const nextFieldValues = getHomeAssistantRenderableFields(action, device ?? buildFallbackDevice(link)).reduce< + Record + >((values, field) => { + values[field.key] = + fieldValues[field.key] ?? + getHomeAssistantActionInitialFieldValue( + action, + field, + device ?? buildFallbackDevice(link), + link.serviceData, + ) + return values + }, {}) + + setSelectedActionKey(action.key) + setFieldValues(nextFieldValues) + setActionError('') + setActionResult(null) + + if (canRunHomeAssistantActionImmediately(action, device ?? buildFallbackDevice(link), nextFieldValues)) { + await runAction(action, nextFieldValues) + return + } + + setStatusMessage(`Choose a ${action.label.toLowerCase()} option.`) + } + + function optionValueKey(value: unknown) { + return typeof value === 'string' ? value : JSON.stringify(value) + } + + async function runSelectedActionIfReady(nextValues: Record) { + if (!device || !selectedAction) { + return + } + + try { + buildHomeAssistantActionServiceData(selectedAction, device, nextValues) + await runAction(selectedAction, nextValues) + } catch { + setStatusMessage(`Choose a ${selectedAction.label.toLowerCase()} option.`) + } + } + + function renderField(field: HomeAssistantAvailableActionField) { + if (!device || !selectedAction) { + return null + } + + const selectorKey = + field.selector && Object.keys(field.selector).length > 0 + ? Object.keys(field.selector)[0] + : null + const value = fieldValues[field.key] + const options = getHomeAssistantActionFieldOptions(selectedAction, field, device) + const fieldLabel = `${field.label}${field.required ? ' *' : ''}` + const baseCardClass = 'rounded-2xl border border-white/10 bg-black/24 px-3 py-3' + + if ( + selectorKey === 'boolean' || + selectorKey === 'select' || + selectorKey === 'state' || + selectorKey === 'color_rgb' || + selectorKey === 'color_temp' || + selectorKey === 'media' || + selectorKey === 'constant' + ) { + return ( +
+
+ {fieldLabel} + + One Tap + +
+
+ {options.map((option: HomeAssistantFieldOption) => { + const isSelected = optionValueKey(value) === optionValueKey(option.value) + const isColor = selectorKey === 'color_rgb' + const colorValue = + isColor && Array.isArray(option.value) + ? `rgb(${option.value[0]}, ${option.value[1]}, ${option.value[2]})` + : null + + return ( + + ) + })} +
+
+ ) + } + + if (selectorKey === 'number') { + const numberSelector = + field.selector?.number && typeof field.selector.number === 'object' ? field.selector.number : null + const min = numberSelector && 'min' in numberSelector ? Number(numberSelector.min) : 0 + const max = numberSelector && 'max' in numberSelector ? Number(numberSelector.max) : 100 + const step = numberSelector && 'step' in numberSelector ? Number(numberSelector.step) : 1 + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number.parseFloat(value) + : min + + return ( +
+
+ {fieldLabel} + + {Number.isFinite(numericValue) + ? min === 0 && max === 1 + ? `${Math.round(numericValue * 100)}%` + : numericValue + : min} + +
+ {options.length > 0 && ( +
+ {options.map((option: HomeAssistantFieldOption) => ( + + ))} +
+ )} + setFieldValue(field.key, Number(event.target.value))} + onPointerUp={(event) => { + const nextValue = Number((event.target as HTMLInputElement).value) + const nextValues = { + ...fieldValues, + [field.key]: nextValue, + } + setFieldValue(field.key, nextValue) + void runSelectedActionIfReady(nextValues) + }} + step={String(step)} + type="range" + value={Number.isFinite(numericValue) ? numericValue : min} + /> +
+ ) + } + + if (selectorKey === 'date' || selectorKey === 'time' || selectorKey === 'datetime') { + const inputType = selectorKey === 'datetime' ? 'datetime-local' : selectorKey + return ( + + ) + } + + return null + } + + return ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > +
+ + +
+

{item.name || item.asset.name}

+

{device?.name ?? link.deviceName}

+
+ + + + +
+ +
+
+
+

+ Connected features +

+

+ {device?.name ?? link.deviceName} +

+
+ + +
+ + {enabledCategories.length > 0 && ( +
+ {enabledCategories.map((category) => { + const meta = getCategoryMeta(category) + const isSelected = selectedCategory === category + + return ( + + ) + })} +
+ )} + +
+ {loadState === 'loading' && ( +
+ + Loading device features... +
+ )} + + {loadState === 'error' && ( +
+ {loadError || 'Failed to load Home Assistant features.'} +
+ )} + + {loadState === 'ready' && visibleActions.length === 0 && ( +
+ No enabled actions are available in this category. +
+ )} + + {loadState === 'ready' && visibleActions.length > 0 && ( +
+ {visibleActions.map((action) => { + const isSelected = selectedAction?.key === action.key + const renderableFieldCount = device + ? getHomeAssistantRenderableFields(action, device).length + : 0 + const actionPresentation = getHomeAssistantAvailableActionPresentation(action) + + return ( + + ) + })} +
+ )} +
+ + {selectedAction && + device && + getHomeAssistantRenderableFields(selectedAction, device).length > 0 && ( +
+ {getHomeAssistantRenderableFields(selectedAction, device).map((field) => renderField(field))} +
+ )} +
+ +
+ {statusMessage} +
+ + {actionError && ( +
+ {actionError} +
+ )} + + {actionResult && ( +
+
+ {actionResult.deviceName} + + {actionResult.initialState} to {actionResult.finalState} + +
+ {actionResult.observedAppNames.length > 0 && ( +

+ Observed receiver apps: {actionResult.observedAppNames.join(', ')} +

+ )} +
+ )} +
+ ) +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 4dbbda1a8..a838c4b2e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -26,7 +26,6 @@ import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system' -import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' import { BoxSelectTool } from '../tools/select/box-select-tool' import { ToolManager } from '../tools/tool-manager' @@ -35,6 +34,7 @@ import { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-pa import { EditorCommands } from '../ui/command-palette/editor-commands' import { FloatingLevelSelector } from '../ui/floating-level-selector' import { HelperManager } from '../ui/helpers/helper-manager' +import { HomeAssistantPanel } from '../ui/panels/home-assistant-panel' import { PanelManager } from '../ui/panels/panel-manager' import { ErrorBoundary } from '../ui/primitives/error-boundary' import { useSidebarStore } from '../ui/primitives/sidebar' @@ -536,7 +536,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isFirstPersonMode && } - {isFirstPersonMode && } + ) }) @@ -701,7 +701,6 @@ const ViewerCanvas = memo(function ViewerCanvas({ - {!(isLoading || isVersionPreviewMode) && } ) }) @@ -898,6 +897,11 @@ export default function Editor({ )} + {!isVersionPreviewMode && ( +
+ +
+ )}
@@ -970,6 +974,9 @@ export default function Editor({
+
+ +
diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 95b86dd17..3e92abdfb 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -3,11 +3,16 @@ import { Icon } from '@iconify/react' import { Copy, Move, Spline, Trash2 } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' +import type { HomeAssistantActionIcon } from '../../lib/home-assistant' +import { HomeAssistantActionIconView } from '../ui/home-assistant-action-icon' type NodeActionMenuProps = { + extraActionIcon?: HomeAssistantActionIcon | 'connectivity' + extraActionLabel?: string onAddHole?: MouseEventHandler onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler + onExtraAction?: MouseEventHandler onMove?: MouseEventHandler onCurve?: MouseEventHandler onPointerDown?: PointerEventHandler @@ -17,9 +22,12 @@ type NodeActionMenuProps = { } export function NodeActionMenu({ + extraActionIcon, + extraActionLabel, onAddHole, onDelete, onDuplicate, + onExtraAction, onMove, onCurve, onPointerDown, @@ -68,6 +76,18 @@ export function NodeActionMenu({ )} + {onExtraAction && extraActionLabel && ( + + )} {onAddHole && ( + + {open && ( +
+
+
+

+ Home Assistant +

+

+ Connect, Import, Bind +

+
+ +
+ +
+
+
+
+

Connection

+

+ {connectionState?.linked ? connectionState.message : 'Not linked yet'} +

+
+ +
+ + {!connectionState?.linked && ( +
+ setInstanceUrlInput(event.target.value)} + placeholder="http://localhost:8123" + value={instanceUrlInput} + /> + setExternalUrlInput(event.target.value)} + placeholder="https://your-ha.example.com (optional)" + value={externalUrlInput} + /> + +
+ )} + + {connectionState?.linked && ( +
+ + {connectionState.entityCount} entities visible from HA +
+ )} +
+ +
+
+
+

Pascal Target

+

{collectionLabel}

+
+ + {activeCollection + ? activeCollection.kind ?? 'device' + : selectedItems.length > 0 + ? 'pending' + : 'none'} + +
+ +

+ {selectedItems.length > 0 + ? activeCollection + ? 'Selected items already resolve to a Pascal collection. Imports bind to that collection.' + : 'Select imports below to create a Pascal collection from the current item selection.' + : 'Select one or more virtual items in Pascal to create or target a collection.'} +

+ + {boundResources.length > 0 && ( +
+ {boundResources.map((resource) => ( +
+
+

{resource.label}

+

+ {resource.kind} + {resource.entityId ? ` | ${resource.entityId}` : ''} +

+
+ +
+ ))} +
+ )} +
+ +
+
+
+

Imported Resources

+

+ {imports.length > 0 ? `${imports.length} imported` : 'No imports yet'} +

+
+ +
+ +
+
+ {imports.map((resource) => { + const isBound = Boolean( + activeCollection?.homeAssistant?.resources.some((entry) => entry.id === resource.id), + ) + + return ( + + ) + })} + + {connectionState?.linked && imports.length === 0 && !isRefreshingImports && ( +
+ Import Home Assistant resources to bind them to Pascal collections. +
+ )} +
+
+
+ +
+ {statusMessage} +
+ + {panelError && ( +
+ {panelError} +
+ )} +
+
+ )} + + ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1750c6fc3..9392b4497 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -18,6 +18,7 @@ import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) + const roomControlOverlayActive = useViewer((s) => s.roomControlOverlayActive) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) // Only subscribe to the *type* of the single-selected node — string primitive // so we don't re-render on unrelated scene mutations. @@ -32,6 +33,10 @@ export function PanelManager() { return } + if (roomControlOverlayActive) { + return null + } + // Show appropriate panel based on selected node type if (selectedNodeType) { switch (selectedNodeType) { diff --git a/packages/editor/src/components/viewer-zone-system.tsx b/packages/editor/src/components/viewer-zone-system.tsx index bbc26132d..5c933d4e8 100755 --- a/packages/editor/src/components/viewer-zone-system.tsx +++ b/packages/editor/src/components/viewer-zone-system.tsx @@ -34,9 +34,8 @@ export const ViewerZoneSystem = () => { } }) - // Labels: always visible on the current level (regardless of mode or zone selection) - const showLabel = !!levelId && isOnSelectedLevel - const targetOpacity = showLabel ? '1' : '0' + // Keep the built-in zone labels hidden while the room-button HUD owns room naming. + const targetOpacity = '0' const labelEl = document.getElementById(`${id}-label`) if (labelEl && labelEl.style.opacity !== targetOpacity) { labelEl.style.opacity = targetOpacity diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 021eeeedd..86da4e01f 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -59,7 +59,10 @@ export function useAutoSave({ // Stable subscription to scene changes useEffect(() => { - let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) + let lastSceneSnapshot = JSON.stringify({ + collections: useScene.getState().collections, + nodes: useScene.getState().nodes, + }) async function executeSave() { if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { @@ -68,8 +71,8 @@ export function useAutoSave({ return } - const { nodes, rootNodeIds } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds } as SceneGraph + const { collections, nodes, rootNodeIds } = useScene.getState() + const sceneGraph = { collections, nodes, rootNodeIds } as SceneGraph isSavingRef.current = true pendingSaveRef.current = false @@ -103,20 +106,29 @@ export function useAutoSave({ const unsubscribe = useScene.subscribe((state) => { if (isLoadingSceneRef.current) { - lastNodesSnapshot = JSON.stringify(state.nodes) + lastSceneSnapshot = JSON.stringify({ + collections: state.collections, + nodes: state.nodes, + }) return } if (isVersionPreviewModeRef.current) { setSaveStatus('paused') - lastNodesSnapshot = JSON.stringify(state.nodes) + lastSceneSnapshot = JSON.stringify({ + collections: state.collections, + nodes: state.nodes, + }) return } - const currentNodesSnapshot = JSON.stringify(state.nodes) - if (currentNodesSnapshot === lastNodesSnapshot) return + const currentSceneSnapshot = JSON.stringify({ + collections: state.collections, + nodes: state.nodes, + }) + if (currentSceneSnapshot === lastSceneSnapshot) return - lastNodesSnapshot = currentNodesSnapshot + lastSceneSnapshot = currentSceneSnapshot hasDirtyChangesRef.current = true onDirtyRef.current?.() setSaveStatus('pending') @@ -136,8 +148,8 @@ export function useAutoSave({ function flushOnExit() { if (!hasDirtyChangesRef.current) return - const { nodes, rootNodeIds } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds } as SceneGraph + const { collections, nodes, rootNodeIds } = useScene.getState() + const sceneGraph = { collections, nodes, rootNodeIds } as SceneGraph if (onSaveRef.current) { onSaveRef.current(sceneGraph).catch(() => {}) } else { diff --git a/packages/editor/src/lib/home-assistant-collections.ts b/packages/editor/src/lib/home-assistant-collections.ts new file mode 100644 index 000000000..5a8289da8 --- /dev/null +++ b/packages/editor/src/lib/home-assistant-collections.ts @@ -0,0 +1,283 @@ +import type { + AnyNodeId, + Collection, + CollectionCapability, + CollectionHomeAssistantAction, + CollectionHomeAssistantActionField, + CollectionHomeAssistantResourceBinding, + CollectionId, + CollectionKind, + CollectionZoneId, + ItemNode, +} from '@pascal-app/core/schema' +import { normalizeCollection } from '@pascal-app/core/schema' +import type { + HomeAssistantAvailableAction, + HomeAssistantAvailableActionField, + HomeAssistantDiscoveredDevice, +} from './home-assistant' + +export type HomeAssistantImportedResource = CollectionHomeAssistantResourceBinding & { + description: string + domain: string | null + state: string | null +} + +const dedupeCapabilities = (capabilities: CollectionCapability[]) => + Array.from(new Set(capabilities)) + +const mapField = ( + field: HomeAssistantAvailableActionField, +): CollectionHomeAssistantActionField => ({ + defaultValue: field.defaultValue, + key: field.key, + label: field.label, + required: field.required, + selector: field.selector ?? null, +}) + +const inferCapabilitiesFromAction = ( + action: Pick, +) => { + const capabilities: CollectionCapability[] = [] + + switch (action.actionKind) { + case 'connect': + case 'next': + case 'pause': + case 'play': + case 'previous': + case 'stop': + capabilities.push('media') + break + case 'power': + case 'turn_off': + case 'turn_on': + capabilities.push('power') + break + case 'volume': + capabilities.push('volume') + break + default: + break + } + + if ( + action.service === 'set_percentage' || + action.fields.some((field) => field.key === 'percentage') + ) { + capabilities.push(action.domain === 'fan' ? 'speed' : 'brightness') + } + + if ( + action.fields.some((field) => + ['brightness', 'brightness_pct'].includes(field.key), + ) + ) { + capabilities.push('brightness') + } + + if ( + action.fields.some((field) => + ['temperature', 'target_temp_high', 'target_temp_low'].includes(field.key), + ) || action.service === 'set_temperature' + ) { + capabilities.push('temperature') + } + + if (action.domain === 'scene' || action.domain === 'script' || action.domain === 'automation') { + capabilities.push('trigger') + } + + return dedupeCapabilities(capabilities) +} + +export const toCollectionHomeAssistantAction = ( + action: HomeAssistantAvailableAction, +): CollectionHomeAssistantAction => ({ + capability: inferCapabilitiesFromAction(action)[0] ?? 'trigger', + domain: action.domain, + fields: action.fields.map((field) => mapField(field)), + key: action.key, + label: action.label, + service: action.service, +}) + +export const toImportedEntityResource = ( + device: HomeAssistantDiscoveredDevice, +): HomeAssistantImportedResource => { + const actions = device.availableActions.map((action) => toCollectionHomeAssistantAction(action)) + return { + actions, + capabilities: dedupeCapabilities(actions.map((action) => action.capability)), + defaultActionKey: device.defaultActionKey, + description: device.description, + domain: device.haEntityId?.split('.')[0] ?? null, + entityId: device.haEntityId, + id: device.haEntityId ?? device.id, + kind: 'entity', + label: device.name, + state: null, + } +} + +export const buildCollectionBindingFromResource = ( + resource: HomeAssistantImportedResource, +): Collection['homeAssistant'] => ({ + aggregation: resource.kind === 'entity' ? 'single' : 'trigger_only', + primaryResourceId: resource.id, + resources: [ + { + actions: resource.actions, + capabilities: resource.capabilities, + defaultActionKey: resource.defaultActionKey, + entityId: resource.entityId, + id: resource.id, + kind: resource.kind, + label: resource.label, + }, + ], +}) + +export const getCollectionRoomZoneIds = ( + collection: Collection, + zoneIds: CollectionZoneId[] = [], +) => { + const normalizedZoneIds = Array.from( + new Set([...(collection.zoneIds ?? []), ...zoneIds]), + ).filter((zoneId): zoneId is CollectionZoneId => typeof zoneId === 'string') + + return normalizedZoneIds.length > 0 ? normalizedZoneIds : undefined +} + +export const inferCollectionKindFromResource = ( + resource: HomeAssistantImportedResource, +): CollectionKind => (resource.kind === 'entity' ? 'device' : 'automation') + +export const getCollectionBindingDisplayLabel = (collection: Collection) => + collection.presentation?.label?.trim() || collection.name.trim() || 'Collection' + +export const collectionHasHomeAssistantBinding = (collection: Collection | null | undefined) => + Boolean(collection?.homeAssistant?.resources?.length) + +export const resolveCollectionForSelectedItems = ({ + collections, + selectedIds, +}: { + collections: Record + selectedIds: AnyNodeId[] +}) => { + if (selectedIds.length === 0) { + return null + } + + const matchingCollections = Object.values(collections).filter((collection) => + selectedIds.every((selectedId) => collection.nodeIds.includes(selectedId)), + ) + + if (matchingCollections.length > 0) { + return matchingCollections[0] ?? null + } + + const collectionIds = new Set() + for (const collection of Object.values(collections)) { + if (collection.nodeIds.some((nodeId) => selectedIds.includes(nodeId))) { + collectionIds.add(collection.id) + } + } + + return collectionIds.size === 1 ? collections[Array.from(collectionIds)[0]!] : null +} + +export const buildCollectionForSelection = ({ + color, + controlNodeId, + name, + selectedItems, + zoneIds, +}: { + color?: string + controlNodeId?: AnyNodeId + name: string + selectedItems: ItemNode[] + zoneIds?: CollectionZoneId[] +}) => + normalizeCollection({ + color, + id: '' as CollectionId, + name, + nodeIds: selectedItems.map((item) => item.id), + controlNodeId: controlNodeId ?? selectedItems[0]?.id, + zoneIds, + }) + +export const bindResourceToCollection = ({ + collection, + resource, + zoneIds, +}: { + collection: Collection + resource: HomeAssistantImportedResource + zoneIds?: CollectionZoneId[] +}) => { + const existingResources = collection.homeAssistant?.resources ?? [] + const nextResources = existingResources.some((entry) => entry.id === resource.id) + ? existingResources.map((entry) => + entry.id === resource.id + ? { + actions: resource.actions, + capabilities: resource.capabilities, + defaultActionKey: resource.defaultActionKey, + entityId: resource.entityId, + id: resource.id, + kind: resource.kind, + label: resource.label, + } + : entry, + ) + : [ + ...existingResources, + { + actions: resource.actions, + capabilities: resource.capabilities, + defaultActionKey: resource.defaultActionKey, + entityId: resource.entityId, + id: resource.id, + kind: resource.kind, + label: resource.label, + }, + ] + + const nextCapabilities = dedupeCapabilities([ + ...(collection.capabilities ?? []), + ...nextResources.flatMap((entry) => entry.capabilities), + ]) + + const nextKind = + resource.kind === 'entity' + ? nextResources.length > 1 || collection.nodeIds.length > 1 + ? 'group' + : 'device' + : 'automation' + + return normalizeCollection({ + ...collection, + capabilities: nextCapabilities, + homeAssistant: { + aggregation: + nextKind === 'automation' + ? 'trigger_only' + : nextResources.length > 1 + ? 'all' + : 'single', + primaryResourceId: collection.homeAssistant?.primaryResourceId ?? resource.id, + resources: nextResources, + }, + kind: nextKind, + presentation: { + ...collection.presentation, + label: collection.presentation?.label ?? resource.label, + }, + zoneIds: getCollectionRoomZoneIds(collection, zoneIds), + }) +} diff --git a/packages/editor/src/lib/home-assistant-connect.ts b/packages/editor/src/lib/home-assistant-connect.ts new file mode 100644 index 000000000..37e4b0e9e --- /dev/null +++ b/packages/editor/src/lib/home-assistant-connect.ts @@ -0,0 +1,40 @@ +import type { ItemNode } from '@pascal-app/core' + +export const PASCAL_HA_CONNECT_REQUEST_EVENT = 'pascal:ha-connect-request' + +export type PascalHaConnectRequestDetail = { + itemId: ItemNode['id'] + itemName: ItemNode['asset']['name'] +} + +function normalizeConnectCandidate(value: string | undefined) { + return value?.trim().toLowerCase() ?? '' +} + +export function isHomeAssistantConnectableItem(item: ItemNode | null | undefined) { + if (!item) { + return false + } + + const { asset } = item + const candidates = [asset.id, asset.name, asset.src, ...(asset.tags ?? [])] + .map(normalizeConnectCandidate) + .filter(Boolean) + + return candidates.some((candidate) => candidate.includes('television') || candidate === 'tv') +} + +export function requestHomeAssistantConnect(item: ItemNode) { + if (typeof window === 'undefined') { + return + } + + window.dispatchEvent( + new CustomEvent(PASCAL_HA_CONNECT_REQUEST_EVENT, { + detail: { + itemId: item.id, + itemName: item.asset.name, + }, + }), + ) +} diff --git a/packages/editor/src/lib/home-assistant-controls.ts b/packages/editor/src/lib/home-assistant-controls.ts new file mode 100644 index 000000000..451190f5d --- /dev/null +++ b/packages/editor/src/lib/home-assistant-controls.ts @@ -0,0 +1,576 @@ +import type { + HomeAssistantAvailableAction, + HomeAssistantAvailableActionField, + HomeAssistantDiscoveredDevice, +} from './home-assistant' +import { getHomeAssistantCapabilityCategory } from './home-assistant' + +export const HOME_ASSISTANT_DEFAULT_MEDIA_SENTINEL = '__pascal_default_media__' + +export type HomeAssistantFieldSelectorKey = + | 'area' + | 'boolean' + | 'color_rgb' + | 'color_temp' + | 'constant' + | 'date' + | 'datetime' + | 'entity' + | 'media' + | 'number' + | 'object' + | 'select' + | 'state' + | 'text' + | 'time' + | null + +export type HomeAssistantFieldOption = { + description?: string | null + label: string + value: unknown +} + +function getStateSelectorAttributeCandidates(attributeName: string) { + switch (attributeName) { + case 'source': + return ['source', 'source_list'] + case 'sound_mode': + return ['sound_mode', 'sound_mode_list'] + case 'fan_mode': + return ['fan_mode', 'fan_modes'] + case 'hvac_mode': + return ['hvac_mode', 'hvac_modes'] + case 'preset_mode': + return ['preset_mode', 'preset_modes'] + case 'swing_mode': + return ['swing_mode', 'swing_modes'] + case 'swing_horizontal_mode': + return ['swing_horizontal_mode', 'swing_horizontal_modes'] + default: + return [attributeName] + } +} + +function getNumberSelectorConfig(field: HomeAssistantAvailableActionField) { + const selector = + field.selector?.number && typeof field.selector.number === 'object' ? field.selector.number : null + const min = selector && 'min' in selector ? Number(selector.min) : Number.NaN + const max = selector && 'max' in selector ? Number(selector.max) : Number.NaN + const step = selector && 'step' in selector ? Number(selector.step) : Number.NaN + const mode = selector && 'mode' in selector && typeof selector.mode === 'string' ? selector.mode : null + const unit = + selector && + 'unit_of_measurement' in selector && + typeof selector.unit_of_measurement === 'string' + ? selector.unit_of_measurement + : null + + return { + max: Number.isFinite(max) ? max : null, + min: Number.isFinite(min) ? min : null, + mode, + step: Number.isFinite(step) && step > 0 ? step : 1, + unit, + } +} + +function clampNumber(value: number, min: number | null, max: number | null) { + if (min !== null && value < min) { + return min + } + + if (max !== null && value > max) { + return max + } + + return value +} + +function roundToStep(value: number, min: number | null, step: number) { + if (!Number.isFinite(value)) { + return min ?? 0 + } + + const base = min ?? 0 + return Number((Math.round((value - base) / step) * step + base).toFixed(4)) +} + +function buildNumberPresets(field: HomeAssistantAvailableActionField) { + const config = getNumberSelectorConfig(field) + if (config.min === null || config.max === null) { + return [] as HomeAssistantFieldOption[] + } + + const min = config.min + const max = config.max + const step = config.step + const unit = config.unit + const span = max - min + + let candidates: number[] + if (min === 0 && max === 1) { + candidates = [0, 0.25, 0.5, 0.75, 1] + } else if (unit === '%' || max === 100) { + candidates = [0, 25, 50, 75, 100] + } else if (span <= 10) { + candidates = [min, min + span * 0.25, min + span * 0.5, min + span * 0.75, max] + } else { + candidates = [min, min + span * 0.2, min + span * 0.5, min + span * 0.8, max] + } + + return Array.from( + new Set( + candidates.map((candidate) => + clampNumber(roundToStep(candidate, min, step), config.min, config.max), + ), + ), + ).map((value) => ({ + label: + min === 0 && max === 1 + ? `${Math.round(value * 100)}%` + : unit === '%' + ? `${Math.round(value)}%` + : `${value}${unit ? ` ${unit}` : ''}`, + value, + })) +} + +function buildColorTemperatureOptions(field: HomeAssistantAvailableActionField) { + const config = getNumberSelectorConfig(field) + const usesKelvin = + field.key.includes('kelvin') || + (field.selector?.color_temp && + typeof field.selector.color_temp === 'object' && + 'unit' in field.selector.color_temp && + field.selector.color_temp.unit === 'kelvin') + + const presets = usesKelvin + ? [ + { label: 'Warm', value: 2700 }, + { label: 'Neutral', value: 4000 }, + { label: 'Cool', value: 6500 }, + ] + : [ + { label: 'Warm', value: 400 }, + { label: 'Neutral', value: 250 }, + { label: 'Cool', value: 153 }, + ] + + return presets.map((preset) => ({ + ...preset, + value: + config.min !== null || config.max !== null + ? clampNumber(Number(preset.value), config.min, config.max) + : preset.value, + })) +} + +function isDefaultConnectAction(action: HomeAssistantAvailableAction) { + return action.actionKind === 'connect' && action.domain === 'media_player' && action.service === 'play_media' +} + +function canUseDefaultValue(field: HomeAssistantAvailableActionField) { + return field.defaultValue !== null && field.defaultValue !== undefined +} + +export function getHomeAssistantActionFieldSelectorKey(field: HomeAssistantAvailableActionField) { + if (!field.selector) { + return null + } + + const selectorKey = Object.keys(field.selector)[0] ?? null + switch (selectorKey) { + case 'area': + case 'boolean': + case 'color_rgb': + case 'color_temp': + case 'constant': + case 'date': + case 'datetime': + case 'entity': + case 'media': + case 'number': + case 'object': + case 'select': + case 'state': + case 'text': + case 'time': + return selectorKey + default: + return null + } +} + +export function getHomeAssistantActionFieldOptions( + action: HomeAssistantAvailableAction, + field: HomeAssistantAvailableActionField, + device: HomeAssistantDiscoveredDevice, +) { + const selectorKey = getHomeAssistantActionFieldSelectorKey(field) + + if (selectorKey === 'boolean') { + if (field.key === 'is_volume_muted') { + return [ + { label: 'Mute', value: true }, + { label: 'Unmute', value: false }, + ] satisfies HomeAssistantFieldOption[] + } + + return [ + { label: 'On', value: true }, + { label: 'Off', value: false }, + ] satisfies HomeAssistantFieldOption[] + } + + if (selectorKey === 'constant') { + const constantSelector = + field.selector?.constant && typeof field.selector.constant === 'object' + ? field.selector.constant + : null + + if (!constantSelector || !('value' in constantSelector)) { + return [] as HomeAssistantFieldOption[] + } + + return [ + { + label: + 'label' in constantSelector && typeof constantSelector.label === 'string' + ? constantSelector.label + : field.label, + value: constantSelector.value, + }, + ] + } + + if (selectorKey === 'select') { + const selectSelector = field.selector?.select + if ( + selectSelector && + typeof selectSelector === 'object' && + !Array.isArray(selectSelector) && + 'options' in selectSelector && + Array.isArray(selectSelector.options) + ) { + return selectSelector.options + .filter((option: unknown): option is string => typeof option === 'string') + .map((option: string) => ({ label: option, value: option })) + } + + return [] as HomeAssistantFieldOption[] + } + + if (selectorKey === 'state') { + const stateSelector = field.selector?.state + if ( + !stateSelector || + typeof stateSelector !== 'object' || + stateSelector === null || + !('attribute' in stateSelector) || + typeof stateSelector.attribute !== 'string' + ) { + return [] as HomeAssistantFieldOption[] + } + + const attributes = device.attributes ?? {} + const options = getStateSelectorAttributeCandidates(stateSelector.attribute).flatMap((attributeName) => { + const rawValue = attributes[attributeName] + if (Array.isArray(rawValue)) { + return rawValue.filter((entry): entry is string => typeof entry === 'string') + } + + if (typeof rawValue === 'string' && rawValue.trim().length > 0) { + return [rawValue.trim()] + } + + return [] + }) + + return Array.from(new Set(options)).map((option) => ({ + label: option, + value: option, + })) + } + + if (selectorKey === 'number') { + return buildNumberPresets(field) + } + + if (selectorKey === 'color_rgb') { + return [ + { label: 'Warm', value: [255, 183, 76] }, + { label: 'Daylight', value: [255, 244, 229] }, + { label: 'Blue', value: [91, 160, 255] }, + { label: 'Green', value: [72, 199, 116] }, + { label: 'Violet', value: [153, 102, 255] }, + ] satisfies HomeAssistantFieldOption[] + } + + if (selectorKey === 'color_temp') { + return buildColorTemperatureOptions(field) + } + + if (selectorKey === 'media' && isDefaultConnectAction(action)) { + return [ + { + description: 'Uses the configured Home Assistant cast test clip.', + label: 'Cast Test', + value: HOME_ASSISTANT_DEFAULT_MEDIA_SENTINEL, + }, + ] + } + + return [] as HomeAssistantFieldOption[] +} + +export function getHomeAssistantActionInitialFieldValue( + action: HomeAssistantAvailableAction, + field: HomeAssistantAvailableActionField, + device: HomeAssistantDiscoveredDevice, + serviceData: Record = {}, +) { + if (serviceData[field.key] !== undefined) { + return serviceData[field.key] + } + + if (isDefaultConnectAction(action) && field.key === 'media') { + return HOME_ASSISTANT_DEFAULT_MEDIA_SENTINEL + } + + if (field.key === 'is_volume_muted') { + const muted = device.attributes?.is_volume_muted + if (typeof muted === 'boolean') { + return !muted + } + } + + if (canUseDefaultValue(field)) { + return field.defaultValue + } + + const options = getHomeAssistantActionFieldOptions(action, field, device) + const onlyOption = options[0] + if (field.required && options.length === 1 && onlyOption) { + return onlyOption.value + } + + return '' +} + +export function hasHomeAssistantActionFieldValue( + field: HomeAssistantAvailableActionField, + value: unknown, +) { + const selectorKey = getHomeAssistantActionFieldSelectorKey(field) + if (selectorKey === 'boolean') { + return typeof value === 'boolean' + } + + if (typeof value === 'number') { + return Number.isFinite(value) + } + + if (typeof value === 'string') { + return value.trim().length > 0 + } + + if (Array.isArray(value)) { + return value.length > 0 + } + + if (value && typeof value === 'object') { + return true + } + + return false +} + +export function normalizeHomeAssistantActionFieldValue( + action: HomeAssistantAvailableAction, + field: HomeAssistantAvailableActionField, + value: unknown, +) { + const selectorKey = getHomeAssistantActionFieldSelectorKey(field) + + if (isDefaultConnectAction(action) && value === HOME_ASSISTANT_DEFAULT_MEDIA_SENTINEL) { + return undefined + } + + if (selectorKey === 'number') { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + throw new Error(`${field.label} must be a valid number.`) + } + + if (selectorKey === 'boolean') { + if (typeof value === 'boolean') { + return value + } + + if (value === 'true') { + return true + } + + if (value === 'false') { + return false + } + + throw new Error(`${field.label} must be true or false.`) + } + + if (selectorKey === 'media' || selectorKey === 'object') { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) { + return '' + } + + try { + return JSON.parse(trimmed) + } catch { + return trimmed + } + } + } + + return value +} + +export function buildHomeAssistantActionServiceData( + action: HomeAssistantAvailableAction, + device: HomeAssistantDiscoveredDevice, + values: Record, +) { + return action.fields.reduce>((serviceData, field) => { + const rawValue = + values[field.key] !== undefined + ? values[field.key] + : getHomeAssistantActionInitialFieldValue(action, field, device) + + if (!field.required && !hasHomeAssistantActionFieldValue(field, rawValue)) { + return serviceData + } + + if (field.required && !hasHomeAssistantActionFieldValue(field, rawValue)) { + throw new Error(`Choose a value for ${field.label}.`) + } + + const normalizedValue = normalizeHomeAssistantActionFieldValue(action, field, rawValue) + if (normalizedValue !== undefined) { + serviceData[field.key] = normalizedValue + } + return serviceData + }, {}) +} + +export function getHomeAssistantRenderableFields( + action: HomeAssistantAvailableAction, + device: HomeAssistantDiscoveredDevice, +) { + return action.fields.filter((field) => { + const selectorKey = getHomeAssistantActionFieldSelectorKey(field) + if (field.required) { + switch (selectorKey) { + case 'boolean': + case 'color_rgb': + case 'color_temp': + case 'constant': + case 'date': + case 'datetime': + case 'media': + case 'number': + case 'select': + case 'state': + case 'time': + return true + default: + return canUseDefaultValue(field) || getHomeAssistantActionFieldOptions(action, field, device).length > 0 + } + } + + switch (selectorKey) { + case 'boolean': + case 'color_rgb': + case 'color_temp': + case 'number': + case 'select': + case 'state': + return true + default: + return false + } + }) +} + +export function isHomeAssistantActionLean( + action: HomeAssistantAvailableAction, + device: HomeAssistantDiscoveredDevice, +) { + return getHomeAssistantRenderableFields(action, device).length >= action.fields.filter((field) => field.required).length +} + +export function canRunHomeAssistantActionImmediately( + action: HomeAssistantAvailableAction, + device: HomeAssistantDiscoveredDevice, + values: Record = {}, +) { + if (action.fields.length === 0 || isDefaultConnectAction(action)) { + return true + } + + const requiredFields = action.fields.filter((field) => field.required) + if (requiredFields.length === 0) { + return true + } + + return requiredFields.every((field) => { + const selectorKey = getHomeAssistantActionFieldSelectorKey(field) + if (selectorKey === 'number' || selectorKey === 'color_rgb' || selectorKey === 'color_temp') { + return false + } + + const value = + values[field.key] !== undefined + ? values[field.key] + : getHomeAssistantActionInitialFieldValue(action, field, device) + return hasHomeAssistantActionFieldValue(field, value) + }) +} + +export function normalizeHomeAssistantDiscoveredDevice(device: HomeAssistantDiscoveredDevice) { + const availableActions = device.availableActions.filter((action) => isHomeAssistantActionLean(action, device)) + const enabledActionCategories = + device.enabledActionCategories.length > 0 + ? device.enabledActionCategories.filter((category) => + availableActions.some( + (action) => getHomeAssistantCapabilityCategory(action.actionKind) === category, + ), + ) + : Array.from( + new Set( + availableActions.map((action) => getHomeAssistantCapabilityCategory(action.actionKind)), + ), + ) + const defaultAction = + availableActions.find((action) => action.key === device.defaultActionKey) ?? + availableActions.find((action) => action.actionKind === 'connect') ?? + availableActions[0] ?? + null + + return { + ...device, + availableActions, + defaultActionKey: defaultAction?.key ?? null, + enabledActionCategories, + } +} diff --git a/packages/editor/src/lib/home-assistant.ts b/packages/editor/src/lib/home-assistant.ts new file mode 100644 index 000000000..ba2794eaa --- /dev/null +++ b/packages/editor/src/lib/home-assistant.ts @@ -0,0 +1,661 @@ +import type { ItemNode } from '@pascal-app/core' + +export const HOME_ASSISTANT_LINK_METADATA_KEY = 'homeAssistantLink' +export const PASCAL_HA_DEVICE_ACTION_REQUEST_EVENT = 'pascal:ha-device-action-request' + +export type HomeAssistantDeviceProtocol = 'home-assistant' | 'mdns' | 'ssdp' +export type HomeAssistantCapabilityCategory = 'access' | 'audio' | 'other' | 'playback' | 'power' +export type HomeAssistantActionKind = + | 'close' + | 'connect' + | 'custom' + | 'lock' + | 'next' + | 'open' + | 'pause' + | 'play' + | 'power' + | 'previous' + | 'stop' + | 'turn_off' + | 'turn_on' + | 'unlock' + | 'volume' +export type HomeAssistantActionIcon = + | 'brightness' + | 'clean_area' + | 'clean_spot' + | 'climate_mode' + | 'close' + | 'color' + | 'color_temperature' + | 'connect' + | 'custom' + | 'direction' + | 'fan_mode' + | 'group' + | 'humidity' + | 'locate' + | 'lock' + | 'next' + | 'open' + | 'pause' + | 'play' + | 'play_pause' + | 'playlist_clear' + | 'position' + | 'position_stop' + | 'power_toggle' + | 'previous' + | 'preset_mode' + | 'repeat' + | 'return_to_base' + | 'search' + | 'seek' + | 'shuffle' + | 'sound_mode' + | 'speed' + | 'speed_down' + | 'speed_up' + | 'start' + | 'stop' + | 'swing' + | 'swing_horizontal' + | 'temperature' + | 'tilt_close' + | 'tilt_open' + | 'tilt_position' + | 'tilt_stop' + | 'toggle' + | 'turn_off' + | 'turn_on' + | 'ungroup' + | 'unlock' + | 'volume_down' + | 'volume_mute' + | 'volume_set' + | 'volume_up' + +export type HomeAssistantServiceTargetFilter = { + domain?: string[] + supported_features?: Array +} + +export type HomeAssistantSelectorConfig = Record + +export type HomeAssistantAvailableActionField = { + advanced: boolean + defaultValue: unknown + example: unknown + filterAttribute: Record | null + filterSupportedFeatures: Array | null + key: string + label: string + required: boolean + selector: HomeAssistantSelectorConfig | null +} + +export type HomeAssistantAvailableAction = { + actionKind: HomeAssistantActionKind + description: string + domain: string + fields: HomeAssistantAvailableActionField[] + key: string + label: string + service: string +} + +export type HomeAssistantActionPresentation = { + displayKey: string + icon: HomeAssistantActionIcon + label: string +} + +export type HomeAssistantDiscoveredDevice = { + actionable: boolean + attributes: Record | null + availableActions: HomeAssistantAvailableAction[] + enabledActionCategories: HomeAssistantCapabilityCategory[] + defaultActionKey: string | null + defaultServiceData: Record + description: string + deviceType: string + haEntityId: string | null + id: string + ip: string | null + manufacturer: string | null + model: string | null + name: string + protocol: HomeAssistantDeviceProtocol + serviceType: string | null + supportedFeatures: number | null +} + +export type HomeAssistantLink = { + actionKind: HomeAssistantActionKind + actionLabel: string + description: string + deviceId: string + deviceName: string + deviceType: string + enabledActionCategories: HomeAssistantCapabilityCategory[] + haEntityId: string | null + ip: string | null + linkedAt: string + manufacturer: string | null + model: string | null + protocol: HomeAssistantDeviceProtocol + serviceData: Record + serviceDomain: string + serviceName: string + serviceType: string | null +} + +export type PascalHaDeviceActionRequestDetail = { + itemId: ItemNode['id'] + itemName: ItemNode['asset']['name'] + link: HomeAssistantLink +} + +function isMetadataRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizeString(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +function normalizeActionKind(value: unknown): HomeAssistantActionKind | null { + switch (value) { + case 'close': + case 'connect': + case 'custom': + case 'lock': + case 'next': + case 'open': + case 'pause': + case 'play': + case 'power': + case 'previous': + case 'stop': + case 'turn_off': + case 'turn_on': + case 'unlock': + case 'volume': + return value + default: + return null + } +} + +function normalizeCapabilityCategory(value: unknown): HomeAssistantCapabilityCategory | null { + switch (value) { + case 'access': + case 'audio': + case 'other': + case 'playback': + case 'power': + return value + default: + return null + } +} + +export function getHomeAssistantCapabilityCategory( + actionKind: HomeAssistantActionKind, +): HomeAssistantCapabilityCategory { + switch (actionKind) { + case 'turn_on': + case 'turn_off': + case 'power': + return 'power' + case 'play': + case 'pause': + case 'stop': + case 'next': + case 'previous': + case 'connect': + return 'playback' + case 'volume': + return 'audio' + case 'lock': + case 'unlock': + case 'open': + case 'close': + return 'access' + case 'custom': + default: + return 'other' + } +} + +function normalizeCapabilityCategories(value: unknown) { + if (!Array.isArray(value)) { + return [] as HomeAssistantCapabilityCategory[] + } + + return value.reduce((categories, entry) => { + const normalized = normalizeCapabilityCategory(entry) + if (normalized && !categories.includes(normalized)) { + categories.push(normalized) + } + return categories + }, []) +} + +function deriveLegacyServiceDomain(actionKind: HomeAssistantActionKind) { + switch (actionKind) { + case 'play': + case 'pause': + case 'stop': + case 'next': + case 'previous': + case 'connect': + case 'volume': + return 'media_player' + case 'power': + return 'homeassistant' + case 'turn_on': + case 'turn_off': + case 'lock': + case 'unlock': + case 'open': + case 'close': + case 'custom': + default: + return 'homeassistant' + } +} + +function deriveLegacyServiceName(actionKind: HomeAssistantActionKind) { + switch (actionKind) { + case 'connect': + return 'play_media' + case 'play': + return 'media_play' + case 'pause': + return 'media_pause' + case 'stop': + return 'media_stop' + case 'next': + return 'media_next_track' + case 'previous': + return 'media_previous_track' + case 'volume': + return 'volume_set' + case 'turn_on': + return 'turn_on' + case 'turn_off': + return 'turn_off' + case 'lock': + return 'lock' + case 'unlock': + return 'unlock' + case 'open': + return 'open_cover' + case 'close': + return 'close_cover' + case 'power': + case 'custom': + default: + return 'toggle' + } +} + +function normalizeProtocol(value: unknown): HomeAssistantDeviceProtocol | null { + return value === 'home-assistant' || value === 'mdns' || value === 'ssdp' ? value : null +} + +function titleCaseServiceLabel(value: string) { + return value + .replace(/[_-]+/g, ' ') + .split(' ') + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' ') +} + +function buildHomeAssistantActionPresentation({ + actionKind, + fallbackLabel, + serviceDomain, + serviceName, +}: { + actionKind: HomeAssistantActionKind + fallbackLabel?: string | null + serviceDomain: string + serviceName: string +}): HomeAssistantActionPresentation { + if (serviceDomain === 'media_player' && serviceName === 'play_media' && actionKind === 'connect') { + return { + displayKey: 'connect', + icon: 'connect', + label: 'Connect', + } + } + + switch (serviceName) { + case 'turn_on': + return { displayKey: 'turn_on', icon: 'turn_on', label: 'Turn On' } + case 'turn_off': + return { displayKey: 'turn_off', icon: 'turn_off', label: 'Turn Off' } + case 'toggle': + return { displayKey: 'toggle', icon: 'toggle', label: 'Toggle' } + case 'media_play': + return { displayKey: 'play', icon: 'play', label: 'Play' } + case 'media_play_pause': + return { displayKey: 'play_pause', icon: 'play_pause', label: 'Play/Pause' } + case 'media_pause': + case 'pause': + return { displayKey: 'pause', icon: 'pause', label: 'Pause' } + case 'media_stop': + case 'stop': + return { displayKey: 'stop', icon: 'stop', label: 'Stop' } + case 'start': + return { displayKey: 'start', icon: 'start', label: 'Start' } + case 'media_next_track': + return { displayKey: 'next', icon: 'next', label: 'Next' } + case 'media_previous_track': + return { displayKey: 'previous', icon: 'previous', label: 'Previous' } + case 'volume_up': + return { displayKey: 'volume_up', icon: 'volume_up', label: 'Volume Up' } + case 'volume_down': + return { displayKey: 'volume_down', icon: 'volume_down', label: 'Volume Down' } + case 'volume_set': + return { displayKey: 'volume_set', icon: 'volume_set', label: 'Set Volume' } + case 'volume_mute': + return { displayKey: 'volume_mute', icon: 'volume_mute', label: 'Mute' } + case 'select_source': + return { displayKey: 'source', icon: 'connect', label: 'Source' } + case 'select_sound_mode': + return { displayKey: 'sound_mode', icon: 'sound_mode', label: 'Sound Mode' } + case 'repeat_set': + return { displayKey: 'repeat', icon: 'repeat', label: 'Repeat' } + case 'shuffle_set': + return { displayKey: 'shuffle', icon: 'shuffle', label: 'Shuffle' } + case 'media_seek': + return { displayKey: 'seek', icon: 'seek', label: 'Seek' } + case 'join': + return { displayKey: 'group', icon: 'group', label: 'Group' } + case 'unjoin': + return { displayKey: 'ungroup', icon: 'ungroup', label: 'Ungroup' } + case 'clear_playlist': + return { displayKey: 'playlist_clear', icon: 'playlist_clear', label: 'Clear Queue' } + case 'search_media': + return { displayKey: 'search', icon: 'search', label: 'Search' } + case 'lock': + return { displayKey: 'lock', icon: 'lock', label: 'Lock' } + case 'unlock': + return { displayKey: 'unlock', icon: 'unlock', label: 'Unlock' } + case 'open_cover': + case 'open': + return { displayKey: 'open', icon: 'open', label: 'Open' } + case 'close_cover': + case 'close': + return { displayKey: 'close', icon: 'close', label: 'Close' } + case 'open_cover_tilt': + return { displayKey: 'tilt_open', icon: 'tilt_open', label: 'Tilt Open' } + case 'close_cover_tilt': + return { displayKey: 'tilt_close', icon: 'tilt_close', label: 'Tilt Close' } + case 'set_cover_position': + return { displayKey: 'position', icon: 'position', label: 'Position' } + case 'set_cover_tilt_position': + return { displayKey: 'tilt_position', icon: 'tilt_position', label: 'Tilt Position' } + case 'stop_cover': + return { displayKey: 'position_stop', icon: 'position_stop', label: 'Stop' } + case 'stop_cover_tilt': + return { displayKey: 'tilt_stop', icon: 'tilt_stop', label: 'Tilt Stop' } + case 'increase_speed': + return { displayKey: 'speed_up', icon: 'speed_up', label: 'Speed Up' } + case 'decrease_speed': + return { displayKey: 'speed_down', icon: 'speed_down', label: 'Speed Down' } + case 'set_percentage': + return { displayKey: 'speed', icon: 'speed', label: 'Speed' } + case 'set_direction': + return { displayKey: 'direction', icon: 'direction', label: 'Direction' } + case 'oscillate': + return { displayKey: 'swing', icon: 'swing', label: 'Oscillate' } + case 'set_preset_mode': + return { displayKey: 'preset_mode', icon: 'preset_mode', label: 'Preset' } + case 'set_fan_mode': + return { displayKey: 'fan_mode', icon: 'fan_mode', label: 'Fan Mode' } + case 'set_hvac_mode': + return { displayKey: 'climate_mode', icon: 'climate_mode', label: 'Mode' } + case 'set_temperature': + return { displayKey: 'temperature', icon: 'temperature', label: 'Temperature' } + case 'set_humidity': + return { displayKey: 'humidity', icon: 'humidity', label: 'Humidity' } + case 'set_swing_mode': + return { displayKey: 'swing', icon: 'swing', label: 'Swing' } + case 'set_swing_horizontal_mode': + return { displayKey: 'swing_horizontal', icon: 'swing_horizontal', label: 'Horizontal Swing' } + case 'clean_spot': + return { displayKey: 'clean_spot', icon: 'clean_spot', label: 'Spot Clean' } + case 'clean_area': + return { displayKey: 'clean_area', icon: 'clean_area', label: 'Area Clean' } + case 'return_to_base': + return { displayKey: 'return_to_base', icon: 'return_to_base', label: 'Return' } + case 'locate': + return { displayKey: 'locate', icon: 'locate', label: 'Locate' } + case 'set_fan_speed': + return { displayKey: 'fan_mode', icon: 'fan_mode', label: 'Fan Speed' } + default: + break + } + + switch (actionKind) { + case 'turn_on': + return { displayKey: 'turn_on', icon: 'turn_on', label: 'Turn On' } + case 'turn_off': + return { displayKey: 'turn_off', icon: 'turn_off', label: 'Turn Off' } + case 'power': + return { displayKey: 'toggle', icon: 'toggle', label: 'Toggle' } + case 'play': + return { displayKey: 'play', icon: 'play', label: 'Play' } + case 'pause': + return { displayKey: 'pause', icon: 'pause', label: 'Pause' } + case 'stop': + return { displayKey: 'stop', icon: 'stop', label: 'Stop' } + case 'next': + return { displayKey: 'next', icon: 'next', label: 'Next' } + case 'previous': + return { displayKey: 'previous', icon: 'previous', label: 'Previous' } + case 'volume': + return { + displayKey: serviceName || 'volume_set', + icon: + serviceName === 'volume_up' + ? 'volume_up' + : serviceName === 'volume_down' + ? 'volume_down' + : serviceName === 'volume_mute' + ? 'volume_mute' + : 'volume_set', + label: fallbackLabel ?? 'Volume', + } + case 'lock': + return { displayKey: 'lock', icon: 'lock', label: 'Lock' } + case 'unlock': + return { displayKey: 'unlock', icon: 'unlock', label: 'Unlock' } + case 'open': + return { displayKey: 'open', icon: 'open', label: 'Open' } + case 'close': + return { displayKey: 'close', icon: 'close', label: 'Close' } + case 'connect': + return { displayKey: 'connect', icon: 'connect', label: 'Connect' } + case 'custom': + default: + return { + displayKey: `${serviceDomain}.${serviceName}`, + icon: 'custom', + label: fallbackLabel?.trim() || titleCaseServiceLabel(serviceName), + } + } +} + +export function getHomeAssistantAvailableActionPresentation( + action: Pick, +): HomeAssistantActionPresentation { + return buildHomeAssistantActionPresentation({ + actionKind: action.actionKind, + fallbackLabel: action.label, + serviceDomain: action.domain, + serviceName: action.service, + }) +} + +export function toHomeAssistantLink( + device: HomeAssistantDiscoveredDevice, + action: HomeAssistantAvailableAction | null = null, + serviceData: Record = device.defaultServiceData, + enabledActionCategories?: HomeAssistantCapabilityCategory[], + linkedAt = new Date().toISOString(), +): HomeAssistantLink { + const resolvedAction = + action ?? + device.availableActions.find((candidate) => candidate.key === device.defaultActionKey) ?? + device.availableActions[0] ?? + null + const resolvedEnabledCategories = + enabledActionCategories && enabledActionCategories.length > 0 + ? enabledActionCategories + : device.enabledActionCategories.length > 0 + ? device.enabledActionCategories + : Array.from( + new Set( + device.availableActions.map((candidate) => + getHomeAssistantCapabilityCategory(candidate.actionKind), + ), + ), + ) + + return { + actionKind: resolvedAction?.actionKind ?? 'custom', + actionLabel: resolvedAction?.label ?? 'Run action', + description: device.description, + deviceId: device.id, + deviceName: device.name, + deviceType: device.deviceType, + enabledActionCategories: resolvedEnabledCategories, + haEntityId: device.haEntityId, + ip: device.ip, + linkedAt, + manufacturer: device.manufacturer, + model: device.model, + protocol: device.protocol, + serviceData, + serviceDomain: resolvedAction?.domain ?? 'homeassistant', + serviceName: resolvedAction?.service ?? 'toggle', + serviceType: device.serviceType, + } +} + +function isJsonRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function getHomeAssistantLink(metadata: unknown): HomeAssistantLink | null { + if (!isMetadataRecord(metadata)) { + return null + } + + const value = metadata[HOME_ASSISTANT_LINK_METADATA_KEY] + if (!isMetadataRecord(value)) { + return null + } + + const protocol = normalizeProtocol(value.protocol) + const actionKind = normalizeActionKind(value.actionKind) + const deviceId = normalizeString(value.deviceId) + const deviceName = normalizeString(value.deviceName) + const deviceType = normalizeString(value.deviceType) + const enabledActionCategories = normalizeCapabilityCategories(value.enabledActionCategories) + const actionLabel = normalizeString(value.actionLabel) + const description = normalizeString(value.description) + const linkedAt = normalizeString(value.linkedAt) + const serviceDomain = normalizeString(value.serviceDomain) + const serviceName = normalizeString(value.serviceName) + const serviceData = isJsonRecord(value.serviceData) ? value.serviceData : {} + + if ( + !protocol || + !actionKind || + !deviceId || + !deviceName || + !deviceType || + !actionLabel || + !description || + !linkedAt + ) { + return null + } + + return { + actionKind, + actionLabel, + description, + deviceId, + deviceName, + deviceType, + enabledActionCategories: + enabledActionCategories.length > 0 + ? enabledActionCategories + : [getHomeAssistantCapabilityCategory(actionKind)], + haEntityId: normalizeString(value.haEntityId), + ip: normalizeString(value.ip), + linkedAt, + manufacturer: normalizeString(value.manufacturer), + model: normalizeString(value.model), + protocol, + serviceData, + serviceDomain: serviceDomain ?? deriveLegacyServiceDomain(actionKind), + serviceName: serviceName ?? deriveLegacyServiceName(actionKind), + serviceType: normalizeString(value.serviceType), + } +} + +export function setHomeAssistantLink(metadata: unknown, link: HomeAssistantLink | null) { + const nextMetadata = isMetadataRecord(metadata) ? { ...metadata } : {} + + if (link) { + nextMetadata[HOME_ASSISTANT_LINK_METADATA_KEY] = link + return nextMetadata + } + + delete nextMetadata[HOME_ASSISTANT_LINK_METADATA_KEY] + return nextMetadata +} + +export function getHomeAssistantActionPresentation(link: HomeAssistantLink | null) { + if (!link || !link.haEntityId) { + return null + } + + const presentation = buildHomeAssistantActionPresentation({ + actionKind: link.actionKind, + fallbackLabel: link.actionLabel, + serviceDomain: link.serviceDomain, + serviceName: link.serviceName, + }) + + return { + icon: presentation.icon, + label: presentation.label, + } +} + +export function requestHomeAssistantDeviceAction(item: ItemNode, link: HomeAssistantLink) { + if (typeof window === 'undefined') { + return + } + + window.dispatchEvent( + new CustomEvent(PASCAL_HA_DEVICE_ACTION_REQUEST_EVENT, { + detail: { + itemId: item.id, + itemName: item.asset.name, + link, + }, + }), + ) +} diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 6eea48f34..1358fde57 100755 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -1,5 +1,6 @@ 'use client' +import type { Collection, CollectionId } from '@pascal-app/core' import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor, { @@ -9,6 +10,7 @@ import useEditor, { } from '../store/use-editor' export type SceneGraph = { + collections?: Record nodes: Record rootNodeIds: string[] } @@ -365,8 +367,8 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { if (hasUsableSceneGraph(sceneGraph)) { - const { nodes, rootNodeIds } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any) + const { collections, nodes, rootNodeIds } = sceneGraph + useScene.getState().setScene(nodes as any, rootNodeIds as any, collections as any) } else { useScene.getState().clearScene() } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 58f7a4852..b1e919289 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -151,6 +151,8 @@ type EditorState = { setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + homeAssistantControlItemId: string | null + setHomeAssistantControlItemId: (id: string | null) => void // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void @@ -532,6 +534,8 @@ const useEditor = create()( setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + homeAssistantControlItemId: null, + setHomeAssistantControlItemId: (id) => set({ homeAssistantControlItemId: id }), spaces: {}, setSpaces: (spaces) => set({ spaces }), editingHole: null, diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 1c214282c..d570a23a8 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -26,6 +26,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", + "react-dom": "^18 || ^19", "three": "^0.184" }, "dependencies": { @@ -36,6 +37,7 @@ "@pascal/typescript-config": "*", "@types/node": "^25.5.0", "@types/react": "^19.2.2", + "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "5.9.3" }, diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 51e17f4a6..77693fc97 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -34,6 +34,7 @@ function createCeilingMaterials(color = '#999999') { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) + const visible = node.visible && !node.autoFromWalls useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') @@ -46,7 +47,7 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( - + { const ref = useRef(null!) + const visible = node.visible && !node.autoFromWalls useRegistry(node.id, 'slab', ref) @@ -62,7 +63,7 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { ref={ref} {...handlers} material={material} - visible={node.visible} + visible={visible} > diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index f51c4dd54..abb873d0a 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -277,6 +277,7 @@ export const SelectionManager = () => { useEffect(() => { const onEnter = (event: NodeEvent) => { + if (useViewer.getState().roomControlOverlayActive) return const strategy = getStrategy() if (!strategy) return if (strategy.isValid(event.node)) { @@ -290,6 +291,7 @@ export const SelectionManager = () => { } const onLeave = (event: NodeEvent) => { + if (useViewer.getState().roomControlOverlayActive) return const strategy = getStrategy() if (!strategy) return if (strategy.isValid(event.node)) { @@ -299,6 +301,7 @@ export const SelectionManager = () => { } const onClick = (event: NodeEvent) => { + if (useViewer.getState().roomControlOverlayActive) return const strategy = getStrategy() if (!strategy) return if (!strategy.isValid(event.node)) return @@ -359,6 +362,7 @@ const PointerMissedHandler = ({ const handleClick = (event: MouseEvent) => { // Only handle left clicks if (useViewer.getState().cameraDragging) return + if (useViewer.getState().roomControlOverlayActive) return if (event.button !== 0) return // Use requestAnimationFrame to check after R3F event handlers @@ -391,6 +395,7 @@ const PointerMissedHandler = ({ const OutlinerSync = () => { const selection = useViewer((s) => s.selection) const hoveredId = useViewer((s) => s.hoveredId) + const hoveredIds = useViewer((s) => s.hoveredIds) const outliner = useViewer((s) => s.outliner) const nodes = useScene((s) => s.nodes) @@ -406,13 +411,15 @@ const OutlinerSync = () => { // Sync hovered objects outliner.hoveredObjects.length = 0 - if (hoveredId) { - const hoveredNode = nodes[hoveredId as AnyNodeId] - if (hoveredNode?.type === 'slab') return - const obj = sceneRegistry.nodes.get(hoveredId) + for (const id of new Set([hoveredId, ...hoveredIds].filter(Boolean))) { + const hoveredNode = nodes[id as AnyNodeId] + if (hoveredNode?.type === 'slab') { + continue + } + const obj = sceneRegistry.nodes.get(id as string) if (obj) outliner.hoveredObjects.push(obj) } - }, [selection, hoveredId, outliner, nodes]) + }, [selection, hoveredId, hoveredIds, outliner, nodes]) return null } diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 133ba34be..8e99446fa 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -56,6 +56,16 @@ type NodeConfig = { type NodeType = keyof NodeConfig export function useNodeEvents(node: NodeConfig[T]['node'], type: T) { + const areNodeEventsSuppressed = () => { + const viewerState = useViewer.getState() + if (viewerState.nodeEventsSuppressed) { + return true + } + + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + return viewerState.nodeEventsSuppressedUntil > now + } + const emit = (suffix: EventSuffix, e: ThreeEvent) => { const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` const localPoint = e.object.worldToLocal(e.point.clone()) @@ -75,11 +85,13 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t return { onPointerDown: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerup', e) @@ -88,26 +100,32 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t emit('click', e) }, onClick: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return // Disable default R3F click since we synthesize it on pointerup // This prevents double-clicks from firing twice. }, onPointerEnter: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return emit('enter', e) }, onPointerLeave: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return emit('leave', e) }, onPointerMove: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return emit('move', e) }, onDoubleClick: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return emit('double-click', e) }, onContextMenu: (e: ThreeEvent) => { + if (areNodeEventsSuppressed()) return if (useViewer.getState().cameraDragging) return emit('context-menu', e) }, diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 4dde28a6b..e757f6f4a 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -14,10 +14,17 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + nodeEventsSuppressed: boolean + nodeEventsSuppressedUntil: number + suppressNodeEvents: (durationMs?: number) => void + roomControlOverlayActive: boolean + setRoomControlOverlayActive: (active: boolean) => void hoverHighlightMode: 'default' | 'delete' setHoverHighlightMode: (mode: 'default' | 'delete') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null + hoveredIds: Array setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void + setHoveredIds: (ids: Array) => void cameraMode: 'perspective' | 'orthographic' setCameraMode: (mode: 'perspective' | 'orthographic') => void levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 070ec71d4..d12972802 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -22,10 +22,17 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + nodeEventsSuppressed: boolean + nodeEventsSuppressedUntil: number + suppressNodeEvents: (durationMs?: number) => void + roomControlOverlayActive: boolean + setRoomControlOverlayActive: (active: boolean) => void hoverHighlightMode: 'default' | 'delete' setHoverHighlightMode: (mode: 'default' | 'delete') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null + hoveredIds: Array setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void + setHoveredIds: (ids: Array) => void cameraMode: 'perspective' | 'orthographic' setCameraMode: (mode: 'perspective' | 'orthographic') => void @@ -84,10 +91,22 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), + nodeEventsSuppressed: false, + nodeEventsSuppressedUntil: 0, + suppressNodeEvents: (durationMs = 250) => + set({ + nodeEventsSuppressedUntil: + (typeof performance !== 'undefined' ? performance.now() : Date.now()) + durationMs, + }), + roomControlOverlayActive: false, + setRoomControlOverlayActive: (roomControlOverlayActive) => + set({ roomControlOverlayActive }), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), hoveredId: null, + hoveredIds: [], setHoveredId: (id) => set({ hoveredId: id }), + setHoveredIds: (ids) => set({ hoveredIds: ids }), cameraMode: 'perspective', setCameraMode: (mode) => set({ cameraMode: mode }), diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index f85726d5d..81ce42885 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -2,191 +2,3692 @@ import { type AnyNodeId, + type Collection, + type CollectionCapability, + type CollectionHomeAssistantActionRequest, + type CollectionId, + type CollectionZoneId, type Control, type ControlValue, type ItemNode, pointInPolygon, + resolveLevelId, sceneRegistry, useInteractive, useScene, type ZoneNode, } from '@pascal-app/core' import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' import { type Object3D, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' -const _tempVec = new Vector3() +const PANEL_CLOSED_MIN_WIDTH = 56 +const PANEL_CLOSED_MAX_WIDTH = 148 +const PANEL_CLOSED_CHAR_WIDTH = 6.6 +const PANEL_CLOSED_HEIGHT = 32 +const PANEL_OPEN_MIN_WIDTH = 120 +const PANEL_HEADER_HEIGHT = 38 +const PANEL_HORIZONTAL_PADDING = 12 +const PANEL_GAP = 16 +const PANEL_BOTTOM_MARGIN = 12 +const PANEL_BODY_PADDING = 8 +const PANEL_GRID_GAP = 6 +const CONTROL_ICON_BUTTON_SIZE = 44 +const CONTROL_ICON_SIZE = 20 +const PANEL_MAX_COLUMNS = 8 +const PANEL_PREFERRED_MAX_ROWS = 3 +const LINE_GAP = 4 +const LINE_END_MARGIN = 12 +const OFFSCREEN_MARGIN = 64 +const POSITION_EPSILON = 0.5 +const MERGE_HOTSPOT_INSET_RATIO = 0.08 +const GROUP_EXPAND_HOLD_MS = 750 +const GROUP_EXPAND_DRAG_THRESHOLD_PX = 18 +const EDIT_EXIT_ACTION_SUPPRESS_MS = 260 +const LONG_PRESS_CLICK_SUPPRESS_MS = 900 +const ROOM_PANEL_NODE_EVENT_SUPPRESS_MS = 260 +const ROOM_PANEL_LONG_PRESS_NODE_EVENT_SUPPRESS_MS = + GROUP_EXPAND_HOLD_MS + ROOM_PANEL_NODE_EVENT_SUPPRESS_MS +const ROOM_PANEL_CENTER_ALIGNMENT_THRESHOLD = 0.76 +const ROOM_PANEL_OPEN_CENTER_ALIGNMENT_THRESHOLD = 0.62 +const EXPANDED_GROUP_PADDING = 6 +const EXPANDED_GROUP_GAP = 4 +const INTENSITY_STRIP_INSET = 4 +const INTENSITY_STRIP_HEIGHT = 8 +const INTENSITY_STRIP_GAP = 4 +const INTENSITY_SEGMENT_BOUNDARY_INSET = 2 +const INTENSITY_CONTENT_BOTTOM_OFFSET = INTENSITY_STRIP_INSET + INTENSITY_STRIP_HEIGHT + 2 +const ROOM_GROUPS_STORAGE_KEY = 'pascal-room-control-groups:v1' + +const _anchor = new Vector3() +const _cameraForward = new Vector3() +const _cameraToAnchor = new Vector3() +const _projected = new Vector3() + +const overlayRootStyle: CSSProperties = { + position: 'absolute', + inset: 0, + overflow: 'hidden', + pointerEvents: 'none', +} + +const overlayItemStyle: CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', +} + +const panelBaseStyle: CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + transform: 'translateX(-50%)', + borderRadius: 18, + border: '1px solid rgba(92,98,108,1)', + background: 'linear-gradient(180deg, rgba(237,239,243,1) 0%, rgba(216,220,226,1) 100%)', + boxShadow: 'inset -4px 0 0 rgba(92,98,108,1), 0 12px 24px rgba(0,0,0,0.24)', + overflow: 'hidden', + userSelect: 'none', + opacity: 0, + transformOrigin: 'top center', + transition: + 'width 180ms cubic-bezier(0.22, 1, 0.36, 1), height 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 120ms linear', + visibility: 'hidden', +} + +const lineStyle: CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + width: 2, + marginLeft: -1, + borderRadius: 999, + background: 'rgba(70,74,82,0.92)', + boxShadow: 'none', + opacity: 0, + visibility: 'hidden', +} + +const endpointStyle: CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + width: 6, + height: 6, + marginLeft: -3, + marginTop: -3, + borderRadius: '50%', + border: '1px solid rgba(70,74,82,0.92)', + background: 'rgba(70,74,82,0.92)', + boxShadow: 'none', + opacity: 0, + visibility: 'hidden', +} + +const headerRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: PANEL_HEADER_HEIGHT, + padding: `0 ${PANEL_HORIZONTAL_PADDING}px`, + borderBottom: '1px solid rgba(255,255,255,0.16)', +} + +const headerMainButtonStyle: CSSProperties = { + width: '100%', + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'transparent', + color: 'rgba(18,20,24,0.96)', + cursor: 'pointer', + padding: 0, + textAlign: 'center', +} + +const collapsedHeaderButtonStyle: CSSProperties = { + ...headerMainButtonStyle, + width: '100%', + minHeight: PANEL_CLOSED_HEIGHT, + padding: `0 ${PANEL_HORIZONTAL_PADDING}px`, +} + +const headerNameStyle: CSSProperties = { + width: '100%', + overflow: 'hidden', + fontSize: 12, + fontWeight: 700, + letterSpacing: '0.02em', + lineHeight: 1, + textAlign: 'center', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +const panelBodyStyle: CSSProperties = { + display: 'grid', + alignContent: 'start', + gap: PANEL_GRID_GAP, + gridAutoFlow: 'row dense', + gridAutoRows: CONTROL_ICON_BUTTON_SIZE, + justifyContent: 'start', + padding: PANEL_BODY_PADDING, +} + +const emptyStateStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: CONTROL_ICON_BUTTON_SIZE, + borderRadius: 10, + border: '1px dashed rgba(92,98,108,0.45)', + background: 'rgba(255,255,255,0.32)', + color: 'rgba(55,65,81,0.78)', + fontSize: 10, + fontWeight: 600, + textAlign: 'center', + padding: 8, +} + +const compactControlButtonStyle: CSSProperties = { + boxSizing: 'border-box', + width: CONTROL_ICON_BUTTON_SIZE, + height: CONTROL_ICON_BUTTON_SIZE, + borderRadius: 10, + display: 'grid', + placeItems: 'center', + position: 'relative', + padding: 0, +} + +const iconGlyphWrapStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: CONTROL_ICON_SIZE, + height: CONTROL_ICON_SIZE, +} + +const groupedIconGlyphRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 'auto', + height: CONTROL_ICON_SIZE, + padding: 0, + gap: 4, +} + +const glyphContentRowStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 'auto', + maxWidth: '100%', + gap: 2, +} + +const segmentedGlyphContentStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'stretch', + width: '100%', + height: '100%', + gap: 0, +} + +const segmentedGlyphLaneStyle: CSSProperties = { + flex: '1 1 0', + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +} + +const intensityStripStyle: CSSProperties = { + position: 'absolute', + left: INTENSITY_STRIP_INSET, + right: INTENSITY_STRIP_INSET, + bottom: INTENSITY_STRIP_INSET, + height: INTENSITY_STRIP_HEIGHT, + display: 'flex', + alignItems: 'stretch', + gap: 0, + pointerEvents: 'auto', +} + +const intensitySegmentLaneStyle: CSSProperties = { + flex: '1 1 0', + minWidth: 0, + display: 'flex', + alignItems: 'stretch', + justifyContent: 'stretch', +} + +const intensitySegmentTrackStyle: CSSProperties = { + position: 'relative', + flex: 1, + minWidth: 0, + overflow: 'hidden', + borderRadius: 999, + boxSizing: 'border-box', + cursor: 'ew-resize', + touchAction: 'none', +} + +const intensitySegmentFillStyle: CSSProperties = { + position: 'absolute', + inset: 1, + borderRadius: 999, + transformOrigin: 'left center', +} + +const intensitySegmentThumbStyle: CSSProperties = { + position: 'absolute', + top: 1, + bottom: 1, + width: 4, + marginLeft: -2, + borderRadius: 999, + background: 'rgba(255,255,255,0.92)', + boxShadow: '0 0 0 1px rgba(15,23,42,0.18), 0 1px 4px rgba(15,23,42,0.18)', +} + +const iconCountBadgeStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: CONTROL_ICON_SIZE + 4, + height: CONTROL_ICON_SIZE + 4, + padding: '0 3px', + marginLeft: -1, + borderRadius: 999, + border: '1px solid rgba(92,98,108,0.22)', + fontSize: CONTROL_ICON_SIZE - 2, + fontWeight: 800, + lineHeight: 1, + letterSpacing: '-0.03em', + fontVariantNumeric: 'tabular-nums', + boxShadow: '0 1px 3px rgba(15,23,42,0.12)', +} + +const editTileStyle: CSSProperties = { + ...compactControlButtonStyle, + border: '1px solid rgba(92,98,108,0.55)', + background: 'linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(230,233,239,0.96) 100%)', + boxShadow: 'inset -3px 0 0 rgba(92,98,108,0.75), 0 8px 18px rgba(0,0,0,0.1)', + color: 'rgba(31,41,55,0.92)', + cursor: 'grab', +} + +const dragGhostStyle: CSSProperties = { + position: 'fixed', + top: 0, + left: 0, + width: CONTROL_ICON_BUTTON_SIZE, + height: CONTROL_ICON_BUTTON_SIZE, + transform: 'translate(-50%, -50%) rotate(-3deg)', + pointerEvents: 'none', + zIndex: 400, + opacity: 0.94, +} + +const editModeAnimationCss = ` +@keyframes room-panel-edit-wobble { + 0% { transform: translate3d(0, 0, 0) rotate(-1.4deg); } + 100% { transform: translate3d(0, -1px, 0) rotate(1.4deg); } +} +` + +type RoomControlTile = { + collectionBinding?: Collection['homeAssistant'] + collectionId: CollectionId + collectionLabel: string + control: Control + controlIndex: number + id: string + intensityControl: Extract | null + intensityControlIndex: number | null + itemId: AnyNodeId + itemKind: string + itemName: string +} + +type RoomControlIntensityTile = RoomControlTile & { + intensityControl: Extract + intensityControlIndex: number +} + +type GroupIntensitySegment = { + itemKind: string + key: string + members: RoomControlIntensityTile[] + ratio: number +} + +type GroupVisualSegment = { + count: number + itemKind: string +} + +type RoomControlGroupKind = 'toggle' | 'numeric' | 'mixed' -// ---- Parent: one overlay per interactive item ---- +type RoomControlGroup = { + binding?: Collection['homeAssistant'] + collectionId?: CollectionId + controlKind: RoomControlGroupKind + displayName?: string + id: string + itemIds: AnyNodeId[] + members: RoomControlTile[] +} + +type PanelBodyMetrics = { + bodyHeight: number + bodyWidth: number + columns: number + rows: number +} + +type RoomOverlayNode = { + controlGroups: RoomControlGroup[] + id: AnyNodeId + node: ZoneNode + roomName: string + totalSlotCount: number +} + +type OverlayLayout = { + opacity: number + panelHeight: number + panelTop: number + panelWidth: number + visible: boolean + x: number + y: number +} + +type OverlayDomRefs = { + endpoint: HTMLDivElement | null + line: HTMLDivElement | null + panel: HTMLDivElement | null +} + +type DragState = { + startedAt: number + startX: number + startY: number + pointerX: number + pointerY: number + dropTargetGroupId: string | null + placeAfterTarget: boolean + sourceGroupId: string + sourceMemberId: string | null + targetGroupId: string | null +} + +type LongPressAction = 'edit' | 'open-edit' + +type PendingExpandState = { + groupId: string + pointerId: number + startedAt: number + startX: number + startY: number +} + +type PendingLongPressState = { + action: LongPressAction + dragGroupId: string | null + key: string + pointerId: number + pointerX: number + pointerY: number + startedAt: number + startX: number + startY: number +} + +type StoredRoomGroupsState = Record + +type ExpandedGroupMemberLayout = { + buttonSize: number + columns: number +} + +const projectToViewportOrigin = () => [0, 0] as [number, number] + +const suppressRoomPanelNodeEvents = (durationMs = ROOM_PANEL_NODE_EVENT_SUPPRESS_MS) => { + useViewer.getState().suppressNodeEvents(durationMs) +} + +const isQuickEditTap = ( + startedAt: number, + startX: number, + startY: number, + pointerX: number, + pointerY: number, +) => + Date.now() - startedAt < GROUP_EXPAND_HOLD_MS && + Math.hypot(pointerX - startX, pointerY - startY) < GROUP_EXPAND_DRAG_THRESHOLD_PX + +const isPointInsideElement = (clientX: number, clientY: number, element: Element | null) => { + if (!(element instanceof HTMLElement)) { + return false + } + + const rect = element.getBoundingClientRect() + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) +} + +const findRoomControlGroupElement = (groupId: string) => { + if (typeof document === 'undefined') { + return null + } + + for (const element of document.querySelectorAll('[data-room-control-group-id]')) { + if (element.dataset.roomControlGroupId === groupId) { + return element + } + } + + return null +} + +const getReorderPlacement = ( + pointerX: number, + pointerY: number, + sourceElement: HTMLElement, + targetElement: HTMLElement, +) => { + const sourceRect = sourceElement.getBoundingClientRect() + const targetRect = targetElement.getBoundingClientRect() + const sourceCenterX = sourceRect.left + sourceRect.width / 2 + const sourceCenterY = sourceRect.top + sourceRect.height / 2 + const targetCenterX = targetRect.left + targetRect.width / 2 + const targetCenterY = targetRect.top + targetRect.height / 2 + const deltaX = targetCenterX - sourceCenterX + const deltaY = targetCenterY - sourceCenterY + + if (Math.abs(deltaX) >= Math.abs(deltaY)) { + const placeAfter = deltaX >= 0 + const ready = placeAfter ? pointerX > targetCenterX : pointerX < targetCenterX + return { placeAfter, ready } + } + + const placeAfter = deltaY >= 0 + const ready = placeAfter ? pointerY > targetCenterY : pointerY < targetCenterY + return { placeAfter, ready } +} export const InteractiveSystem = () => { - const interactiveNodeIds = useScene( + const selectedLevelId = useViewer((state) => state.selection.levelId) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const setHoveredId = useViewer((state) => state.setHoveredId) + const setHoveredIds = useViewer((state) => state.setHoveredIds) + const sceneNodes = useScene((state) => state.nodes) + const sceneCollections = useScene((state) => state.collections) + const zoneNodes = useScene( + useShallow((state) => + Object.values(state.nodes).filter((node): node is ZoneNode => node.type === 'zone'), + ), + ) + const interactiveNodes = useScene( useShallow((state) => - Object.values(state.nodes) - .filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null) - .map((n) => n.id), + Object.values(state.nodes).filter( + (node): node is ItemNode => + node.type === 'item' && Boolean(node.asset.interactive?.controls.length), + ), ), ) + const interactiveState = useInteractive((state) => state.items) + const initItem = useInteractive((state) => state.initItem) + const setControlValue = useInteractive((state) => state.setControlValue) + const [openRoomId, setOpenRoomId] = useState(null) + const [editingRoomId, setEditingRoomId] = useState(null) + const [storedRoomGroups, setStoredRoomGroups] = useState({}) + const [storedRoomGroupsReady, setStoredRoomGroupsReady] = useState(false) - return ( - <> - {interactiveNodeIds.map((id) => ( - - ))} - + const domRefsRef = useRef>({}) + const layoutRef = useRef>({}) + const pendingCollectionActionTimeoutsRef = useRef>({}) + + useEffect(() => { + for (const node of interactiveNodes) { + const interactive = node.asset.interactive + if (interactive && interactive.controls.length > 0) { + initItem(node.id, interactive) + } + } + }, [initItem, interactiveNodes]) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + setStoredRoomGroups(readStoredRoomGroups()) + setStoredRoomGroupsReady(true) + }, []) + + useEffect(() => { + if (!(storedRoomGroupsReady && typeof window !== 'undefined')) { + return + } + + if (Object.keys(storedRoomGroups).length === 0) { + window.localStorage.removeItem(ROOM_GROUPS_STORAGE_KEY) + return + } + + window.localStorage.setItem(ROOM_GROUPS_STORAGE_KEY, JSON.stringify(storedRoomGroups)) + }, [storedRoomGroups, storedRoomGroupsReady]) + + const roomOverlayNodes = useMemo( + () => + zoneNodes + .filter( + (node) => + node.polygon.length >= 3 && + (!selectedLevelId || resolveLevelId(node, sceneNodes) === selectedLevelId), + ) + .map((node) => { + const collectionEntries = Object.values(sceneCollections) + .filter((collection) => collectionHasBoundResources(collection)) + .filter((collection) => isCollectionVisibleInZone(collection, node, sceneNodes)) + .sort((left, right) => compareCollectionsForRoom(left, right)) + const collectionControlEntries = collectionEntries.map((collection) => ({ + collection, + controls: buildCollectionRoomControlTiles(collection, sceneNodes), + })) + const roomControls = collectionControlEntries.flatMap((entry) => entry.controls) + const defaultGroups = collectionControlEntries + .map((entry) => entry.controls.map((control) => control.id)) + .filter((group) => group.length > 0) + const storedGroups = storedRoomGroups[node.id] ?? defaultGroups + + return { + controlGroups: buildRoomControlGroups(roomControls, storedGroups), + id: node.id, + node, + roomName: node.name.trim() || 'Room', + totalSlotCount: roomControls.length, + } + }) + .sort((left, right) => left.roomName.localeCompare(right.roomName)), + [sceneCollections, sceneNodes, selectedLevelId, storedRoomGroups, zoneNodes], ) -} -// ---- Child: polls sceneRegistry then portals controls into the item group ---- + useEffect(() => { + const activeIds = new Set(roomOverlayNodes.map((node) => node.id)) -const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => { - const node = useScene((state) => state.nodes[nodeId] as ItemNode) - const [itemObj, setItemObj] = useState(null) + for (const id of Object.keys(domRefsRef.current)) { + if (!activeIds.has(id as AnyNodeId)) { + delete domRefsRef.current[id] + } + } - useFrame(() => { - if (itemObj) return - const obj = sceneRegistry.nodes.get(nodeId) - if (obj) setItemObj(obj) - }) + for (const id of Object.keys(layoutRef.current)) { + if (!activeIds.has(id as AnyNodeId)) { + delete layoutRef.current[id] + } + } + }, [roomOverlayNodes]) - const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues)) - const setControlValue = useInteractive((state) => state.setControlValue) + useEffect(() => { + if (openRoomId && !roomOverlayNodes.some((room) => room.id === openRoomId)) { + setOpenRoomId(null) + } + }, [openRoomId, roomOverlayNodes]) + + useEffect(() => { + if (!editingRoomId) { + return + } + if (editingRoomId !== openRoomId) { + setEditingRoomId(null) + } + }, [editingRoomId, openRoomId]) + + useEffect( + () => () => { + setHoveredId(null) + setHoveredIds([]) + }, + [setHoveredId, setHoveredIds], + ) + + useEffect(() => { + useViewer.getState().setRoomControlOverlayActive(openRoomId !== null) + }, [openRoomId]) + + useEffect(() => { + if (openRoomId && selectedIds.length > 0) { + useViewer.getState().setSelection({ selectedIds: [] }) + } + }, [openRoomId, selectedIds]) + + useEffect( + () => () => { + useViewer.getState().setRoomControlOverlayActive(false) + }, + [], + ) - const zoneId = useViewer((s) => s.selection.zoneId) - const zonePolygon = useScene((s) => { - if (!zoneId) return null - const z = s.nodes[zoneId] as ZoneNode | undefined - return z?.polygon ?? null + useEffect(() => { + if (!openRoomId) { + return + } + + const handlePointerDown = (event: PointerEvent) => { + const panel = domRefsRef.current[openRoomId]?.panel + if (panel && event.target instanceof Node && panel.contains(event.target)) { + return + } + setOpenRoomId(null) + setEditingRoomId(null) + setHoveredId(null) + setHoveredIds([]) + } + + window.addEventListener('pointerdown', handlePointerDown) + return () => window.removeEventListener('pointerdown', handlePointerDown) + }, [openRoomId, setHoveredId, setHoveredIds]) + + const roomControlMemberLookup = useMemo(() => { + const lookup = new Map() + for (const roomOverlayNode of roomOverlayNodes) { + for (const group of roomOverlayNode.controlGroups) { + for (const member of group.members) { + const key = `${member.itemId}:${member.controlIndex}` + if (!lookup.has(key)) { + lookup.set(key, member) + } + } + } + } + return lookup + }, [roomOverlayNodes]) + + useEffect( + () => () => { + if (typeof window === 'undefined') { + return + } + for (const timeoutId of Object.values(pendingCollectionActionTimeoutsRef.current)) { + window.clearTimeout(timeoutId) + } + pendingCollectionActionTimeoutsRef.current = {} + }, + [], + ) + + const scheduleCollectionAction = (member: RoomControlTile, nextValue: ControlValue) => { + const collection = sceneCollections[member.collectionId] + const binding = collection?.homeAssistant + if (!(collection && binding) || typeof window === 'undefined') { + return + } + + const request = buildCollectionActionRequest(collection, member, nextValue) + if (!request) { + return + } + + const existingTimeoutId = pendingCollectionActionTimeoutsRef.current[member.collectionId] + if (existingTimeoutId) { + window.clearTimeout(existingTimeoutId) + } + + const delayMs = request.kind === 'range' ? 120 : 0 + pendingCollectionActionTimeoutsRef.current[member.collectionId] = window.setTimeout(() => { + void fetch('/api/home-assistant/device-action', { + body: JSON.stringify({ + binding, + collectionName: getCollectionDisplayName(collection), + request, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }).catch(() => {}) + if (request.kind === 'trigger' && member.control.kind === 'toggle') { + window.setTimeout(() => { + setControlValue(member.itemId, member.controlIndex, false) + }, 220) + } + delete pendingCollectionActionTimeoutsRef.current[member.collectionId] + }, delayMs) + } + + const handleCollectionControlChange = ( + itemId: AnyNodeId, + controlIndex: number, + nextValue: ControlValue, + ) => { + setControlValue(itemId, controlIndex, nextValue) + const member = roomControlMemberLookup.get(`${itemId}:${controlIndex}`) + if (member) { + scheduleCollectionAction(member, nextValue) + } + } + + useFrame(({ camera, size }) => { + if (roomOverlayNodes.length === 0) { + for (const refs of Object.values(domRefsRef.current)) { + applyOverlayLayout(refs, { + opacity: 0, + panelHeight: PANEL_CLOSED_HEIGHT, + panelTop: 0, + panelWidth: PANEL_CLOSED_MIN_WIDTH, + visible: false, + x: 0, + y: 0, + }) + } + layoutRef.current = {} + return + } + + camera.getWorldDirection(_cameraForward) + for (const roomOverlayNode of roomOverlayNodes) { + const refs = domRefsRef.current[roomOverlayNode.id] + const zoneObject = sceneRegistry.nodes.get(roomOverlayNode.id) + const open = openRoomId === roomOverlayNode.id + const metrics = getRoomPanelMetrics( + open, + roomOverlayNode.controlGroups, + roomOverlayNode.totalSlotCount, + roomOverlayNode.roomName, + ) + + if (!zoneObject) { + const hiddenLayout = { + opacity: 0, + panelHeight: metrics.height, + panelTop: 0, + panelWidth: metrics.width, + visible: false, + x: 0, + y: 0, + } + applyOverlayLayout(refs, hiddenLayout) + layoutRef.current[roomOverlayNode.id] = hiddenLayout + continue + } + + zoneObject.updateWorldMatrix(true, false) + + const [anchorX, anchorY, anchorZ] = getZoneAnchorLocalPosition(zoneObject, roomOverlayNode.node) + _anchor.set(anchorX, anchorY, anchorZ) + zoneObject.localToWorld(_anchor) + _cameraToAnchor.copy(_anchor).sub(camera.position) + _projected.copy(_anchor).project(camera) + + const x = (_projected.x * 0.5 + 0.5) * size.width + const y = (-_projected.y * 0.5 + 0.5) * size.height + const forwardAlignment = + _cameraToAnchor.lengthSq() <= 0.0001 ? 1 : _cameraToAnchor.normalize().dot(_cameraForward) + const minimumForwardAlignment = open + ? ROOM_PANEL_OPEN_CENTER_ALIGNMENT_THRESHOLD + : ROOM_PANEL_CENTER_ALIGNMENT_THRESHOLD + const collapsedPanelTop = y - PANEL_CLOSED_HEIGHT - PANEL_GAP + const panelTop = Math.min( + Math.max(collapsedPanelTop, 14), + Math.max(14, size.height - metrics.height - PANEL_BOTTOM_MARGIN), + ) + + const visible = + forwardAlignment >= minimumForwardAlignment && + _projected.z >= -1 && + _projected.z <= 1 && + x >= -OFFSCREEN_MARGIN && + x <= size.width + OFFSCREEN_MARGIN && + y >= -OFFSCREEN_MARGIN && + y <= size.height + OFFSCREEN_MARGIN + + const layout: OverlayLayout = { + opacity: 1, + panelHeight: metrics.height, + panelTop, + panelWidth: metrics.width, + visible, + x, + y, + } + + if (!areLayoutsClose(layoutRef.current[roomOverlayNode.id], layout) || refs?.panel) { + applyOverlayLayout(refs, layout) + layoutRef.current[roomOverlayNode.id] = layout + } + } }) - if (!(itemObj && controlValues && node?.asset.interactive)) return null - - const { controls } = node.asset.interactive - const [, height] = node.asset.dimensions - - let opacity = 0 - let pointerEvents: 'auto' | 'none' = 'none' - if (zoneId && zonePolygon?.length) { - itemObj.getWorldPosition(_tempVec) - const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon) - opacity = inside ? 1 : 0.1 - pointerEvents = inside ? 'auto' : 'none' - } - - return createPortal( - -
- {controls.map((control, i) => ( - setControlValue(nodeId, i, v)} - value={controlValues[i] ?? false} - /> + if (roomOverlayNodes.length === 0) { + return null + } + + const setHoveredItemTargets = (itemIds: AnyNodeId[]) => { + const uniqueIds = Array.from(new Set(itemIds)) + setHoveredId(uniqueIds[0] ?? null) + setHoveredIds(uniqueIds) + } + + const clearHoveredItemTargets = () => { + setHoveredId(null) + setHoveredIds([]) + } + + return ( + +
+ + {roomOverlayNodes.map((roomOverlayNode) => ( +
+
setOverlayDomRef(domRefsRef.current, roomOverlayNode.id, 'line', node)} + style={lineStyle} + /> +
+ setOverlayDomRef(domRefsRef.current, roomOverlayNode.id, 'endpoint', node) + } + style={endpointStyle} + /> + { + setStoredRoomGroups((current) => ({ + ...current, + [roomOverlayNode.id]: nextGroups.filter((group) => group.length > 0), + })) + }} + onChange={handleCollectionControlChange} + onOpenIntoEdit={() => { + setOpenRoomId(roomOverlayNode.id) + setEditingRoomId(roomOverlayNode.id) + }} + onSetEditing={(editing) => + setEditingRoomId(editing ? roomOverlayNode.id : null) + } + onSetOpen={(open) => { + setOpenRoomId(open ? roomOverlayNode.id : null) + if (!open) { + setEditingRoomId(null) + clearHoveredItemTargets() + } + }} + refsStore={domRefsRef.current} + roomId={roomOverlayNode.id} + roomName={roomOverlayNode.roomName} + totalSlotCount={roomOverlayNode.totalSlotCount} + setHoveredItemTargets={setHoveredItemTargets} + /> +
))}
- , - itemObj, + ) } -// ---- Control widgets ---- - -const ControlWidget = ({ - control, - value, +const RoomPanel = ({ + clearHoveredItemTargets, + controlGroups, + controlValues, + editing, + isOpen, + onApplyGrouping, onChange, + onOpenIntoEdit, + onSetEditing, + onSetOpen, + refsStore, + roomId, + roomName, + totalSlotCount, + setHoveredItemTargets, }: { - control: Control - value: ControlValue - onChange: (v: ControlValue) => void + clearHoveredItemTargets: () => void + controlGroups: RoomControlGroup[] + controlValues: Record + editing: boolean + isOpen: boolean + onApplyGrouping: (nextGroups: string[][]) => void + onChange: (itemId: AnyNodeId, controlIndex: number, nextValue: ControlValue) => void + onOpenIntoEdit: () => void + onSetEditing: (editing: boolean) => void + onSetOpen: (open: boolean) => void + refsStore: Record + roomId: AnyNodeId + roomName: string + totalSlotCount: number + setHoveredItemTargets: (itemIds: AnyNodeId[]) => void }) => { - const labelStyle: React.CSSProperties = { - color: 'white', - fontSize: 11, - fontFamily: 'monospace', - display: 'flex', - flexDirection: 'column', - gap: 2, + const [dragState, setDragState] = useState(null) + const [expandedGroupId, setExpandedGroupId] = useState(null) + const [orderedGroupIds, setOrderedGroupIds] = useState(() => + controlGroups.map((group) => group.id), + ) + const [pendingExpand, setPendingExpand] = useState(null) + const dragStateRef = useRef(null) + const longPressRef = useRef(null) + const longPressTimeoutRef = useRef(null) + const suppressedClickRef = useRef(null) + const suppressedClickTimeoutRef = useRef(null) + const editExitActionSuppressedUntilRef = useRef(0) + const pendingExpandRef = useRef(null) + const expandTimeoutRef = useRef(null) + const groupById = useMemo( + () => new Map(controlGroups.map((group) => [group.id, group])), + [controlGroups], + ) + const orderedGroups = useMemo( + () => + reconcileGroupOrder(orderedGroupIds, controlGroups.map((group) => group.id)) + .map((groupId) => groupById.get(groupId)) + .filter((group): group is RoomControlGroup => Boolean(group)), + [controlGroups, groupById, orderedGroupIds], + ) + const displayedGroups = orderedGroups + const panelMetrics = useMemo( + () => getPanelBodyMetrics(totalSlotCount, displayedGroups), + [displayedGroups, totalSlotCount], + ) + const panelColumns = panelMetrics.columns + const currentOrderIds = useMemo( + () => orderedGroups.map((group) => group.id), + [orderedGroups], + ) + + const clearLongPress = () => { + if (typeof window !== 'undefined' && longPressTimeoutRef.current !== null) { + window.clearTimeout(longPressTimeoutRef.current) + } + longPressTimeoutRef.current = null + longPressRef.current = null } - if (control.kind === 'toggle') { - return ( - + const clearSuppressedClick = () => { + if (typeof window !== 'undefined' && suppressedClickTimeoutRef.current !== null) { + window.clearTimeout(suppressedClickTimeoutRef.current) + } + suppressedClickTimeoutRef.current = null + suppressedClickRef.current = null + } + + const scheduleSuppressedClickReset = (key: string) => { + if (typeof window === 'undefined') { + return + } + if (suppressedClickTimeoutRef.current !== null) { + window.clearTimeout(suppressedClickTimeoutRef.current) + } + suppressedClickTimeoutRef.current = window.setTimeout(() => { + if (suppressedClickRef.current === key) { + suppressedClickRef.current = null + } + suppressedClickTimeoutRef.current = null + }, LONG_PRESS_CLICK_SUPPRESS_MS) + } + + const consumeSuppressedClick = (key: string) => { + if (suppressedClickRef.current !== key) { + return false + } + clearSuppressedClick() + return true + } + + const suppressEditExitActions = () => { + editExitActionSuppressedUntilRef.current = Date.now() + EDIT_EXIT_ACTION_SUPPRESS_MS + } + + const shouldSuppressEditExitAction = () => + editExitActionSuppressedUntilRef.current > Date.now() + + const commitLongPress = (activeLongPress: PendingLongPressState) => { + suppressedClickRef.current = activeLongPress.key + scheduleSuppressedClickReset(activeLongPress.key) + clearLongPress() + if (activeLongPress.action === 'open-edit') { + onOpenIntoEdit() + return + } + if (activeLongPress.dragGroupId) { + const sourceGroup = groupById.get(activeLongPress.dragGroupId) + if (sourceGroup && sourceGroup.members.length === 1) { + setHoveredItemTargets(sourceGroup.itemIds) + startGroupDrag( + activeLongPress.dragGroupId, + activeLongPress.pointerX, + activeLongPress.pointerY, + ) + } + } + onSetEditing(true) + } + + const startLongPress = ( + event: ReactPointerEvent, + key: string, + action: LongPressAction, + dragGroupId: string | null = null, + ) => { + if (action === 'edit' && editing) { + return + } + if (event.pointerType === 'mouse' && event.button !== 0) { + return + } + + clearLongPress() + clearSuppressedClick() + event.currentTarget.setPointerCapture?.(event.pointerId) + const nextPendingLongPress = { + action, + dragGroupId, + key, + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + startedAt: Date.now(), + startX: event.clientX, + startY: event.clientY, + } + longPressRef.current = nextPendingLongPress + if (typeof window !== 'undefined') { + longPressTimeoutRef.current = window.setTimeout(() => { + const activeLongPress = longPressRef.current + if ( + !activeLongPress || + activeLongPress.pointerId !== event.pointerId || + activeLongPress.key !== key + ) { + return + } + commitLongPress(activeLongPress) + }, GROUP_EXPAND_HOLD_MS) + } + } + + const continueLongPress = (event: ReactPointerEvent, key: string) => { + const activeLongPress = longPressRef.current + if (!activeLongPress || activeLongPress.key !== key || activeLongPress.pointerId !== event.pointerId) { + return + } + + activeLongPress.pointerX = event.clientX + activeLongPress.pointerY = event.clientY + + const distance = Math.hypot( + event.clientX - activeLongPress.startX, + event.clientY - activeLongPress.startY, ) + if (distance >= GROUP_EXPAND_DRAG_THRESHOLD_PX) { + clearLongPress() + } } - if (control.kind === 'slider') { - return ( - + const endLongPress = (event: ReactPointerEvent, key: string) => { + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + const activeLongPress = longPressRef.current + if (!activeLongPress || activeLongPress.key !== key || activeLongPress.pointerId !== event.pointerId) { + return + } + clearLongPress() + } + + const clearPendingExpand = () => { + if (typeof window !== 'undefined' && expandTimeoutRef.current !== null) { + window.clearTimeout(expandTimeoutRef.current) + } + expandTimeoutRef.current = null + pendingExpandRef.current = null + setPendingExpand(null) + } + + const startGroupDrag = (groupId: string, clientX: number, clientY: number) => { + clearPendingExpand() + const nextDragState = { + startedAt: Date.now(), + startX: clientX, + startY: clientY, + pointerX: clientX, + pointerY: clientY, + dropTargetGroupId: null, + placeAfterTarget: false, + sourceGroupId: groupId, + sourceMemberId: null, + targetGroupId: null, + } + dragStateRef.current = nextDragState + setDragState(nextDragState) + } + + const startMemberDrag = ( + groupId: string, + memberId: string, + clientX: number, + clientY: number, + ) => { + clearPendingExpand() + const nextDragState = { + startedAt: Date.now(), + startX: clientX, + startY: clientY, + pointerX: clientX, + pointerY: clientY, + dropTargetGroupId: null, + placeAfterTarget: false, + sourceGroupId: groupId, + sourceMemberId: memberId, + targetGroupId: null, + } + dragStateRef.current = nextDragState + setDragState(nextDragState) + } + + useEffect(() => { + setOrderedGroupIds((current) => + reconcileGroupOrder( + current, + controlGroups.map((group) => group.id), + ), ) + }, [controlGroups]) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + pendingExpandRef.current = pendingExpand + }, [pendingExpand]) + + useEffect(() => { + if (!(editing && pendingExpand)) { + return + } + + const handlePointerMove = (event: PointerEvent) => { + const activePendingExpand = pendingExpandRef.current + if (!activePendingExpand || event.pointerId !== activePendingExpand.pointerId) { + return + } + + const distance = Math.hypot( + event.clientX - activePendingExpand.startX, + event.clientY - activePendingExpand.startY, + ) + if (distance < GROUP_EXPAND_DRAG_THRESHOLD_PX) { + return + } + + const groupId = activePendingExpand.groupId + clearPendingExpand() + startGroupDrag(groupId, event.clientX, event.clientY) + } + + const handlePointerFinish = (event: PointerEvent) => { + const activePendingExpand = pendingExpandRef.current + if (!activePendingExpand || event.pointerId !== activePendingExpand.pointerId) { + return + } + const quickTap = isQuickEditTap( + activePendingExpand.startedAt, + activePendingExpand.startX, + activePendingExpand.startY, + event.clientX, + event.clientY, + ) + clearPendingExpand() + if (quickTap) { + suppressEditExitActions() + exitEditMode() + } + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerFinish) + window.addEventListener('pointercancel', handlePointerFinish) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerFinish) + window.removeEventListener('pointercancel', handlePointerFinish) + } + }, [editing, exitEditMode, pendingExpand]) + + useEffect(() => { + if (!expandedGroupId) { + return + } + + const expandedGroup = groupById.get(expandedGroupId) + if (!expandedGroup || expandedGroup.members.length < 2) { + setExpandedGroupId(null) + } + }, [expandedGroupId, groupById]) + + useEffect(() => { + if (!(editing && expandedGroupId) || dragState) { + return + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target + if ( + target instanceof Element && + target.closest(`[data-expanded-room-control-root="${expandedGroupId}"]`) + ) { + return + } + setExpandedGroupId(null) + } + + window.addEventListener('pointerdown', handlePointerDown, true) + return () => { + window.removeEventListener('pointerdown', handlePointerDown, true) + } + }, [dragState, editing, expandedGroupId]) + + useEffect(() => { + if (!(editing && dragState)) { + return + } + + const handlePointerMove = (event: PointerEvent) => { + const activeDragState = dragStateRef.current + if (!activeDragState) { + return + } + + const hoveredElement = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest('[data-room-control-group-id]') as HTMLElement | null + const targetGroupId = hoveredElement?.dataset.roomControlGroupId ?? null + const sourceGroup = groupById.get(activeDragState.sourceGroupId) + const sourceMember = activeDragState.sourceMemberId + ? sourceGroup?.members.find((member) => member.id === activeDragState.sourceMemberId) ?? null + : null + const targetGroup = targetGroupId ? groupById.get(targetGroupId) : null + const compatibleTargetId = + hoveredElement && + targetGroup && + ((sourceMember && + targetGroup.id !== activeDragState.sourceGroupId && + canMergeControlMemberIntoGroup(sourceMember, targetGroup)) || + (sourceGroup && canMergeControlGroups(sourceGroup, targetGroup))) + ? targetGroupId + : null + const mergeTargetId = + compatibleTargetId && + hoveredElement && + isPointerInMergeHotspot(event.clientX, event.clientY, hoveredElement) + ? compatibleTargetId + : null + const reorderTargetGroupId = + targetGroupId && targetGroupId !== activeDragState.sourceGroupId ? targetGroupId : null + const sourceElement = + activeDragState.sourceMemberId == null + ? findRoomControlGroupElement(activeDragState.sourceGroupId) + : null + const reorderPlacement = + mergeTargetId == null && reorderTargetGroupId && hoveredElement && sourceElement + ? getReorderPlacement(event.clientX, event.clientY, sourceElement, hoveredElement) + : null + const placeAfterTarget = reorderPlacement?.placeAfter ?? false + + if ( + activeDragState.sourceMemberId == null && + mergeTargetId == null && + reorderTargetGroupId && + reorderPlacement?.ready + ) { + setOrderedGroupIds((current) => + moveGroupIdRelative( + current, + activeDragState.sourceGroupId, + reorderTargetGroupId, + placeAfterTarget, + ), + ) + } + + setDragState((current) => + current + ? (() => { + const nextDragState = { + ...current, + pointerX: event.clientX, + pointerY: event.clientY, + dropTargetGroupId: reorderPlacement?.ready ? reorderTargetGroupId : null, + placeAfterTarget, + targetGroupId: mergeTargetId, + } + dragStateRef.current = nextDragState + return nextDragState + })() + : current, + ) + } + + const handlePointerUp = (event: PointerEvent) => { + const activeDragState = dragStateRef.current + if (!activeDragState) { + return + } + + if ( + isQuickEditTap( + activeDragState.startedAt, + activeDragState.startX, + activeDragState.startY, + activeDragState.pointerX, + activeDragState.pointerY, + ) + ) { + suppressEditExitActions() + dragStateRef.current = null + setDragState(null) + exitEditMode() + return + } + + const sourceGroup = groupById.get(activeDragState.sourceGroupId) + const targetGroup = activeDragState.targetGroupId + ? groupById.get(activeDragState.targetGroupId) + : null + const panelElement = refsStore[roomId]?.panel + const releasedOutsidePanel = !isPointInsideElement( + event.clientX, + event.clientY, + panelElement ?? null, + ) + + if (sourceGroup && activeDragState.sourceMemberId) { + const sourceMember = sourceGroup.members.find( + (member) => member.id === activeDragState.sourceMemberId, + ) + const sourceRemainingIds = sourceGroup.members + .filter((member) => member.id !== activeDragState.sourceMemberId) + .map((member) => member.id) + + if ( + sourceMember && + targetGroup && + canMergeControlMemberIntoGroup(sourceMember, targetGroup) + ) { + const nextSourceGroupId = sourceRemainingIds.join('|') + const mergedMemberIds = [ + ...targetGroup.members.map((member) => member.id), + sourceMember.id, + ] + const nextTargetGroupId = mergedMemberIds.join('|') + + setOrderedGroupIds( + currentOrderIds.flatMap((groupId) => { + if (groupId === sourceGroup.id) { + return sourceRemainingIds.length > 0 ? [nextSourceGroupId] : [] + } + if (groupId === targetGroup.id) { + return [nextTargetGroupId] + } + return [groupId] + }), + ) + + onApplyGrouping( + controlGroups.flatMap((group) => { + if (group.id === sourceGroup.id) { + return sourceRemainingIds.length > 0 ? [sourceRemainingIds] : [] + } + if (group.id === targetGroup.id) { + return [mergedMemberIds] + } + return [group.members.map((member) => member.id)] + }), + ) + } else if (sourceMember && sourceRemainingIds.length > 0) { + const nextSourceGroupId = sourceRemainingIds.join('|') + const memberGroupId = sourceMember.id + const reorderAnchorId = + activeDragState.dropTargetGroupId && activeDragState.dropTargetGroupId !== sourceGroup.id + ? activeDragState.dropTargetGroupId + : nextSourceGroupId + const nextOrderIds = moveGroupIdRelative( + [ + ...currentOrderIds.flatMap((groupId) => + groupId === sourceGroup.id ? [nextSourceGroupId] : [groupId], + ), + memberGroupId, + ], + memberGroupId, + reorderAnchorId, + reorderAnchorId === nextSourceGroupId ? true : activeDragState.placeAfterTarget, + ) + + setOrderedGroupIds(nextOrderIds) + onApplyGrouping( + controlGroups.flatMap((group) => { + if (group.id === sourceGroup.id) { + return [sourceRemainingIds, [sourceMember.id]] + } + return [group.members.map((member) => member.id)] + }), + ) + } + + setExpandedGroupId(null) + } else if (sourceGroup && sourceGroup.members.length > 1 && releasedOutsidePanel) { + const splitMemberIds = sourceGroup.members.map((member) => member.id) + + setOrderedGroupIds((current) => + current.flatMap((groupId) => (groupId === sourceGroup.id ? splitMemberIds : [groupId])), + ) + + onApplyGrouping( + controlGroups.flatMap((group) => + group.id === sourceGroup.id + ? splitMemberIds.map((memberId) => [memberId]) + : [group.members.map((member) => member.id)], + ), + ) + } else if (sourceGroup && targetGroup && canMergeControlGroups(sourceGroup, targetGroup)) { + const mergedMemberIds = [ + ...targetGroup.members.map((member) => member.id), + ...sourceGroup.members.map((member) => member.id), + ] + const mergedGroupId = mergedMemberIds.join('|') + + setOrderedGroupIds((current) => + current.flatMap((groupId) => { + if (groupId === sourceGroup.id) { + return [] + } + if (groupId === targetGroup.id) { + return [mergedGroupId] + } + return [groupId] + }), + ) + + onApplyGrouping( + controlGroups + .filter((group) => group.id !== sourceGroup.id) + .map((group) => + group.id === targetGroup.id + ? mergedMemberIds + : group.members.map((member) => member.id), + ), + ) + } + + dragStateRef.current = null + setDragState(null) + clearHoveredItemTargets() + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + }, [ + clearHoveredItemTargets, + controlGroups, + currentOrderIds, + dragState, + editing, + exitEditMode, + groupById, + onApplyGrouping, + orderedGroups, + refsStore, + roomId, + suppressEditExitActions, + ]) + + useEffect(() => { + if (!editing) { + setDragState(null) + setExpandedGroupId(null) + clearPendingExpand() + } + }, [editing]) + + useEffect( + () => () => { + clearLongPress() + clearSuppressedClick() + }, + [], + ) + + useEffect(() => { + if (!dragState) { + return + } + + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + document.body.style.cursor = 'grabbing' + document.body.style.userSelect = 'none' + + return () => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + } + }, [dragState]) + + const currentDragGroup = dragState ? groupById.get(dragState.sourceGroupId) ?? null : null + const currentDragMember = + dragState?.sourceMemberId && currentDragGroup + ? currentDragGroup.members.find((member) => member.id === dragState.sourceMemberId) ?? null + : null + const openHeaderKey = `${roomId}:header-open` + const closeHeaderKey = `${roomId}:header-close` + const getGroupLongPressKey = (groupId: string) => `${roomId}:group:${groupId}` + + const handleHeaderPointerDown = ( + event: ReactPointerEvent, + action: LongPressAction, + key: string, + ) => { + event.stopPropagation() + suppressRoomPanelNodeEvents(ROOM_PANEL_LONG_PRESS_NODE_EVENT_SUPPRESS_MS) + startLongPress(event, key, action) } - if (control.kind === 'temperature') { - return ( - + const handleHeaderPointerMove = ( + event: ReactPointerEvent, + key: string, + ) => { + continueLongPress(event, key) + } + + const handleHeaderPointerEnd = ( + event: ReactPointerEvent, + key: string, + ) => { + suppressRoomPanelNodeEvents() + endLongPress(event, key) + } + + const handleControlPointerDown = ( + groupId: string, + event: ReactPointerEvent, + ) => { + event.stopPropagation() + suppressRoomPanelNodeEvents(ROOM_PANEL_LONG_PRESS_NODE_EVENT_SUPPRESS_MS) + startLongPress(event, getGroupLongPressKey(groupId), 'edit', groupId) + } + + const handleControlPointerMove = ( + groupId: string, + event: ReactPointerEvent, + ) => { + continueLongPress(event, getGroupLongPressKey(groupId)) + } + + const handleControlPointerEnd = ( + groupId: string, + event: ReactPointerEvent, + ) => { + suppressRoomPanelNodeEvents() + endLongPress(event, getGroupLongPressKey(groupId)) + } + + const consumeControlSuppressedClick = (groupId: string) => + consumeSuppressedClick(getGroupLongPressKey(groupId)) + + function exitEditMode() { + onApplyGrouping( + orderedGroups + .map((group) => group.members.map((member) => member.id)) + .filter((group) => group.length > 0), ) + clearPendingExpand() + setExpandedGroupId(null) + clearHoveredItemTargets() + onSetEditing(false) } - return null + const handlePanelBodyPointerDown = (event: ReactPointerEvent) => { + if (!editing || dragState || event.target !== event.currentTarget) { + return + } + event.stopPropagation() + suppressRoomPanelNodeEvents() + exitEditMode() + } + + return ( +
setOverlayDomRef(refsStore, roomId, 'panel', node)} style={panelBaseStyle}> + {isOpen ? ( +
+ +
+ ) : ( + + )} + {isOpen ? ( +
+ {controlGroups.length > 0 ? ( + displayedGroups.map((group) => + editing ? ( + expandedGroupId === group.id && group.members.length > 1 ? ( + setHoveredItemTargets(group.itemIds)} + onMemberHover={(member) => setHoveredItemTargets([member.itemId])} + onStartMemberDrag={(member, event) => { + event.preventDefault() + event.stopPropagation() + suppressRoomPanelNodeEvents(ROOM_PANEL_LONG_PRESS_NODE_EVENT_SUPPRESS_MS) + setHoveredItemTargets([member.itemId]) + startMemberDrag(group.id, member.id, event.clientX, event.clientY) + }} + panelColumns={panelColumns} + /> + ) : ( + setHoveredItemTargets(group.itemIds)} + onStartDrag={(event) => { + event.preventDefault() + event.stopPropagation() + suppressRoomPanelNodeEvents(ROOM_PANEL_LONG_PRESS_NODE_EVENT_SUPPRESS_MS) + setHoveredItemTargets(group.itemIds) + + if (group.members.length > 1) { + clearPendingExpand() + const nextPendingExpand = { + groupId: group.id, + pointerId: event.pointerId, + startedAt: Date.now(), + startX: event.clientX, + startY: event.clientY, + } + pendingExpandRef.current = nextPendingExpand + setPendingExpand(nextPendingExpand) + if (typeof window !== 'undefined') { + expandTimeoutRef.current = window.setTimeout(() => { + const activePendingExpand = pendingExpandRef.current + if (!activePendingExpand || activePendingExpand.groupId !== group.id) { + return + } + clearPendingExpand() + setExpandedGroupId(group.id) + }, GROUP_EXPAND_HOLD_MS) + } + return + } + + startGroupDrag(group.id, event.clientX, event.clientY) + }} + panelColumns={panelColumns} + /> + ) + ) : group.controlKind === 'numeric' ? ( + setHoveredItemTargets(group.itemIds)} + onPointerDown={handleControlPointerDown} + onPointerEnd={handleControlPointerEnd} + onPointerMove={handleControlPointerMove} + shouldSuppressEditExitAction={shouldSuppressEditExitAction} + panelColumns={panelColumns} + /> + ) : ( + setHoveredItemTargets(group.itemIds)} + onPointerDown={handleControlPointerDown} + onPointerEnd={handleControlPointerEnd} + onPointerMove={handleControlPointerMove} + shouldSuppressEditExitAction={shouldSuppressEditExitAction} + panelColumns={panelColumns} + /> + ), + ) + ) : ( +
No controls
+ )} +
+ ) : null} + {editing && dragState && currentDragGroup && typeof document !== 'undefined' + ? createPortal( +
+ +
, + document.body, + ) + : null} +
+ ) +} + +const GroupIntensityStrip = ({ + controlValues, + group, + onChange, +}: { + controlValues: Record + group: RoomControlGroup + onChange: (itemId: AnyNodeId, controlIndex: number, nextValue: ControlValue) => void +}) => { + const segments = useMemo( + () => getGroupIntensitySegments(group, controlValues), + [controlValues, group], + ) + const visualSegments = useMemo(() => getGroupVisualSegments(group), [group]) + + if (segments.length === 0) { + return null + } + + const segmentByKind = new Map(segments.map((segment) => [segment.itemKind, segment] as const)) + + const applySegmentRatio = ( + segment: GroupIntensitySegment, + element: HTMLSpanElement, + clientX: number, + ) => { + const nextRatio = getSliderPointerRatio(clientX, element) + for (const member of segment.members) { + onChange( + member.itemId, + member.intensityControlIndex, + getSliderValueAtRatio(member.intensityControl, nextRatio), + ) + } + } + + const stopSegmentEvent = (event: { preventDefault: () => void; stopPropagation: () => void }) => { + event.preventDefault() + event.stopPropagation() + suppressRoomPanelNodeEvents() + } + + return ( +