diff --git a/.gitignore b/.gitignore index d01fc0bdd..83c67ce07 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ package-lock.json # Testing coverage +test-results/ # Turbo .turbo 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..c245ec652 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-auth.ts @@ -0,0 +1,150 @@ +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..73c152b67 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-discovery.ts @@ -0,0 +1,1205 @@ +import { createHash } from 'node:crypto' +import dgram from 'node:dgram' +import type { + HomeAssistantActionKind, + HomeAssistantActionPresentation, + HomeAssistantAvailableAction, + HomeAssistantAvailableActionField, + HomeAssistantCapabilityCategory, + HomeAssistantDiscoveredDevice, + HomeAssistantServiceTargetFilter, +} from '../../../../packages/editor/src/lib/home-assistant' +import { + getHomeAssistantAvailableActionPresentation, + getHomeAssistantCapabilityCategory, +} from '../../../../packages/editor/src/lib/home-assistant' +import { + type HomeAssistantEntityState, + type HomeAssistantServerConfig, + type HomeAssistantServiceDescription, + type HomeAssistantServiceRegistryEntry, + 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< + HomeAssistantAvailableActionField[] + >((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..2eb388f85 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-imports.ts @@ -0,0 +1,203 @@ +import type { + HomeAssistantAction, + HomeAssistantCollectionCapability, + HomeAssistantResourceKind, +} from '@pascal-app/core/schema' +import type { HomeAssistantImportedResource } from '../../../../packages/editor/src/lib/home-assistant-collections' +import { + isHiddenHomeAssistantGroupResourceId, + 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: HomeAssistantCollectionCapability + 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): HomeAssistantAction { + 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)] + }) +} + +function getMemberEntityIds(state: HomeAssistantEntityState | undefined) { + const rawEntityIds = + state?.attributes?.entity_id ?? state?.attributes?.entities ?? state?.attributes?.members + const values = Array.isArray(rawEntityIds) + ? rawEntityIds + : typeof rawEntityIds === 'string' + ? rawEntityIds.split(/[\s,]+/) + : [] + + return Array.from( + new Set( + values + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(value)), + ), + ) +} + +function isLikelyGroupEntity( + resource: HomeAssistantImportedResource, + state: HomeAssistantEntityState | undefined, +) { + const label = + typeof state?.attributes?.friendly_name === 'string' + ? state.attributes.friendly_name + : resource.label + const haystack = `${resource.entityId ?? resource.id} ${label}`.toLowerCase() + + return /\b(group|all[_\s-]?lights|lights[_\s-]?all)\b/.test(haystack) +} + +function applyGroupMetadata( + resource: HomeAssistantImportedResource, + statesByEntityId: Map, +): HomeAssistantImportedResource { + if (!(resource.kind === 'entity' && resource.entityId)) { + return resource + } + + const state = statesByEntityId.get(resource.entityId) + const memberEntityIds = getMemberEntityIds(state) + if (memberEntityIds.length === 0 && !isLikelyGroupEntity(resource, state)) { + return resource + } + + return { + ...resource, + description: + memberEntityIds.length > 0 + ? `${resource.description}; ${memberEntityIds.length} grouped HA entities` + : `${resource.description}; grouped HA entity`, + isGroup: true, + memberEntityIds, + } +} + +function isImportedDeviceResource(resource: HomeAssistantImportedResource) { + return resource.kind === 'entity' && resource.isGroup !== true && Boolean(resource.entityId) +} + +function removeHiddenGroupMembers( + resources: HomeAssistantImportedResource[], +): HomeAssistantImportedResource[] { + const importedDeviceEntityIds = new Set( + resources + .filter(isImportedDeviceResource) + .map((resource) => resource.entityId) + .filter((entityId): entityId is string => Boolean(entityId)), + ) + + return resources.map((resource) => { + if (!(resource.kind === 'entity' && resource.isGroup === true)) { + return resource + } + + const memberEntityIds = (resource.memberEntityIds ?? []).filter((entityId) => + importedDeviceEntityIds.has(entityId), + ) + + return { + ...resource, + memberEntityIds, + } + }) +} + +export async function listImportableHomeAssistantResources( + config: HomeAssistantServerConfig, +): Promise { + const [devices, states] = await Promise.all([ + discoverHomeAssistantDevices(config), + listEntityStates(config), + ]) + const statesByEntityId = new Map(states.map((state) => [state.entity_id, state])) + + const resources = [ + ...devices + .map((device) => toImportedEntityResource(device)) + .map((resource) => applyGroupMetadata(resource, statesByEntityId)), + ...getTriggerResources(states), + ] + + const uniqueResources = new Map() + for (const resource of resources) { + if (isHiddenHomeAssistantGroupResourceId(resource.id)) { + continue + } + uniqueResources.set(resource.id, resource) + } + + return removeHiddenGroupMembers(Array.from(uniqueResources.values())).sort((left, right) => + left.label.localeCompare(right.label), + ) +} diff --git a/apps/editor/app/_lib/home-assistant-instance-discovery.ts b/apps/editor/app/_lib/home-assistant-instance-discovery.ts new file mode 100644 index 000000000..dddfb11af --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-instance-discovery.ts @@ -0,0 +1,489 @@ +import dgram from 'node:dgram' +import os from 'node:os' + +export type HomeAssistantDiscoveredInstance = { + id: string + instanceUrl: string + label: string + source: 'known-host' | 'loopback' | 'zeroconf' +} + +const MDNS_GROUP = '224.0.0.251' +const MDNS_PORT = 5353 +const DISCOVERY_TIMEOUT_MS = 1800 +const HOME_ASSISTANT_SERVICE_TYPE = '_home-assistant._tcp.local' +const HOME_ASSISTANT_KNOWN_HOST_CANDIDATES = [ + 'http://homeassistant.local:8123', + 'http://homeassistant:8123', +] + +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 + name: string + properties: Record + raw: Array<{ key: string; value: string | null }> + ttl: number + type: 16 + } + +function stableId(parts: Array) { + return parts + .map((part) => part?.trim().toLowerCase() ?? '') + .join('|') + .replace(/[^a-z0-9|]+/g, '-') + .replace(/\|+/g, '|') + .replace(/^-+|-+$/g, '') + .slice(0, 96) +} + +function getDisplayHostFromUrl(url: string) { + try { + return new URL(url).hostname.replace(/^\[|\]$/g, '') + } catch { + return url + } +} + +function encodeDnsName(value: string) { + const normalized = value.replace(/\.$/, '') + const labels = normalized.split('.').filter(Boolean) + const parts: Buffer[] = [] + + for (const label of labels) { + const encoded = Buffer.from(label, 'utf8') + parts.push(Buffer.from([encoded.length])) + parts.push(encoded) + } + + parts.push(Buffer.from([0])) + return Buffer.concat(parts) +} + +function buildMdnsQuery(serviceType: string) { + const header = Buffer.alloc(12) + header.writeUInt16BE(0, 0) + header.writeUInt16BE(0, 2) + header.writeUInt16BE(1, 4) + header.writeUInt16BE(0, 6) + header.writeUInt16BE(0, 8) + header.writeUInt16BE(0, 10) + + return Buffer.concat([ + header, + encodeDnsName(serviceType), + Buffer.from([0x00, 0x0c]), + Buffer.from([0x00, 0x01]), + ]) +} + +function getLanIpv4Addresses() { + return Object.entries(os.networkInterfaces()) + .filter(([name]) => !/docker|hyper-v|vethernet|virtual|vmware|wsl/i.test(name)) + .flatMap(([, entries]) => entries ?? []) + .filter((entry) => entry.family === 'IPv4' && !entry.internal) + .map((entry) => entry.address) +} + +function decodeDnsName(message: Buffer, startOffset: number) { + const labels: string[] = [] + let offset = startOffset + let jumped = false + let nextOffset = startOffset + let guard = 0 + + while (offset < message.length && guard < 64) { + guard += 1 + const length = message[offset] + + if (length === undefined) { + break + } + + if (length === 0) { + if (!jumped) { + nextOffset = offset + 1 + } + break + } + + if ((length & 0xc0) === 0xc0) { + const pointer = ((length & 0x3f) << 8) | (message[offset + 1] ?? 0) + if (!jumped) { + nextOffset = offset + 2 + } + offset = pointer + jumped = true + continue + } + + const labelStart = offset + 1 + const labelEnd = labelStart + length + if (labelEnd > message.length) { + break + } + + labels.push(message.subarray(labelStart, labelEnd).toString('utf8')) + offset = labelEnd + if (!jumped) { + nextOffset = offset + } + } + + return { + name: labels.join('.'), + offset: nextOffset, + } +} + +function parseTxtRecord(data: Buffer) { + const raw: Array<{ key: string; value: string | null }> = [] + const properties: Record = {} + let offset = 0 + + while (offset < data.length) { + const entryLength = data[offset] ?? 0 + offset += 1 + if (entryLength <= 0 || offset + entryLength > data.length) { + break + } + + const entry = data.subarray(offset, offset + entryLength).toString('utf8') + offset += entryLength + + const separatorIndex = entry.indexOf('=') + if (separatorIndex === -1) { + raw.push({ key: entry, value: null }) + continue + } + + const key = entry.slice(0, separatorIndex) + const value = entry.slice(separatorIndex + 1) + raw.push({ key, value }) + properties[key] = value + } + + return { raw, 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 decodedName = decodeDnsName(message, offset) + offset = decodedName.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 === 1 && dataLength === 4) { + records.push({ + classCode, + name: decodedName.name, + ttl, + type, + value: Array.from(message.subarray(dataOffset, dataEnd)).join('.'), + }) + } else if (type === 12) { + records.push({ + classCode, + name: decodedName.name, + ttl, + type, + value: decodeDnsName(message, dataOffset).name, + }) + } else if (type === 16) { + records.push({ + classCode, + name: decodedName.name, + ttl, + type, + ...parseTxtRecord(message.subarray(dataOffset, dataEnd)), + }) + } else if (type === 28 && dataLength === 16) { + const segments: string[] = [] + for (let partIndex = 0; partIndex < 8; partIndex += 1) { + segments.push(message.readUInt16BE(dataOffset + partIndex * 2).toString(16)) + } + records.push({ + classCode, + name: decodedName.name, + ttl, + type, + value: segments.join(':'), + }) + } else if (type === 33 && dataLength >= 6) { + records.push({ + classCode, + name: decodedName.name, + port: message.readUInt16BE(dataOffset + 4), + priority: message.readUInt16BE(dataOffset), + target: decodeDnsName(message, dataOffset + 6).name, + ttl, + type, + weight: message.readUInt16BE(dataOffset + 2), + }) + } + + offset = dataEnd + } + + return records +} + +async function collectUdpMessages(onMessage: (message: Buffer) => void) { + await new Promise((resolve, reject) => { + const socket = dgram.createSocket({ reuseAddr: true, type: 'udp4' }) + + const finish = (callback?: () => void) => { + try { + callback?.() + } finally { + try { + socket.close() + } catch {} + } + } + + socket.on('error', (error) => { + clearTimeout(timeout) + finish(() => reject(error)) + }) + + socket.on('message', onMessage) + + socket.bind(0, () => { + try { + socket.setMulticastTTL(255) + const query = buildMdnsQuery(HOME_ASSISTANT_SERVICE_TYPE) + const interfaces = getLanIpv4Addresses() + + socket.send(query, MDNS_PORT, MDNS_GROUP) + for (const interfaceAddress of interfaces) { + try { + socket.setMulticastInterface(interfaceAddress) + socket.send(query, MDNS_PORT, MDNS_GROUP) + } catch {} + } + } catch (error) { + clearTimeout(timeout) + finish(() => reject(error)) + } + }) + + const timeout = setTimeout(() => { + finish(() => resolve()) + }, DISCOVERY_TIMEOUT_MS) + }) +} + +async function probeHttpCandidate(url: string) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 1200) + + try { + const response = await fetch(url, { + cache: 'no-store', + redirect: 'follow', + signal: controller.signal, + }) + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('text/html')) { + const html = await response.text() + return /home assistant/i.test(html) + } + + return response.ok + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function discoverLoopbackInstances() { + const candidates = ['http://localhost:8123', 'http://127.0.0.1:8123'] + const discovered: HomeAssistantDiscoveredInstance[] = [] + + for (const candidate of candidates) { + const reachable = await probeHttpCandidate(candidate) + if (!reachable) { + continue + } + + discovered.push({ + id: `loopback:${stableId([candidate])}`, + instanceUrl: candidate, + label: 'This machine', + source: 'loopback', + }) + } + + return discovered +} + +async function discoverKnownHostInstances() { + const discovered: HomeAssistantDiscoveredInstance[] = [] + + for (const candidate of HOME_ASSISTANT_KNOWN_HOST_CANDIDATES) { + const reachable = await probeHttpCandidate(candidate) + if (!reachable) { + continue + } + + const url = new URL(candidate) + discovered.push({ + id: `known-host:${stableId([candidate])}`, + instanceUrl: candidate, + label: url.hostname, + source: 'known-host', + }) + } + + return discovered +} + +async function discoverZeroconfInstances() { + const records: MdnsRecord[] = [] + + try { + await collectUdpMessages((message) => { + records.push(...parseMdnsPacket(message)) + }) + } catch { + return [] as HomeAssistantDiscoveredInstance[] + } + + const ptrRecords = records.filter( + (record): record is Extract => + record.type === 12 && record.name === HOME_ASSISTANT_SERVICE_TYPE, + ) + + const instances = new Map() + + 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, + ) + + if (!srvRecord) { + continue + } + + const addressRecord = records.find( + (record): record is Extract => + (record.type === 1 || record.type === 28) && record.name === srvRecord.target, + ) + + if (!addressRecord) { + continue + } + + const label = + txtRecord?.properties.location_name ?? + txtRecord?.properties.fn ?? + txtRecord?.properties.name ?? + instanceName.replace(`.${HOME_ASSISTANT_SERVICE_TYPE}`, '') + const protocol = srvRecord.port === 443 ? 'https' : 'http' + const address = + addressRecord.type === 28 && !addressRecord.value.startsWith('[') + ? `[${addressRecord.value}]` + : addressRecord.value + const instanceUrl = `${protocol}://${address}:${srvRecord.port}` + const id = `zeroconf:${stableId([instanceUrl, label])}` + + instances.set(id, { + id, + instanceUrl, + label: getDisplayHostFromUrl(instanceUrl) || label, + source: 'zeroconf', + }) + } + + return Array.from(instances.values()) +} + +export async function discoverHomeAssistantInstances() { + const discovered = new Map() + const zeroconfInstances = await discoverZeroconfInstances() + + for (const instance of zeroconfInstances) { + discovered.set(instance.instanceUrl, instance) + } + + for (const instance of await discoverKnownHostInstances()) { + if (!discovered.has(instance.instanceUrl)) { + discovered.set(instance.instanceUrl, instance) + } + } + + if (zeroconfInstances.length === 0) { + for (const instance of await discoverLoopbackInstances()) { + if (!discovered.has(instance.instanceUrl)) { + discovered.set(instance.instanceUrl, instance) + } + } + } + + return Array.from(discovered.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..f0c64d220 --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-linked-profile.ts @@ -0,0 +1,123 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +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..f4e7f819c --- /dev/null +++ b/apps/editor/app/_lib/home-assistant-server.ts @@ -0,0 +1,854 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { + HomeAssistantActionRequest, + HomeAssistantCollectionBinding, +} 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 = ` +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: HomeAssistantCollectionBinding, + resource: HomeAssistantCollectionBinding['resources'][number], + request: HomeAssistantActionRequest, +) { + 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: HomeAssistantActionRequest, +) { + 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: HomeAssistantCollectionBinding, + request: HomeAssistantActionRequest, +): 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..3a112e919 --- /dev/null +++ b/apps/editor/app/api/home-assistant/device-action/route.ts @@ -0,0 +1,70 @@ +import type { + HomeAssistantActionRequest, + HomeAssistantCollectionBinding, +} 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?: HomeAssistantCollectionBinding + collectionName?: string + itemName?: string + link?: unknown + request?: HomeAssistantActionRequest +} + +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..536f60c4a --- /dev/null +++ b/apps/editor/app/api/home-assistant/discover-devices/route.ts @@ -0,0 +1,38 @@ +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/discover-instances/route.ts b/apps/editor/app/api/home-assistant/discover-instances/route.ts new file mode 100644 index 000000000..0ce09024e --- /dev/null +++ b/apps/editor/app/api/home-assistant/discover-instances/route.ts @@ -0,0 +1,24 @@ +import { discoverHomeAssistantInstances } from '../../../_lib/home-assistant-instance-discovery' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const instances = await discoverHomeAssistantInstances() + return Response.json({ + instances, + scannedAt: new Date().toISOString(), + }) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown Home Assistant discovery error.' + + return Response.json( + { + error: message, + instances: [], + }, + { 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..e11676f87 --- /dev/null +++ b/apps/editor/app/api/home-assistant/oauth/callback/route.ts @@ -0,0 +1,81 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + exchangeAuthorizationCode, + HOME_ASSISTANT_OAUTH_COOKIE, +} 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..910cd0b0d --- /dev/null +++ b/apps/editor/app/api/home-assistant/oauth/start/route.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + buildHomeAssistantAuthorizeUrl, + buildHomeAssistantOauthState, + HOME_ASSISTANT_OAUTH_COOKIE, + 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/apps/editor/next.config.ts b/apps/editor/next.config.mjs similarity index 57% rename from apps/editor/next.config.ts rename to apps/editor/next.config.mjs index 6c684f1bf..6bf3a4216 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.mjs @@ -1,6 +1,10 @@ -import type { NextConfig } from 'next' +import path from 'node:path' +import { fileURLToPath } from 'node:url' -const nextConfig: NextConfig = { +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +/** @type {import('next').NextConfig} */ +const nextConfig = { typescript: { ignoreBuildErrors: true, }, @@ -12,13 +16,22 @@ const nextConfig: NextConfig = { '@pascal-app/mcp', ], turbopack: { + root: path.resolve(__dirname, '../..'), resolveAlias: { - react: './node_modules/react', - three: './node_modules/three', '@react-three/fiber': './node_modules/@react-three/fiber', '@react-three/drei': './node_modules/@react-three/drei', }, }, + webpack: (config) => { + config.resolve ??= {} + config.resolve.alias = { + ...(config.resolve.alias ?? {}), + '@react-three/fiber': path.resolve(__dirname, 'node_modules/@react-three/fiber'), + '@react-three/drei': path.resolve(__dirname, 'node_modules/@react-three/drei'), + } + + return config + }, experimental: { serverActions: { bodySizeLimit: '100mb', diff --git a/bun.lock b/bun.lock index fc2f7c2fe..e6f4c48dd 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "editor", @@ -199,6 +198,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", }, @@ -207,6 +207,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", + "react-dom": "^18 || ^19", "three": "^0.184", }, }, diff --git a/docs/assets/home-assistant-demo.gif b/docs/assets/home-assistant-demo.gif new file mode 100644 index 000000000..8ea9df898 Binary files /dev/null and b/docs/assets/home-assistant-demo.gif differ 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/src/schema/collections.ts b/packages/core/src/schema/collections.ts index 32fb01c8b..ba5381b2d 100644 --- a/packages/core/src/schema/collections.ts +++ b/packages/core/src/schema/collections.ts @@ -12,3 +12,30 @@ export type Collection = { } export const generateCollectionId = (): CollectionId => generateId('collection') + +export const getCollectionAttachmentNodeCollectionId = (node: unknown): CollectionId | null => { + if (!(node && typeof node === 'object')) { + return null + } + + const collectionId = (node as { collectionId?: unknown }).collectionId + const resources = (node as { resources?: unknown }).resources + return typeof collectionId === 'string' && Array.isArray(resources) + ? (collectionId as CollectionId) + : null +} + +export const normalizeCollection = (collection: Collection): Collection => { + const nodeIds = Array.from( + new Set(collection.nodeIds.filter((nodeId): nodeId is AnyNodeId => typeof nodeId === 'string')), + ) + + return { + ...collection, + controlNodeId: + typeof collection.controlNodeId === 'string' && nodeIds.includes(collection.controlNodeId) + ? collection.controlNodeId + : nodeIds[0], + nodeIds, + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index a86f1a716..6031e7b91 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -3,7 +3,13 @@ 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 CollectionId, + generateCollectionId, + getCollectionAttachmentNodeCollectionId, + normalizeCollection, +} from './collections' export type { MaterialMapProperties, MaterialMaps, @@ -29,6 +35,38 @@ export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode } from './nodes/guide' +export type { + HomeAssistantAction, + HomeAssistantActionField, + HomeAssistantActionRequest, + HomeAssistantBindingAggregation, + HomeAssistantBindingNode as HomeAssistantBindingNodeValue, + HomeAssistantBindingNodeId, + HomeAssistantBindingNodeMap, + HomeAssistantBindingPresentation, + HomeAssistantCollectionBinding, + HomeAssistantCollectionBindingMap, + HomeAssistantCollectionCapability, + HomeAssistantResourceBinding, + HomeAssistantResourceKind, + HomeAssistantRoomControlComposition, + HomeAssistantRoomControlGroup, +} from './nodes/home-assistant-binding' +export { + createHomeAssistantBindingNode, + getHomeAssistantBindingCapabilities, + getHomeAssistantBindingDisplayLabel, + getHomeAssistantBindingNodeForCollection, + getHomeAssistantBindingNodeIdForCollection, + getHomeAssistantBindingNodeMap, + getHomeAssistantBindingNodes, + HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT, + HomeAssistantBindingNode, + hasHomeAssistantBinding, + isHomeAssistantBindingNode, + isHomeAssistantTriggerBinding, + normalizeHomeAssistantCollectionBinding, +} from './nodes/home-assistant-binding' export type { AnimationEffect, Asset, diff --git a/packages/core/src/schema/nodes/home-assistant-binding.ts b/packages/core/src/schema/nodes/home-assistant-binding.ts new file mode 100644 index 000000000..32207e95a --- /dev/null +++ b/packages/core/src/schema/nodes/home-assistant-binding.ts @@ -0,0 +1,503 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import type { CollectionId } from '../collections' +import type { AnyNode, AnyNodeId } from '../types' + +export const HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT = 3.5 + +export type HomeAssistantCollectionCapability = + | 'brightness' + | 'media' + | 'power' + | 'speed' + | 'temperature' + | 'trigger' + | 'volume' + +export type HomeAssistantResourceKind = 'automation' | 'entity' | 'scene' | 'script' + +export type HomeAssistantBindingAggregation = + | 'all' + | 'any_on' + | 'primary' + | 'single' + | 'trigger_only' + +const HOME_ASSISTANT_BINDING_AGGREGATIONS = [ + 'all', + 'any_on', + 'primary', + 'single', + 'trigger_only', +] as const satisfies HomeAssistantBindingAggregation[] + +const homeAssistantActionFieldSchema = z.object({ + defaultValue: z.unknown().optional(), + key: z.string(), + label: z.string(), + required: z.boolean(), + selector: z.record(z.string(), z.unknown()).nullable().optional(), +}) + +const homeAssistantActionSchema = z.object({ + capability: z.enum([ + 'brightness', + 'media', + 'power', + 'speed', + 'temperature', + 'trigger', + 'volume', + ] satisfies HomeAssistantCollectionCapability[]), + domain: z.string(), + fields: z.array(homeAssistantActionFieldSchema).optional(), + key: z.string(), + label: z.string(), + service: z.string(), +}) + +const homeAssistantResourceBindingSchema = z.object({ + actions: z.array(homeAssistantActionSchema).default([]), + capabilities: z.array( + z.enum([ + 'brightness', + 'media', + 'power', + 'speed', + 'temperature', + 'trigger', + 'volume', + ] satisfies HomeAssistantCollectionCapability[]), + ), + defaultActionKey: z.string().nullable().optional(), + entityId: z.string().nullable().optional(), + id: z.string(), + isGroup: z.boolean().optional(), + kind: z.enum(['automation', 'entity', 'scene', 'script'] satisfies HomeAssistantResourceKind[]), + label: z.string(), + memberEntityIds: z.array(z.string()).optional(), +}) + +const homeAssistantRoomControlGroupSchema = z.object({ + memberResourceIds: z.array(z.string()), +}) + +const homeAssistantRoomControlCompositionSchema = z.object({ + excludedResourceIds: z.array(z.string()).optional(), + groups: z.array(homeAssistantRoomControlGroupSchema).optional(), + mode: z.enum(['ha-derived', 'user-managed']).optional(), +}) + +const homeAssistantBindingPresentationSchema = z.object({ + icon: z.string().optional(), + label: z.string().optional(), + rtsHidden: z.boolean().optional(), + rtsRoomControls: homeAssistantRoomControlCompositionSchema.optional(), + rtsOrder: z.number().optional(), + rtsScreenPosition: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional(), + rtsWorldPosition: z + .object({ + x: z.number(), + y: z.number(), + z: z.number(), + }) + .optional(), +}) + +const homeAssistantCollectionBindingSchema = z.object({ + aggregation: z.enum(HOME_ASSISTANT_BINDING_AGGREGATIONS), + collectionId: z.custom(), + presentation: homeAssistantBindingPresentationSchema.optional(), + primaryResourceId: z.string().nullable().optional(), + resources: z.array(homeAssistantResourceBindingSchema), +}) + +export type HomeAssistantActionField = z.infer +export type HomeAssistantAction = z.infer +export type HomeAssistantResourceBinding = z.infer +export type HomeAssistantRoomControlGroup = z.infer +export type HomeAssistantRoomControlComposition = z.infer< + typeof homeAssistantRoomControlCompositionSchema +> +export type HomeAssistantBindingPresentation = z.infer< + typeof homeAssistantBindingPresentationSchema +> +export type HomeAssistantCollectionBinding = z.infer +export type HomeAssistantCollectionBindingMap = Record + +type LegacyHomeAssistantRoomControlComposition = Omit< + HomeAssistantRoomControlComposition, + 'groups' +> & { + groups?: Array +} + +type LegacyHomeAssistantBindingPresentation = HomeAssistantBindingPresentation & { + rtsExcludedResourceIds?: string[] + rtsGroups?: string[][] + rtsRoomControls?: LegacyHomeAssistantRoomControlComposition +} + +export const HomeAssistantBindingNode = BaseNode.extend({ + id: objectId('ha-binding'), + type: nodeType('home-assistant-binding'), + aggregation: z.enum(HOME_ASSISTANT_BINDING_AGGREGATIONS).default('single'), + collectionId: z.custom(), + presentation: homeAssistantBindingPresentationSchema.optional(), + primaryResourceId: z.string().nullable().optional(), + resources: z.array(homeAssistantResourceBindingSchema).default([]), +}) + +export type HomeAssistantBindingNode = z.infer +export type HomeAssistantBindingNodeId = HomeAssistantBindingNode['id'] +export type HomeAssistantBindingNodeMap = Record + +export type HomeAssistantActionRequest = + | { + capability: Extract< + HomeAssistantCollectionCapability, + 'brightness' | 'speed' | 'temperature' | 'volume' + > + kind: 'range' + value: number + } + | { + kind: 'toggle' + value: boolean + } + | { + kind: 'trigger' + } + +const dedupeStringArray = (values: T[] | undefined) => + Array.from(new Set((values ?? []).filter((value): value is T => typeof value === 'string'))) + +const normalizeAction = (action: HomeAssistantAction): HomeAssistantAction | 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 HomeAssistantActionField => + 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: HomeAssistantResourceBinding, +): HomeAssistantResourceBinding | null => { + if (!(resource && typeof resource === 'object')) { + return null + } + + if ( + typeof resource.id !== 'string' || + typeof resource.kind !== 'string' || + typeof resource.label !== 'string' + ) { + return null + } + + const memberEntityIds = dedupeStringArray(resource.memberEntityIds) + const isGroup = resource.isGroup === true || memberEntityIds.length > 0 + + return { + actions: Array.isArray(resource.actions) + ? resource.actions + .map((action) => normalizeAction(action)) + .filter((action): action is HomeAssistantAction => 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, + ...(isGroup ? { isGroup, memberEntityIds } : {}), + kind: resource.kind, + label: resource.label, + } +} + +const clampUnit = (value: number) => Math.max(0, Math.min(1, value)) + +const normalizeAggregation = (value: unknown): HomeAssistantBindingAggregation => + HOME_ASSISTANT_BINDING_AGGREGATIONS.includes(value as HomeAssistantBindingAggregation) + ? (value as HomeAssistantBindingAggregation) + : 'single' + +const normalizeStringGroups = (groups: unknown) => + Array.isArray(groups) + ? groups + .filter(Array.isArray) + .map((group) => + group.filter((memberId): memberId is string => typeof memberId === 'string'), + ) + .filter((group) => group.length > 0) + : undefined + +const getRoomControlMemberResourceId = (collectionId: CollectionId, memberId: string) => { + const prefix = `${collectionId}:home-assistant:` + if (!memberId.startsWith(prefix)) { + return null + } + + const encodedResourceId = memberId.slice(prefix.length).replace(/:\d+$/, '') + try { + return decodeURIComponent(encodedResourceId) + } catch { + return encodedResourceId + } +} + +const getLegacyRoomControlMemberId = (collectionId: CollectionId, resourceId: string) => + `${collectionId}:home-assistant:${resourceId.replace(/[^a-zA-Z0-9_-]/g, '-')}` + +const normalizeRoomControlComposition = ({ + collectionId, + presentation, + resources, +}: { + collectionId: CollectionId + presentation: LegacyHomeAssistantBindingPresentation | undefined + resources: HomeAssistantResourceBinding[] +}): HomeAssistantRoomControlComposition | undefined => { + const resourceIds = new Set(resources.map((resource) => resource.id)) + const resourceAliases = new Map() + for (const resource of resources) { + const currentMemberId = `${collectionId}:home-assistant:${encodeURIComponent(resource.id)}` + const legacyMemberId = getLegacyRoomControlMemberId(collectionId, resource.id) + resourceAliases.set(currentMemberId, resource.id) + resourceAliases.set(legacyMemberId, resource.id) + } + const existingComposition = presentation?.rtsRoomControls + const rawGroups = + existingComposition?.groups?.map((group) => group.memberResourceIds) ?? + normalizeStringGroups(presentation?.rtsGroups) ?? + [] + const groups = rawGroups + .map((group, index) => { + const memberResourceIds = Array.from( + new Set( + group + .map((memberId) => { + if (resourceIds.has(memberId)) { + return memberId + } + const canonicalMemberId = memberId.replace(/:\d+$/, '') + return ( + resourceAliases.get(memberId) ?? + resourceAliases.get(canonicalMemberId) ?? + getRoomControlMemberResourceId(collectionId, memberId) + ) + }) + .filter((resourceId): resourceId is string => + Boolean(resourceId && resourceIds.has(resourceId)), + ), + ), + ) + + return { memberResourceIds } + }) + .filter((group) => group.memberResourceIds.length > 0) + const excludedResourceIds = dedupeStringArray( + existingComposition?.excludedResourceIds ?? presentation?.rtsExcludedResourceIds, + ) + const mode = + existingComposition?.mode === 'ha-derived' || existingComposition?.mode === 'user-managed' + ? existingComposition.mode + : undefined + + if (groups.length === 0 && excludedResourceIds.length === 0 && !mode) { + return undefined + } + + return { + ...(excludedResourceIds.length > 0 ? { excludedResourceIds } : {}), + ...(groups.length > 0 ? { groups } : {}), + ...(mode ? { mode } : {}), + } +} + +export const normalizeHomeAssistantCollectionBinding = ( + binding: HomeAssistantCollectionBinding | Record, +): HomeAssistantCollectionBinding | null => { + if (!(binding && typeof binding === 'object')) { + return null + } + + const collectionId = + typeof binding.collectionId === 'string' ? (binding.collectionId as CollectionId) : null + if (!collectionId) { + return null + } + + const normalizedResources = Array.isArray(binding.resources) + ? binding.resources + .map((resource) => normalizeResource(resource)) + .filter((resource): resource is HomeAssistantResourceBinding => Boolean(resource)) + : [] + + if (normalizedResources.length === 0) { + return null + } + + const presentation = + binding.presentation && + typeof binding.presentation === 'object' && + !Array.isArray(binding.presentation) + ? (binding.presentation as LegacyHomeAssistantBindingPresentation) + : undefined + + return { + aggregation: normalizeAggregation(binding.aggregation), + collectionId, + presentation: presentation + ? { + icon: typeof presentation.icon === 'string' ? presentation.icon : undefined, + label: typeof presentation.label === 'string' ? presentation.label : undefined, + rtsHidden: presentation.rtsHidden === true ? presentation.rtsHidden : undefined, + rtsRoomControls: normalizeRoomControlComposition({ + collectionId, + presentation, + resources: normalizedResources, + }), + rtsOrder: typeof presentation.rtsOrder === 'number' ? presentation.rtsOrder : undefined, + rtsScreenPosition: + presentation.rtsScreenPosition && + typeof presentation.rtsScreenPosition.x === 'number' && + typeof presentation.rtsScreenPosition.y === 'number' + ? { + x: clampUnit(presentation.rtsScreenPosition.x), + y: clampUnit(presentation.rtsScreenPosition.y), + } + : undefined, + rtsWorldPosition: + presentation.rtsWorldPosition && + typeof presentation.rtsWorldPosition.x === 'number' && + typeof presentation.rtsWorldPosition.y === 'number' && + typeof presentation.rtsWorldPosition.z === 'number' + ? { + x: presentation.rtsWorldPosition.x, + y: presentation.rtsWorldPosition.y, + z: presentation.rtsWorldPosition.z, + } + : undefined, + } + : undefined, + primaryResourceId: + typeof binding.primaryResourceId === 'string' + ? binding.primaryResourceId + : (normalizedResources[0]?.id ?? null), + resources: normalizedResources, + } +} + +export const createHomeAssistantBindingNode = ({ + binding, + id, + name, +}: { + binding: HomeAssistantCollectionBinding + id?: HomeAssistantBindingNodeId + name?: string +}) => { + const normalizedBinding = normalizeHomeAssistantCollectionBinding(binding) + if (!normalizedBinding) { + return null + } + + return HomeAssistantBindingNode.parse({ + ...normalizedBinding, + ...(id ? { id } : {}), + ...(name ? { name } : {}), + }) +} + +export const isHomeAssistantBindingNode = ( + node: AnyNode | null | undefined, +): node is HomeAssistantBindingNode => node?.type === 'home-assistant-binding' + +export const getHomeAssistantBindingNodes = (nodes: Record) => + Object.values(nodes).filter((node): node is HomeAssistantBindingNode => + isHomeAssistantBindingNode(node), + ) + +export const getHomeAssistantBindingNodeMap = ( + nodes: Record, +): HomeAssistantBindingNodeMap => + Object.fromEntries( + getHomeAssistantBindingNodes(nodes).flatMap((node) => + node.resources.length > 0 ? [[node.collectionId, node]] : [], + ), + ) as HomeAssistantBindingNodeMap + +export const getHomeAssistantBindingNodeForCollection = ( + nodes: Record, + collectionId: CollectionId, +) => getHomeAssistantBindingNodes(nodes).find((node) => node.collectionId === collectionId) ?? null + +export const getHomeAssistantBindingNodeIdForCollection = ( + nodes: Record, + collectionId: CollectionId, +) => getHomeAssistantBindingNodeForCollection(nodes, collectionId)?.id ?? null + +export const getHomeAssistantBindingCapabilities = ( + binding: HomeAssistantCollectionBinding | null | undefined, +) => new Set(binding?.resources.flatMap((resource) => resource.capabilities ?? []) ?? []) + +export const hasHomeAssistantBinding = ( + binding: HomeAssistantCollectionBinding | null | undefined, +) => Boolean(binding?.resources?.length) + +export const isHomeAssistantTriggerBinding = ( + binding: HomeAssistantCollectionBinding | null | undefined, +) => + binding?.aggregation === 'trigger_only' || + (hasHomeAssistantBinding(binding) && + getHomeAssistantBindingCapabilities(binding).has('trigger') && + !getHomeAssistantBindingCapabilities(binding).has('power')) + +export const getHomeAssistantBindingDisplayLabel = ( + binding: HomeAssistantCollectionBinding | null | undefined, + fallbackLabel: string, +) => binding?.presentation?.label?.trim() || fallbackLabel.trim() || 'Collection' diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 00e07fa19..7c16b54f6 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -4,6 +4,7 @@ import { CeilingNode } from './nodes/ceiling' import { DoorNode } from './nodes/door' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' +import { HomeAssistantBindingNode } from './nodes/home-assistant-binding' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { RoofNode } from './nodes/roof' @@ -25,6 +26,7 @@ export const AnyNode = z.discriminatedUnion('type', [ WallNode, FenceNode, ItemNode, + HomeAssistantBindingNode, ZoneNode, SlabNode, CeilingNode, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 51e1caa9f..a720ed049 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -5,7 +5,11 @@ 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, + getCollectionAttachmentNodeCollectionId, + normalizeCollection, +} from '../schema/collections' import { LevelNode } from '../schema/nodes/level' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' @@ -40,6 +44,35 @@ function getStringArray(value: unknown) { : [] } +function normalizeInputRootNodeIds( + rootNodeIds: unknown, + nodes: Record, +): AnyNodeId[] { + if (!Array.isArray(rootNodeIds)) { + return [] + } + + const seen = new Set() + return rootNodeIds.flatMap((nodeId) => { + if (typeof nodeId !== 'string') { + return [] + } + + const node = nodes[nodeId as AnyNodeId] + if (!node || node.type === 'home-assistant-binding') { + return [] + } + + const typedNodeId = nodeId as AnyNodeId + if (seen.has(typedNodeId)) { + return [] + } + + seen.add(typedNodeId) + return [typedNodeId] + }) +} + function getVector3(value: unknown, fallback: [number, number, number]): [number, number, number] { if (!Array.isArray(value) || value.length < 3) { return fallback @@ -410,6 +443,10 @@ function collectReachableNodeIds( return reachable } +function isDetachedDurableNode(node: AnyNode) { + return node.type === 'home-assistant-binding' +} + export type SceneState = { // 1. The Data: A flat dictionary of all nodes nodes: Record @@ -431,7 +468,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 @@ -459,6 +500,120 @@ type UseSceneStore = UseBoundStore> & { temporal: StoreApi>> } +function getCollectionIdsReferencedByAttachmentNodes(nodes: Record) { + const referencedCollectionIds = new Set() + + for (const node of Object.values(nodes)) { + const collectionId = getCollectionAttachmentNodeCollectionId(node) + if (collectionId) { + referencedCollectionIds.add(collectionId) + } + if ( + node.type === 'home-assistant-binding' && + typeof (node as { collectionId?: unknown }).collectionId === 'string' + ) { + referencedCollectionIds.add((node as { collectionId: CollectionId }).collectionId) + } + } + + return referencedCollectionIds +} + +function normalizeCollectionsRecord( + collections: Record | undefined, + nodes: Record, +) { + const referencedCollectionIds = getCollectionIdsReferencedByAttachmentNodes(nodes) + + 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 && !referencedCollectionIds.has(id as CollectionId)) { + 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 +} + +function normalizeCollectionAttachmentNodes( + nodes: Record, + collections: Record, + rootNodeIds: AnyNodeId[], +) { + const nextNodes = { ...nodes } + const nextRootNodeIds = [...rootNodeIds] + let changed = false + + for (const [storedNodeId, node] of Object.entries(nodes) as [AnyNodeId, AnyNode][]) { + if (node.type === 'home-assistant-binding') { + continue + } + + const collectionId = getCollectionAttachmentNodeCollectionId(node) + if (!collectionId) { + continue + } + + if (node.id !== storedNodeId) { + nextNodes[storedNodeId] = { ...node, id: storedNodeId } as AnyNode + changed = true + } + + const resources = (node as { resources?: unknown[] }).resources ?? [] + const hasCollection = Boolean(collections[collectionId]) + const hasResources = resources.length > 0 + + if (!(hasCollection && hasResources)) { + delete nextNodes[storedNodeId] + const rootIndex = nextRootNodeIds.indexOf(storedNodeId) + if (rootIndex >= 0) { + nextRootNodeIds.splice(rootIndex, 1) + } + changed = true + continue + } + + if (!nextRootNodeIds.includes(storedNodeId)) { + nextRootNodeIds.push(storedNodeId) + changed = true + } + } + + return changed ? { nodes: nextNodes, rootNodeIds: nextRootNodeIds } : { nodes, rootNodeIds } +} + const useScene: UseSceneStore = create()( temporal( (set, get) => ({ @@ -492,14 +647,18 @@ const useScene: UseSceneStore = create()( get().loadScene() // Default scene }, - setScene: (nodes, rootNodeIds) => { + setScene: (nodes, rootNodeIds, collections) => { // Apply backward compatibility migrations const patchedNodes = migrateNodes(nodes) // Remove orphans: nodes whose parentId points to a non-existent node const cleanedNodes = { ...patchedNodes } for (const node of Object.values(cleanedNodes)) { - if (node.parentId && !cleanedNodes[node.parentId]) { + if ( + node.type !== 'home-assistant-binding' && + node.parentId && + !cleanedNodes[node.parentId] + ) { console.warn( '[Scene] Removing orphan node', node.id, @@ -511,24 +670,36 @@ const useScene: UseSceneStore = create()( } } - const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) - const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) + const inputRootNodeIds = normalizeInputRootNodeIds(rootNodeIds, cleanedNodes) + const normalizedCollections = normalizeCollectionsRecord(collections, cleanedNodes) + const normalizedScene = normalizeCollectionAttachmentNodes( + cleanedNodes, + normalizedCollections, + inputRootNodeIds, + ) + const normalizedRootNodeIds = normalizeRootNodeIds( + normalizedScene.nodes, + normalizedScene.rootNodeIds, + ) + const normalizedNodes = { ...normalizedScene.nodes } + const reachableNodeIds = collectReachableNodeIds(normalizedNodes, normalizedRootNodeIds) if (normalizedRootNodeIds.length > 0) { - for (const node of Object.values(cleanedNodes)) { + for (const node of Object.values(normalizedNodes)) { if (reachableNodeIds.has(node.id as AnyNodeId)) continue + if (isDetachedDurableNode(node)) continue console.warn('[Scene] Removing unreachable node', node.id) - delete cleanedNodes[node.id] + delete normalizedNodes[node.id] } } set({ - nodes: cleanedNodes, + nodes: normalizedNodes, rootNodeIds: normalizedRootNodeIds, dirtyNodes: new Set(), - collections: {}, + collections: normalizedCollections, }) // Mark all nodes as dirty to trigger re-validation - Object.values(cleanedNodes).forEach((node) => { + Object.values(normalizedNodes).forEach((node) => { get().markDirty(node.id) }) }, @@ -594,7 +765,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 @@ -619,6 +790,7 @@ const useScene: UseSceneStore = create()( delete nextCollections[id] // Remove collectionId from all member nodes const nextNodes = { ...state.nodes } + let nextRootIds = [...state.rootNodeIds] for (const nodeId of col?.nodeIds ?? []) { const node = nextNodes[nodeId] if (!(node && 'collectionIds' in node)) continue @@ -627,7 +799,17 @@ const useScene: UseSceneStore = create()( collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), } as AnyNode } - return { collections: nextCollections, nodes: nextNodes } + + for (const node of Object.values(nextNodes)) { + if (getCollectionAttachmentNodeCollectionId(node) !== id) { + continue + } + + delete nextNodes[node.id] + nextRootIds = nextRootIds.filter((rootId) => rootId !== node.id) + } + + return { collections: nextCollections, nodes: nextNodes, rootNodeIds: nextRootIds } }) }, @@ -636,7 +818,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 }), + }, + } }) }, @@ -647,7 +834,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 } @@ -668,7 +855,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/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 3776e1d49..2e0cf4a39 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,6 +1,7 @@ import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' import type { Collection, CollectionId } from '../schema/collections' +import { getCollectionAttachmentNodeCollectionId } from '../schema/collections' export type SceneGraph = { nodes: Record @@ -121,6 +122,16 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { } } } + + for (const node of Object.values(clonedNodes)) { + const collectionId = getCollectionAttachmentNodeCollectionId(node) + if (!collectionId) { + continue + } + + ;(node as { collectionId: CollectionId }).collectionId = + collectionIdMap.get(collectionId) ?? collectionId + } } return { diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 1fdc5c816..fc78d866e 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -1,6 +1,11 @@ 'use client' -import { memo, type MouseEvent as ReactMouseEvent } from 'react' +import { + type ComponentProps, + memo, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from 'react' import useEditor from '../../store/use-editor' import { NodeActionMenu } from '../editor/node-action-menu' @@ -10,12 +15,17 @@ type SvgPoint = { } export type FloorplanActionMenuHandler = (event: ReactMouseEvent) => void +type NodeActionMenuProps = ComponentProps export type FloorplanActionMenuEntry = { position: SvgPoint | null + customContent?: ReactNode + extraActionIcon?: NodeActionMenuProps['extraActionIcon'] + extraActionLabel?: string onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler + onExtraAction?: FloorplanActionMenuHandler } type FloorplanActionMenuLayerProps = { @@ -75,13 +85,20 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ transform: `translate(-50%, calc(-100% - ${offsetY}px))`, }} > - event.stopPropagation()} - onPointerUp={(event) => event.stopPropagation()} - /> + {entry.customContent ? ( + entry.customContent + ) : ( + event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> + )} ) : null, )} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fec5d288e..2e5809010 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -24,10 +24,12 @@ 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 { duplicateRoofSubtree } from '../../lib/roof-duplication' -import { duplicateStairSubtree } from '../../lib/stair-duplication' import { sfxEmitter } from '../../lib/sfx-bus' +import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' +import { HomeAssistantConnectivityPanel } from './home-assistant-connectivity-panel' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -60,6 +62,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) @@ -147,10 +151,7 @@ export function FloatingActionMenu() { node.type === 'wall' ? obj.localToWorld( new THREE.Vector3( - Math.hypot( - segment.end[0] - segment.start[0], - segment.end[1] - segment.start[1], - ), + Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), 0, 0, ), @@ -335,7 +336,7 @@ export function FloatingActionMenu() { } else if (duplicate.type === 'stair') { setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } - if (duplicate.type !== 'stair' && duplicate.type !== 'roof') { + if (duplicate.type !== 'stair') { setSelection({ selectedIds: [] }) } } @@ -395,6 +396,27 @@ 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') || @@ -415,26 +437,43 @@ 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 4843da830..0b66c924a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -60,6 +60,7 @@ import { rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, } from '../../lib/floorplan' +import { getHomeAssistantLink } from '../../lib/home-assistant' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' @@ -101,6 +102,7 @@ import { import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { HomeAssistantConnectivityPanel } from './home-assistant-connectivity-panel' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanSceneData } from './use-floorplan-scene-data' @@ -2347,7 +2349,11 @@ function getItemDimensionMeasurementOverlays( itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit, ) - const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => { + const buildSideOverlay = ( + id: string, + start: Point2D, + end: Point2D, + ): LinearMeasurementOverlay | null => { const edgeVector = { x: end.x - start.x, y: end.y - start.y, @@ -2391,8 +2397,8 @@ function getItemDimensionMeasurementOverlays( return overlay ? { - dashedExtensions: false, ...overlay, + dashedExtensions: false, isSelected: true, showTicks: false, } @@ -6044,6 +6050,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 selectedItemClearanceMeasurements = useMemo(() => { if (!selectedItemEntry) { return [] as LinearMeasurementOverlay[] @@ -7082,6 +7090,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 @@ -9953,6 +9967,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() @@ -11259,7 +11291,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..c6b52eb7a --- /dev/null +++ b/packages/editor/src/components/editor/home-assistant-connectivity-panel.tsx @@ -0,0 +1,832 @@ +'use client' + +import type { ItemNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +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', + } + 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 +} + +function isTelevisionItem(item: ItemNode) { + const candidates = [item.asset.id, item.asset.name, item.asset.src, ...(item.asset.tags ?? [])] + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + + return candidates.some( + (candidate) => + candidate === 'tv' || + candidate.includes('television') || + candidate.includes('flat-screen-tv'), + ) +} + +function isHomeAssistantOffAction(action: HomeAssistantAvailableAction, link: HomeAssistantLink) { + return ( + action.actionKind === 'turn_off' || + action.service === 'turn_off' || + link.serviceName === 'turn_off' + ) +} + +function isDeferredHomeAssistantPowerToggle( + action: HomeAssistantAvailableAction, + link: HomeAssistantLink, +) { + return ( + action.actionKind === 'power' || action.service === 'toggle' || link.serviceName === 'toggle' + ) +} + +function isHomeAssistantOffState(state: string) { + return state.trim().toLowerCase() === 'off' +} + +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(() => { + // The retry button updates this revision to refetch even when the link is unchanged. + void reloadToken + 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}...`) + if (isTelevisionItem(item)) { + const viewer = useViewer.getState() + if (isHomeAssistantOffAction(actionToRun, actionLink)) { + viewer.clearItemEffect(item.id) + } else if (!isDeferredHomeAssistantPowerToggle(actionToRun, actionLink)) { + viewer.triggerItemEffect(item.id) + } + } + + 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) + if (isTelevisionItem(item)) { + const viewer = useViewer.getState() + if (isHomeAssistantOffState(payload.finalState)) { + viewer.clearItemEffect(item.id) + } else { + viewer.triggerItemEffect(item.id) + } + } + 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>((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/home-assistant-interactive-system.tsx b/packages/editor/src/components/editor/home-assistant-interactive-system.tsx new file mode 100644 index 000000000..5935f015d --- /dev/null +++ b/packages/editor/src/components/editor/home-assistant-interactive-system.tsx @@ -0,0 +1,259 @@ +'use client' + +import { + type AnyNode, + type CollectionId, + getHomeAssistantBindingNodeMap, + type HomeAssistantActionRequest, + type HomeAssistantCollectionBinding, + useInteractive, + useScene, +} from '@pascal-app/core' +import { InteractiveSystem, useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + RoomControlChange, + RoomControlTile, + RoomOverlayNode, +} from '../../features/home-assistant/room-overlay/room-control-model' +import { RoomControlOverlay } from '../../features/home-assistant/room-overlay/room-control-overlay' +import { + buildCollectionActionRequest, + buildHomeAssistantRoomOverlayNodes, + getActionBindingForMember, + getCollectionDisplayName, +} from '../../features/home-assistant/room-overlay/room-overlay-nodes' +import { + getBindingAfterDeviceResourceCopyToGroup, + getBindingAfterDeviceResourceRemovalFromGroup, + getBindingAfterRoomGrouping, +} from '../../lib/home-assistant-binding-presentation' +import { requestSceneImmediateSave } from '../../lib/scene' +import useEditor from '../../store/use-editor' + +export type HomeAssistantDeviceActionDispatch = { + binding: HomeAssistantCollectionBinding + collectionName: string + request: HomeAssistantActionRequest +} + +type HomeAssistantInteractiveSystemProps = { + onHomeAssistantDeviceAction?: (payload: HomeAssistantDeviceActionDispatch) => void | Promise +} + +export function HomeAssistantInteractiveSystem({ + onHomeAssistantDeviceAction, +}: HomeAssistantInteractiveSystemProps = {}) { + const selectedLevelId = useViewer((state) => state.selection.levelId) + const sceneNodes = useScene((state) => state.nodes) + const sceneCollections = useScene((state) => state.collections ?? {}) + const updateNode = useScene((state) => state.updateNode) + const setControlValue = useInteractive((state) => state.setControlValue) + const smartHomeOverlayVisibility = useEditor((state) => state.smartHomeOverlayVisibility) + const pendingCollectionActionTimeoutsRef = useRef>({}) + + const homeAssistantBindings = useMemo( + () => getHomeAssistantBindingNodeMap(sceneNodes), + [sceneNodes], + ) + + const roomOverlayNodes = useMemo( + () => + buildHomeAssistantRoomOverlayNodes({ + bindings: homeAssistantBindings, + collections: sceneCollections, + sceneNodes, + selectedLevelId, + visibility: smartHomeOverlayVisibility, + }), + [ + homeAssistantBindings, + sceneCollections, + sceneNodes, + selectedLevelId, + smartHomeOverlayVisibility, + ], + ) + + useEffect( + () => () => { + if (typeof window === 'undefined') { + return + } + for (const timeoutId of Object.values(pendingCollectionActionTimeoutsRef.current)) { + window.clearTimeout(timeoutId) + } + pendingCollectionActionTimeoutsRef.current = {} + }, + [], + ) + + const applyRoomGroupingToCollection = useCallback( + (collectionId: string, nextGroups: string[][]) => { + const controls = + roomOverlayNodes + .find((roomOverlayNode) => roomOverlayNode.id === collectionId) + ?.controlGroups.flatMap((group) => group.members) ?? [] + const bindingNode = homeAssistantBindings[collectionId as CollectionId] + if (!bindingNode) { + return + } + + const nextBinding = getBindingAfterRoomGrouping({ + binding: bindingNode, + collectionId, + controls, + groups: nextGroups, + }) + if (!nextBinding) { + return + } + + updateNode(bindingNode.id, nextBinding as Partial) + requestSceneImmediateSave() + }, + [homeAssistantBindings, roomOverlayNodes, updateNode], + ) + + const copyDeviceResourceToGroup = useCallback( + (sourceCollectionId: CollectionId, targetCollectionId: CollectionId) => { + if (sourceCollectionId === targetCollectionId) { + return + } + + const sourceBinding = homeAssistantBindings[sourceCollectionId] + const targetBindingNode = homeAssistantBindings[targetCollectionId] + if (!(sourceBinding && targetBindingNode)) { + return + } + + const nextBinding = getBindingAfterDeviceResourceCopyToGroup({ + sourceBinding, + targetBinding: targetBindingNode, + targetCollectionId, + }) + if (!nextBinding) { + return + } + + updateNode(targetBindingNode.id, nextBinding as Partial) + requestSceneImmediateSave() + }, + [homeAssistantBindings, updateNode], + ) + + const removeDeviceResourceFromGroup = useCallback( + (member: RoomControlTile) => { + if (!member.resourceId) { + return + } + + const currentBindings = getHomeAssistantBindingNodeMap(useScene.getState().nodes) + const bindingNode = currentBindings[member.collectionId] + if (!bindingNode) { + return + } + + const nextBinding = getBindingAfterDeviceResourceRemovalFromGroup( + bindingNode, + member.resourceId, + ) + if (!nextBinding) { + return + } + + updateNode(bindingNode.id, nextBinding as Partial) + requestSceneImmediateSave() + }, + [updateNode], + ) + + const handleRoomControlChange = useCallback( + ({ member, nextValue, source }: RoomControlChange) => { + if (member.disabled) { + return + } + + const collection = sceneCollections[member.collectionId] + const binding = homeAssistantBindings[member.collectionId] + if (!(collection && binding) || typeof window === 'undefined') { + return + } + + const actionBinding = getActionBindingForMember(binding, member) + if (!actionBinding) { + return + } + + const request = buildCollectionActionRequest(actionBinding, member, nextValue, source) + if (!request) { + return + } + + const visualItemId = member.linkedItemId ?? member.itemId + if ( + source === 'primary' && + member.itemKind === 'tv' && + sceneNodes[visualItemId]?.type === 'item' + ) { + const viewer = useViewer.getState() + if (request.kind === 'toggle') { + if (request.value) { + viewer.triggerItemEffect(visualItemId) + } else { + viewer.clearItemEffect(visualItemId) + } + } else if (request.kind === 'trigger') { + viewer.triggerItemEffect(visualItemId) + } + } + + const existingTimeoutId = pendingCollectionActionTimeoutsRef.current[member.collectionId] + if (existingTimeoutId) { + window.clearTimeout(existingTimeoutId) + } + + const delayMs = request.kind === 'range' ? 120 : 0 + pendingCollectionActionTimeoutsRef.current[member.collectionId] = window.setTimeout(() => { + if (onHomeAssistantDeviceAction) { + void Promise.resolve( + onHomeAssistantDeviceAction({ + binding: actionBinding, + collectionName: getCollectionDisplayName(collection, homeAssistantBindings), + request, + }), + ).catch(() => {}) + } + if (request.kind === 'trigger' && member.control.kind === 'toggle') { + window.setTimeout(() => { + setControlValue(member.itemId, member.controlIndex, false) + if (member.linkedItemId && member.linkedItemId !== member.itemId) { + setControlValue(member.linkedItemId, member.controlIndex, false) + } + }, 220) + } + delete pendingCollectionActionTimeoutsRef.current[member.collectionId] + }, delayMs) + }, + [ + homeAssistantBindings, + onHomeAssistantDeviceAction, + sceneCollections, + sceneNodes, + setControlValue, + ], + ) + + return ( + <> + + + + ) +} diff --git a/packages/editor/src/components/editor/home-assistant-placement-ground-system.tsx b/packages/editor/src/components/editor/home-assistant-placement-ground-system.tsx new file mode 100644 index 000000000..21e6f4e4f --- /dev/null +++ b/packages/editor/src/components/editor/home-assistant-placement-ground-system.tsx @@ -0,0 +1,91 @@ +'use client' + +import { HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT, sceneRegistry } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useMemo } from 'react' +import { Plane, Vector2, Vector3 } from 'three' +import { registerHomeAssistantGroundResolver } from '../../lib/home-assistant-placement-ground' + +export function HomeAssistantPlacementGroundSystem() { + const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) + const raycaster = useThree((state) => state.raycaster) + const selectedLevelId = useViewer((state) => state.selection.levelId) + const ndc = useMemo(() => new Vector2(), []) + const floorPlane = useMemo(() => new Plane(new Vector3(0, 1, 0), 0), []) + const intersection = useMemo(() => new Vector3(), []) + const pillPoint = useMemo(() => new Vector3(), []) + const projectedGround = useMemo(() => new Vector3(), []) + const projectedPill = useMemo(() => new Vector3(), []) + + useEffect(() => { + return registerHomeAssistantGroundResolver((clientX, clientY) => { + const rect = gl.domElement.getBoundingClientRect() + if ( + rect.width <= 0 || + rect.height <= 0 || + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null + } + + const levelObject = selectedLevelId ? sceneRegistry.nodes.get(selectedLevelId) : null + const floorY = levelObject?.position.y ?? 0 + + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -(((clientY - rect.top) / rect.height) * 2 - 1), + ) + floorPlane.constant = -floorY + camera.updateMatrixWorld() + raycaster.setFromCamera(ndc, camera) + + const point = raycaster.ray.intersectPlane(floorPlane, intersection) + if (!point) { + return null + } + + projectedGround.copy(point).project(camera) + pillPoint.set(point.x, point.y + HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT, point.z) + projectedPill.copy(pillPoint).project(camera) + + return { + groundPosition: { + x: point.x, + y: point.y, + z: point.z, + }, + groundScreenPosition: { + x: rect.left + (projectedGround.x * 0.5 + 0.5) * rect.width, + y: rect.top + (-projectedGround.y * 0.5 + 0.5) * rect.height, + }, + pillScreenPosition: { + x: rect.left + (projectedGround.x * 0.5 + 0.5) * rect.width, + y: rect.top + (-projectedPill.y * 0.5 + 0.5) * rect.height, + }, + visible: + projectedGround.z >= -1 && + projectedGround.z <= 1 && + projectedPill.z >= -1 && + projectedPill.z <= 1, + } + }) + }, [ + camera, + floorPlane, + gl, + intersection, + ndc, + pillPoint, + projectedGround, + projectedPill, + raycaster, + selectedLevelId, + ]) + + return null +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 893b63ec3..cf06fdf87 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -7,7 +7,7 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { type HoverStyles, useViewer, Viewer } from '@pascal-app/viewer' import { memo, type ReactNode, @@ -34,7 +34,6 @@ import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-sel import { CeilingSystem } from '../systems/ceiling/ceiling-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' @@ -43,6 +42,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' @@ -61,6 +61,11 @@ import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanPanel } from './floorplan-panel' import { Grid } from './grid' +import { + type HomeAssistantDeviceActionDispatch, + HomeAssistantInteractiveSystem, +} from './home-assistant-interactive-system' +import { HomeAssistantPlacementGroundSystem } from './home-assistant-placement-ground-system' import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' @@ -568,6 +573,16 @@ function PaintCursorBadge({ ) } +function dispatchHomeAssistantDeviceAction(payload: HomeAssistantDeviceActionDispatch) { + void fetch('/api/home-assistant/device-action', { + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }).catch(() => {}) +} + // ── Viewer scene content: memoized so doesn't re-render on mode/viewMode changes ── const ViewerSceneContent = memo(function ViewerSceneContent({ @@ -603,7 +618,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isFirstPersonMode && } - {isFirstPersonMode && } + + ) }) @@ -909,7 +927,6 @@ const ViewerCanvas = memo(function ViewerCanvas({ - {!(isLoading || isVersionPreviewMode) && } ) }) @@ -1074,7 +1091,9 @@ export default function Editor({ - + ) @@ -1146,6 +1165,11 @@ export default function Editor({ )} + {!isVersionPreviewMode && ( +
+ +
+ )}
@@ -1218,6 +1242,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 && ( + )} + + {owner && ownerCollection && canBind && ( + + )} + + {!owner && !isPairing && canBind && ( + + )} + + {canPosition && ( +
+ {isRenaming ? ( + <> + + + + ) : ( + <> + + {openGroupMenuResourceId === resource.id && ( + <> + + + + + )} + + )} +
+ )} + + ) + } + + const panelContent = ( + <> + {positioningResource && ( +
+ {placementGroundPoint && placementLineHeight > 0 && ( +
+ )} + {placementGroundPoint && ( +
+ )} +
+ {positioningResource.label} +
+
+ )} + +
+ {isSmartHomePanelOpen && activePanel && ( +
+
+
+ {selectedPanelProvider && SelectedPanelProviderIcon ? ( + + ) : ( +

+ SMART HOME +

+ )} + {activePanel.kind === 'config' && ( + + )} +
+ +
+ + {activePanel.kind === 'chooser' && ( +
+ {PROVIDERS.map((provider) => { + const ProviderIcon = provider.icon + const isConnected = connectedProviderIds.includes(provider.id) + + return ( +
+ +
+ {isConnected && provider.id === 'home-assistant' ? ( + + ) : null} + {provider.connectable && !isConnected && ( + + )} +
+
+ ) + })} +
+ )} + + {activePanel.kind === 'connect' && activePanel.providerId === 'home-assistant' && ( +
+
+
+ setInstanceUrlInput(event.target.value)} + placeholder="http://homeassistant.local:8123" + value={instanceUrlInput} + /> + +
+
+ +
+
+
+ + Discovered +
+ +
+ +
+ {discoveredInstances.map((instance) => ( + + ))} + + {!isDiscoveringInstances && discoveredInstances.length === 0 && ( +
+ No network instances found +
+ )} +
+
+ + {panelError && ( +
+ {panelError} +
+ )} +
+ )} + + {activePanel.kind === 'config' && activePanel.providerId === 'home-assistant' && ( +
+
+ {([ + { key: 'devices' as const, label: 'Devices', resources: deviceImports }, + { key: 'groups' as const, label: 'Groups', resources: groupImports }, + ] as const).map((section) => { + const isOpen = openSections[section.key] + const SectionChevron = isOpen ? ChevronDown : ChevronRight + const isRenderVisible = smartHomeOverlayVisibility[section.key] + const SectionVisibilityIcon = isRenderVisible ? Eye : EyeOff + + return ( +
+
+ + +
+ + {isOpen && ( +
+ {section.key === 'devices' && + deviceCategoryGroups.map(({ category, resources }) => { + const isCategoryOpen = openDeviceCategories[category] + const CategoryChevron = isCategoryOpen ? ChevronDown : ChevronRight + + return ( +
+ + + {isCategoryOpen && ( +
+ {resources.map((resource) => + renderResourceRow('devices', resource), + )} +
+ )} +
+ ) + })} + + {section.key !== 'devices' && + section.resources.map((resource) => renderResourceRow(section.key, resource))} + + {false && section.key === 'devices' && ( +
+ + {packedDeviceLayout.groups.map((deviceGroup) => { + const primaryGroupId = deviceGroup.group?.id ?? null + const labelCoordinate = deviceGroup.coordinates[0] + + if (!labelCoordinate) { + return null + } + + return ( + +
+ + + {deviceGroup.group?.label ?? 'Other'} + +
+ {deviceGroup.resources.map((resource, resourceIndex) => { + const coordinate = + deviceGroup.coordinates[resourceIndex + 1] + const membershipDots = ( + deviceGroupMemberships.get(resource.id) ?? [] + ) + .filter((group) => group.id !== primaryGroupId) + .map((group) => ({ + color: groupColorById.get(group.id)?.dot ?? '#71717a', + id: group.id, + label: group.label, + })) + + if (!coordinate) { + return null + } + + return ( +
+ {renderResourceRow('devices', resource, membershipDots)} +
+ ) + })} +
+ ) + })} +
+ )} + + {false && section.key !== 'devices' && section.resources.map((resource) => { + const owner = resourceOwners.get(resource.id) + const isBoundToCurrent = + Boolean(owner) && owner?.collectionId === selectedCollection?.id + const isBoundElsewhere = + Boolean(owner) && owner?.collectionId !== selectedCollection?.id + const isPairing = pairingResourceId === resource.id + const canBind = false + const canPosition = section.key === 'groups' + const ownerCollection = owner ? collections[owner.collectionId] : null + const canPreview = Boolean(ownerCollection) && canBind + const isClickable = + canPreview || (canBind && !isBoundElsewhere && !isBoundToCurrent) + const rowIsActive = isBoundToCurrent || (canPosition && Boolean(owner)) + const isRenaming = renamingResourceId === resource.id + + return ( +
+ {isRenaming ? ( +
+
+ {getResourceTypeIcon(resource)} +
+ setRenameDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyGroupRename(resource) + return + } + if (event.key === 'Escape') { + event.preventDefault() + cancelGroupRename() + } + }} + ref={renameInputRef} + value={renameDraft} + /> +
+ ) : ( + + )} + + {owner && ownerCollection && canBind && ( + + )} + + {!owner && !isPairing && canBind && ( + + )} + + {canPosition && ( + <> + + {isRenaming ? ( + <> + + + + ) : ( + + )} + + + )} +
+ ) + })} + + {!isRefreshingImports && section.resources.length === 0 && ( +
+ {section.key === 'devices' + ? 'No devices found' + : section.key === 'groups' + ? 'No groups found' + : 'No actions found'} +
+ )} + + {section.key === 'groups' && ( + + )} +
+ )} +
+ ) + })} +
+
+ )} + {activePanel.kind === 'config' && ( + + )} +
+ )} +
+ + ) + + if (!portalRoot) { + return null + } + + return createPortal(panelContent, portalRoot) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index d5faef6f0..5a8ad6c55 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -187,6 +187,8 @@ function MobilePanelLayer({ export function PanelManager() { const isMobile = useIsMobile() const selectedIds = useViewer((s) => s.selection.selectedIds) + const interactiveOverlayActive = useViewer((s) => s.interactiveOverlayActive) + const homeAssistantPairingResourceId = useEditor((s) => s.homeAssistantPairingResourceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) const mode = useEditor((s) => s.mode) @@ -222,6 +224,10 @@ export function PanelManager() { return } + if (interactiveOverlayActive || homeAssistantPairingResourceId) { + return null + } + if ( isPaintPanelOpen && mode === 'material-paint' && diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index b467f2c7b..4cee1ea47 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -130,8 +130,12 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index 08d6aa641..c6635e831 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ return ( } + actions={} depth={depth} expanded={false} hasChildren={false} @@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index 4ce5dd4e6..67179ae55 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -91,8 +91,12 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 4a4d91942..010efe40d 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -68,6 +68,11 @@ import { WallTreeNode } from './wall-tree-node' import { WindowTreeNode } from './window-tree-node' import { ZoneTreeNode } from './zone-tree-node' +const isBuildingNodeId = (nodeId: AnyNodeId): nodeId is `building_${string}` => + nodeId.startsWith('building_') +const isLevelNodeId = (nodeId: AnyNodeId): nodeId is `level_${string}` => nodeId.startsWith('level_') +const isZoneNodeId = (nodeId: AnyNodeId): nodeId is `zone_${string}` => nodeId.startsWith('zone_') + interface TreeNodeProps { nodeId: AnyNodeId depth?: number @@ -81,11 +86,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr switch (nodeType) { case 'building': - return + return isBuildingNodeId(nodeId) ? ( + + ) : null case 'ceiling': return case 'level': - return + return isLevelNodeId(nodeId) ? ( + + ) : null case 'slab': return case 'spawn': @@ -105,7 +114,9 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr case 'window': return case 'zone': - return + return isZoneNodeId(nodeId) ? ( + + ) : null default: return null } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index eb578441e..04e17f3b4 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -20,7 +20,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((state) => state.updateNode) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) - const color = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.color) + const color = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.color ?? '#3b82f6') const polygon = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.polygon ?? []) const isSelected = useViewer((state) => state.selection.zoneId === nodeId) const isHovered = useViewer((state) => state.hoveredId === nodeId) diff --git a/packages/editor/src/components/ui/viewer-toolbar.tsx b/packages/editor/src/components/ui/viewer-toolbar.tsx index 53fe873ee..3f09b07b9 100644 --- a/packages/editor/src/components/ui/viewer-toolbar.tsx +++ b/packages/editor/src/components/ui/viewer-toolbar.tsx @@ -2,7 +2,17 @@ import { Icon as IconifyIcon } from '@iconify/react' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react' +import { + Check, + ChevronsLeft, + ChevronsRight, + Columns2, + Eye, + Footprints, + HouseWifi, + Moon, + Sun, +} from 'lucide-react' import { useCallback } from 'react' import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' @@ -353,6 +363,7 @@ function PreviewButton() { + + Smart Home + + ) +} + export function ViewerToolbarLeft() { return ( <> @@ -389,6 +422,7 @@ export function ViewerToolbarRight() {
+
) 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/features/home-assistant/room-overlay/room-control-model.ts b/packages/editor/src/features/home-assistant/room-overlay/room-control-model.ts new file mode 100644 index 000000000..3ab95e8f4 --- /dev/null +++ b/packages/editor/src/features/home-assistant/room-overlay/room-control-model.ts @@ -0,0 +1,530 @@ +import type { AnyNodeId, CollectionId, Control, ControlValue } from '@pascal-app/core' + +export type RoomControlTile = { + canDetachFromRoom?: boolean + collectionId: CollectionId + collectionLabel: string + control: Control + controlIndex: number + directActionMode?: 'toggle' | 'trigger' | null + disabled?: boolean + id: string + intensityControl: Extract | null + intensityControlIndex: number | null + itemId: AnyNodeId + itemKind: string + itemName: string + legacyIds?: string[] + linkedItemId?: AnyNodeId + resourceId?: string +} + +export type RoomControlIntensityTile = RoomControlTile & { + intensityControl: Extract + intensityControlIndex: number +} + +export type RoomControlGroupKind = 'toggle' | 'numeric' | 'mixed' + +export type RoomControlGroup = { + collectionId?: CollectionId + controlKind: RoomControlGroupKind + displayName?: string + id: string + itemIds: AnyNodeId[] + members: RoomControlTile[] +} + +export type GroupVisualSegment = { + count: number + itemKind: string +} + +export type GroupIntensitySegment = { + itemKind: string + key: string + members: RoomControlIntensityTile[] + ratio: number +} + +export type RoomOverlayNode = { + anchorNodeIds: AnyNodeId[] + controlGroups: RoomControlGroup[] + id: string + iconOnly?: boolean + roomName: string + screenPosition?: { x: number; y: number } + totalSlotCount: number + worldPosition?: { x: number; y: number; z: number } +} + +export type RoomControlLookupEntry = { + member: RoomControlTile + source: 'primary' | 'intensity' +} + +export type RoomControlChangeSource = RoomControlLookupEntry['source'] + +export type RoomControlChange = { + member: RoomControlTile + nextValue: ControlValue + source: RoomControlChangeSource +} + +export type RoomControlOverlayProps = { + onApplyRoomGrouping?: (roomId: string, nextGroups: string[][]) => void + onCopyRoomControlToRoom?: ( + sourceCollectionId: CollectionId, + targetCollectionId: CollectionId, + ) => void + onRemoveRoomControlFromRoom?: (member: RoomControlTile) => void + onRoomControlChange?: (payload: RoomControlChange) => void + roomOverlayNodes?: RoomOverlayNode[] +} + +export const normalizeRoomControlGroupList = (groups: unknown) => + Array.isArray(groups) + ? groups + .filter(Array.isArray) + .map((group) => + group.filter((memberId): memberId is string => typeof memberId === 'string'), + ) + .filter((group) => group.length > 0) + : [] + +export const selectRoomControlGroupSource = ( + controls: RoomControlTile[], + presentationGroups: string[][], + defaultGroups: string[][], +) => { + if ( + presentationGroups.length > 0 && + roomControlGroupsCoverControls(presentationGroups, controls) + ) { + return presentationGroups + } + + return defaultGroups +} + +const roomControlGroupsCoverControls = (groups: string[][], controls: RoomControlTile[]) => { + if (controls.length === 0 || groups.length === 0) { + return false + } + + const groupIds = new Set(groups.flat()) + return controls.every( + (control) => + groupIds.has(control.id) || (control.legacyIds ?? []).some((id) => groupIds.has(id)), + ) +} + +export const buildRoomControlGroups = ( + controls: RoomControlTile[], + storedGroups: string[][], +): RoomControlGroup[] => { + const controlById = new Map() + for (const control of controls) { + controlById.set(control.id, control) + for (const legacyId of control.legacyIds ?? []) { + controlById.set(legacyId, control) + } + } + const assignedControlIds = new Set() + const groups: RoomControlGroup[] = [] + + for (const storedGroup of storedGroups) { + const members = storedGroup + .map((controlId) => controlById.get(controlId)) + .filter((member): member is RoomControlTile => Boolean(member)) + const compatibleMemberGroups = splitRoomControlMembersByKind(members) + + for (const compatibleMembers of compatibleMemberGroups) { + if (compatibleMembers.length === 0) { + continue + } + for (const member of compatibleMembers) { + assignedControlIds.add(member.id) + } + groups.push(createRoomControlGroup(compatibleMembers)) + } + } + + for (const control of controls) { + if (assignedControlIds.has(control.id)) { + continue + } + groups.push(createRoomControlGroup([control])) + } + + return groups +} + +export const getRoomControlKind = (control: Control): RoomControlGroupKind => + control.kind === 'toggle' ? 'toggle' : 'numeric' + +const getRoomControlGroupKind = (members: RoomControlTile[]): RoomControlGroupKind => { + return getRoomControlKind(members[0]?.control ?? { kind: 'toggle' }) +} + +const splitRoomControlMembersByKind = (members: RoomControlTile[]) => { + const toggleMembers: RoomControlTile[] = [] + const numericMembers: RoomControlTile[] = [] + + for (const member of members) { + if (getRoomControlKind(member.control) === 'toggle') { + toggleMembers.push(member) + } else { + numericMembers.push(member) + } + } + + return [toggleMembers, numericMembers].filter((group) => group.length > 0) +} + +const getRoomControlGroupId = (members: RoomControlTile[]) => + members.map((member) => member.id).join('|') + +const createRoomControlGroup = (members: RoomControlTile[]): RoomControlGroup => { + const collectionIds = Array.from(new Set(members.map((member) => member.collectionId))) + const singleCollectionId = collectionIds.length === 1 ? collectionIds[0] : undefined + const collectionLabel = + singleCollectionId && members.length > 0 ? members[0]?.collectionLabel : undefined + + return { + collectionId: singleCollectionId, + controlKind: getRoomControlGroupKind(members), + displayName: collectionLabel, + id: getRoomControlGroupId(members), + itemIds: Array.from(new Set(members.map((member) => member.linkedItemId ?? member.itemId))), + members, + } +} + +export const canMergeControlGroups = (source: RoomControlGroup, target: RoomControlGroup) => + source.id !== target.id && source.controlKind === target.controlKind + +export const canMergeControlMemberIntoGroup = (member: RoomControlTile, target: RoomControlGroup) => + getRoomControlKind(member.control) === target.controlKind + +export const getGroupItemKind = (group: RoomControlGroup) => { + const itemKinds = Array.from(new Set(group.members.map((member) => member.itemKind))) + return itemKinds.length === 1 ? (itemKinds[0] ?? 'item') : 'group' +} + +export const getMajorityItemKind = (members: Array>) => { + let majorityItemKind = 'item' + let majorityCount = 0 + const counts = new Map() + + for (const member of members) { + const itemKind = member.itemKind || 'item' + const count = (counts.get(itemKind) ?? 0) + 1 + counts.set(itemKind, count) + + if (count > majorityCount) { + majorityItemKind = itemKind + majorityCount = count + } + } + + return majorityItemKind +} + +export const getGroupDisplayKinds = (group: RoomControlGroup) => { + const itemKinds = Array.from(new Set(group.members.map((member) => member.itemKind))) + return itemKinds.length <= 1 ? [itemKinds[0] ?? 'item'] : itemKinds +} + +export const getGroupVisualSegments = (group: RoomControlGroup): GroupVisualSegment[] => { + const counts = new Map() + for (const member of group.members) { + counts.set(member.itemKind, (counts.get(member.itemKind) ?? 0) + 1) + } + + return getGroupDisplayKinds(group).map((itemKind) => ({ + count: counts.get(itemKind) ?? 0, + itemKind, + })) +} + +export const getControlLabel = (control: Control) => { + if (control.kind === 'toggle') { + return control.label?.trim() || 'Power' + } + return control.label?.trim() || (control.kind === 'temperature' ? 'Temperature' : 'Level') +} + +export const getGroupTitle = (group: RoomControlGroup) => { + if (group.displayName) { + return group.displayName + } + if (group.members.length === 1) { + return group.members[0]?.itemName ?? 'Item' + } + return `${group.members.length} items` +} + +export const getGroupSubtitle = (group: RoomControlGroup) => { + if (group.members.length === 1) { + return getControlLabel(group.members[0]!.control) + } + + const names = Array.from(new Set(group.members.map((member) => member.itemName))) + if (names.length <= 2) { + return names.join(', ') + } + return `${names.slice(0, 2).join(', ')} + ${names.length - 2} more` +} + +export const getGroupTooltip = (group: RoomControlGroup) => + group.members.length === 1 + ? `${group.members[0]?.itemName ?? 'Item'}: ${getControlLabel(group.members[0]?.control ?? { kind: 'toggle' })}` + : `${getGroupTitle(group)}: ${getGroupSubtitle(group)}` + +export const getGroupAccessibleLabel = (group: RoomControlGroup) => { + if (group.displayName) { + return group.displayName + } + if (group.members.length === 1) { + return group.members[0]?.itemName ?? 'item' + } + return `${group.members.length} grouped items` +} + +export const hasIntensityControl = (member: RoomControlTile): member is RoomControlIntensityTile => + Boolean(member.intensityControl && member.intensityControlIndex !== null) + +export const getGroupIntensityTiles = (group: RoomControlGroup) => + group.members.filter(hasIntensityControl) + +const getNormalizedSliderValue = ( + control: Extract, + value: ControlValue | undefined, +) => { + const min = control.min + const max = control.max + if (Math.abs(max - min) < 0.001) { + return 1 + } + const resolvedValue = Number(getResolvedControlValue(control, value)) + return Math.max(0, Math.min(1, (resolvedValue - min) / (max - min))) +} + +export const getSliderValueAtRatio = ( + control: Extract, + ratio: number, +) => { + const clampedRatio = Math.max(0, Math.min(1, ratio)) + const rawValue = control.min + (control.max - control.min) * clampedRatio + const step = control.step && control.step > 0 ? control.step : 1 + const snappedValue = control.min + Math.round((rawValue - control.min) / step) * step + return clampNumericControlValue(Number(snappedValue.toFixed(4)), control) +} + +export const getGroupIntensitySegments = ( + group: RoomControlGroup, + controlValues: Record, +): GroupIntensitySegment[] => { + const intensityTiles = getGroupIntensityTiles(group) + if (intensityTiles.length === 0) { + return [] + } + + const kindOrder = Array.from(new Set(intensityTiles.map((member) => member.itemKind))) + const groupedMembers = + kindOrder.length <= 1 + ? [ + { + itemKind: intensityTiles[0]?.itemKind ?? 'item', + key: intensityTiles.map((member) => member.id).join('|'), + members: intensityTiles, + }, + ] + : kindOrder.map((itemKind) => ({ + itemKind, + key: itemKind, + members: intensityTiles.filter((member) => member.itemKind === itemKind), + })) + + return groupedMembers.map((segment) => ({ + ...segment, + ratio: + segment.members.reduce( + (total, member) => + total + + getNormalizedSliderValue( + member.intensityControl, + controlValues[member.itemId]?.controlValues?.[member.intensityControlIndex], + ), + 0, + ) / Math.max(segment.members.length, 1), + })) +} + +export const getControlStep = (control: Extract) => { + if (control.kind === 'temperature') { + return 1 + } + return control.step || 1 +} + +export const clampNumericControlValue = ( + nextValue: number, + control: Extract, +) => Math.max(control.min, Math.min(control.max, nextValue)) + +export const getResolvedControlValue = ( + control: Control, + value: ControlValue | undefined, +): ControlValue => { + if (value !== undefined) { + return value + } + switch (control.kind) { + case 'toggle': + return control.default ?? false + case 'slider': + return control.default ?? control.min + case 'temperature': + return control.default ?? control.min + } +} + +export const formatControlValue = ( + control: Extract, + value: number, +) => { + const rounded = + Math.abs(value - Math.round(value)) < 0.001 ? `${Math.round(value)}` : value.toFixed(1) + const unit = control.unit ? ` ${control.unit}` : '' + return `${rounded}${unit}` +} + +export const applyNumericGroupDelta = ( + group: RoomControlGroup, + controlValues: Record, + onChange: (itemId: AnyNodeId, controlIndex: number, nextValue: ControlValue) => void, + direction: -1 | 1, +) => { + for (const member of group.members) { + if (member.disabled || member.control.kind === 'toggle') { + continue + } + const currentValue = Number( + getResolvedControlValue( + member.control, + controlValues[member.itemId]?.controlValues?.[member.controlIndex], + ), + ) + onChange( + member.itemId, + member.controlIndex, + clampNumericControlValue( + currentValue + getControlStep(member.control) * direction, + member.control, + ), + ) + } +} + +export const getGroupNumericDisplayValue = ( + group: RoomControlGroup, + controlValues: Record, +) => { + const numericMembers = group.members.filter( + ( + member, + ): member is RoomControlTile & { + control: Extract + } => member.control.kind !== 'toggle', + ) + + if (numericMembers.length === 0) { + return '' + } + + const values = numericMembers.map((member) => + Number( + getResolvedControlValue( + member.control, + controlValues[member.itemId]?.controlValues?.[member.controlIndex], + ), + ), + ) + + const firstValue = values[0] ?? 0 + const allSame = values.every((value) => Math.abs(value - firstValue) < 0.001) + if (!allSame) { + return 'Mixed' + } + + return formatControlValue(numericMembers[0]!.control, firstValue) +} + +export const getItemBadgeText = (itemKind: string) => { + switch (itemKind) { + case 'light': + return 'LT' + case 'fan': + return 'FN' + case 'switch': + return 'SW' + case 'outlet': + return 'OT' + case 'shade': + case 'blind': + case 'curtain': + return 'SH' + case 'door': + return 'DR' + case 'window': + return 'WN' + case 'fireplace': + return 'FP' + case 'speaker': + return 'SP' + case 'tv': + return 'TV' + case 'group': + return 'GR' + default: + return 'IT' + } +} + +export const getAccentRgb = (itemKind: string) => { + switch (itemKind) { + case 'light': + return '245, 158, 11' + case 'fan': + return '59, 130, 246' + case 'switch': + return '249, 115, 22' + case 'outlet': + return '168, 85, 247' + case 'shade': + case 'blind': + case 'curtain': + return '14, 165, 233' + case 'door': + return '34, 197, 94' + case 'window': + return '56, 189, 248' + case 'fireplace': + return '239, 68, 68' + case 'speaker': + case 'tv': + return '217, 70, 239' + case 'group': + return '59, 130, 246' + default: + return '148, 163, 184' + } +} + +export const scaleRgb = (rgb: string, factor: number) => + rgb + .split(',') + .map((channel) => Math.max(0, Math.min(255, Math.round(Number(channel.trim()) * factor)))) + .join(', ') diff --git a/packages/editor/src/features/home-assistant/room-overlay/room-control-overlay.tsx b/packages/editor/src/features/home-assistant/room-overlay/room-control-overlay.tsx new file mode 100644 index 000000000..f9c731b4b --- /dev/null +++ b/packages/editor/src/features/home-assistant/room-overlay/room-control-overlay.tsx @@ -0,0 +1,3811 @@ +'use client' + +import { + type AnyNodeId, + type CollectionId, + type ControlValue, + HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT, + type ItemNode, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { type Object3D, Vector3 } from 'three' +import { + applyNumericGroupDelta, + canMergeControlGroups, + canMergeControlMemberIntoGroup, + type GroupIntensitySegment, + getAccentRgb, + getControlLabel, + getGroupAccessibleLabel, + getGroupDisplayKinds, + getGroupIntensitySegments, + getGroupIntensityTiles, + getGroupItemKind, + getGroupNumericDisplayValue, + getGroupTooltip, + getGroupVisualSegments, + getMajorityItemKind, + getResolvedControlValue, + getSliderValueAtRatio, + type RoomControlGroup, + type RoomControlLookupEntry, + type RoomControlOverlayProps, + type RoomControlTile, + scaleRgb, +} from './room-control-model' + +export type { + RoomControlChange, + RoomControlChangeSource, + RoomControlGroup, + RoomControlGroupKind, + RoomControlIntensityTile, + RoomControlOverlayProps, + RoomControlTile, + RoomOverlayNode, +} from './room-control-model' +export { + buildRoomControlGroups, + normalizeRoomControlGroupList, + selectRoomControlGroupSource, +} from './room-control-model' + +const PANEL_CLOSED_MIN_WIDTH = 56 +const PANEL_CLOSED_MAX_WIDTH = 240 +const PANEL_CLOSED_CHAR_WIDTH = 7.2 +const PANEL_CLOSED_HEIGHT = 32 +const DEVICE_ICON_PILL_WIDTH = 44 +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 MIXED_GROUP_ICON_GAP = 4 +const MIN_MIXED_GROUP_ICON_SIZE = 18 +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 POSITIONED_SCREEN_STICK_HEIGHT = 72 +const WORLD_POSITIONED_LINE_VISIBLE_RATIO = 0.5 +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 DEVICE_ICON_DRAG_THRESHOLD_PX = 8 +const COLLAPSED_PILL_SINGLE_CLICK_DELAY_MS = 220 +const ROOM_PANEL_CENTER_DISTANCE_LIMIT = 0.88 +const ROOM_PANEL_OPEN_CENTER_DISTANCE_LIMIT = 1.02 +const EXPANDED_GROUP_PADDING = 6 +const EDIT_GROUP_GRID_ROW_HEIGHT = CONTROL_ICON_BUTTON_SIZE + EXPANDED_GROUP_PADDING * 2 +const EXPANDED_GROUP_GAP = 4 +const MIN_EXPANDED_GROUP_MEMBER_BUTTON_SIZE = 28 +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 DEVICE_OVERLAY_Z_INDEX = { + closed: 10, + editing: 40, + open: 30, +} +const PILL_OVERLAY_Z_INDEX = { + closed: 50, + editing: 80, + open: 70, +} +const _anchor = new Vector3() +const _groundProjected = new Vector3() +const _projected = new Vector3() +const _scratchVector = new Vector3() + +const overlayRootStyle: CSSProperties = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + overflow: 'hidden', + pointerEvents: 'none', +} + +const overlayItemStyle: CSSProperties = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 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, + transformOrigin: 'top center', + visibility: 'hidden', +} + +const nightLineStyle: CSSProperties = { + ...lineStyle, + background: 'rgba(232,235,240,0.86)', + boxShadow: '0 0 8px rgba(232,235,240,0.32)', +} + +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 nightEndpointStyle: CSSProperties = { + ...endpointStyle, + border: '1px solid rgba(232,235,240,0.86)', + background: 'rgba(232,235,240,0.86)', + boxShadow: '0 0 8px rgba(232,235,240,0.32)', +} + +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 iconOnlyPillButtonStyle: CSSProperties = { + ...headerMainButtonStyle, + width: '100%', + height: PANEL_CLOSED_HEIGHT, + minHeight: PANEL_CLOSED_HEIGHT, + padding: 0, +} + +const iconOnlyPillGlyphStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', +} + +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: MIXED_GROUP_ICON_GAP, +} + +const getSharedIconGlyphWrapStyle = (kindCount: number): CSSProperties => + kindCount <= 2 + ? iconGlyphWrapStyle + : { + ...iconGlyphWrapStyle, + transform: `scale(${Math.max(MIN_MIXED_GROUP_ICON_SIZE / CONTROL_ICON_SIZE, 1 - (kindCount - 2) * 0.08)})`, + transformOrigin: 'center', + width: Math.max(MIN_MIXED_GROUP_ICON_SIZE, CONTROL_ICON_SIZE), + } + +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', + top: 1, + right: 1, + bottom: 1, + left: 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 PanelBodyMetrics = { + bodyHeight: number + bodyWidth: number + columns: number + rows: number +} + +type OverlayLayout = { + endpointX?: number + endpointY?: number + lineEndMargin?: number + lineLengthRatio?: number + 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 DeviceIconDragState = { + dragging: boolean + member: RoomControlTile + pointerId: number + pointerX: number + pointerY: number + sourceCollectionId: CollectionId + startX: number + startY: number + targetCollectionId: CollectionId | null +} + +type LongPressAction = 'edit' | 'open-edit' + +type PendingExpandState = { + groupId: string + pointerId: number + startEventTime: number + startedAt: number + startX: number + startY: number +} + +type PendingLongPressState = { + action: LongPressAction + dragGroupId: string | null + key: string + pointerId: number + pointerX: number + pointerY: number + startEventTime: number + startedAt: number + startX: number + startY: number +} + +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 RoomControlOverlay = ({ + onApplyRoomGrouping, + onCopyRoomControlToRoom, + onRemoveRoomControlFromRoom, + onRoomControlChange, + roomOverlayNodes = [], +}: RoomControlOverlayProps = {}) => { + const theme = useViewer((state) => state.theme) + 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 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 [expandedEditGroupByRoomId, setExpandedEditGroupByRoomId] = useState< + Record + >({}) + + const domRefsRef = useRef>({}) + const layoutRef = useRef>({}) + + useEffect(() => { + for (const roomOverlayNode of roomOverlayNodes) { + for (const group of roomOverlayNode.controlGroups) { + for (const member of group.members) { + const interactive = { + controls: [ + member.control, + ...(member.intensityControl ? [member.intensityControl] : []), + ], + effects: [], + } + + if (!sceneNodes[member.itemId]) { + initItem(member.itemId, interactive) + } else if ( + sceneNodes[member.itemId]?.type === 'item' && + ((sceneNodes[member.itemId] as ItemNode).asset.interactive?.controls.length ?? 0) === 0 + ) { + initItem(member.itemId, interactive) + } + + if ( + member.linkedItemId && + member.linkedItemId !== member.itemId && + sceneNodes[member.linkedItemId]?.type === 'item' && + ((sceneNodes[member.linkedItemId] as ItemNode).asset.interactive?.controls.length ?? + 0) === 0 + ) { + initItem(member.linkedItemId, interactive) + } + } + } + } + }, [initItem, roomOverlayNodes, sceneNodes]) + + useEffect(() => { + const activeIds = new Set(roomOverlayNodes.map((node) => node.id)) + + for (const id of Object.keys(domRefsRef.current)) { + if (!activeIds.has(id as AnyNodeId)) { + delete domRefsRef.current[id] + } + } + + for (const id of Object.keys(layoutRef.current)) { + if (!activeIds.has(id as AnyNodeId)) { + delete layoutRef.current[id] + } + } + }, [roomOverlayNodes]) + + 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().setInteractiveOverlayActive(openRoomId !== null) + }, [openRoomId]) + + useEffect(() => { + if (openRoomId && selectedIds.length > 0) { + useViewer.getState().setSelection({ selectedIds: [] }) + } + }, [openRoomId, selectedIds]) + + useEffect( + () => () => { + useViewer.getState().setInteractiveOverlayActive(false) + }, + [], + ) + + 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 primaryKey = `${member.itemId}:${member.controlIndex}` + if (!lookup.has(primaryKey)) { + lookup.set(primaryKey, { member, source: 'primary' }) + } + if (member.intensityControl && member.intensityControlIndex !== null) { + const intensityKey = `${member.itemId}:${member.intensityControlIndex}` + if (!lookup.has(intensityKey)) { + lookup.set(intensityKey, { member, source: 'intensity' }) + } + } + } + } + } + return lookup + }, [roomOverlayNodes]) + + const handleCollectionControlChange = ( + itemId: AnyNodeId, + controlIndex: number, + nextValue: ControlValue, + ) => { + const lookupEntry = roomControlMemberLookup.get(`${itemId}:${controlIndex}`) + if (!lookupEntry || lookupEntry.member.disabled) { + return + } + + setControlValue(itemId, controlIndex, nextValue) + if (lookupEntry.member.linkedItemId && lookupEntry.member.linkedItemId !== itemId) { + setControlValue(lookupEntry.member.linkedItemId, controlIndex, nextValue) + } + onRoomControlChange?.({ + member: lookupEntry.member, + nextValue, + source: lookupEntry.source, + }) + } + + 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 + } + + for (const roomOverlayNode of roomOverlayNodes) { + const refs = domRefsRef.current[roomOverlayNode.id] + const open = openRoomId === roomOverlayNode.id + const expandedEditGroupId = expandedEditGroupByRoomId[roomOverlayNode.id] ?? null + const metrics = getRoomPanelMetrics( + open, + roomOverlayNode.controlGroups, + roomOverlayNode.totalSlotCount, + roomOverlayNode.roomName, + roomOverlayNode.iconOnly, + editingRoomId === roomOverlayNode.id && expandedEditGroupId !== null, + ) + + const anchorObjects = roomOverlayNode.anchorNodeIds + .map((nodeId) => sceneRegistry.nodes.get(nodeId)) + .filter((node): node is Object3D => Boolean(node)) + + if (roomOverlayNode.screenPosition && !roomOverlayNode.worldPosition) { + const x = roomOverlayNode.screenPosition.x * size.width + const y = roomOverlayNode.screenPosition.y * size.height + const collapsedPanelTop = y - PANEL_CLOSED_HEIGHT - POSITIONED_SCREEN_STICK_HEIGHT + const panelTop = Math.min( + Math.max(collapsedPanelTop, 14), + Math.max(14, size.height - metrics.height - PANEL_BOTTOM_MARGIN), + ) + const layout: OverlayLayout = { + opacity: 1, + panelHeight: metrics.height, + panelTop, + panelWidth: metrics.width, + visible: true, + x, + y, + } + + if (!areLayoutsClose(layoutRef.current[roomOverlayNode.id], layout) || refs?.panel) { + applyOverlayLayout(refs, layout) + layoutRef.current[roomOverlayNode.id] = layout + } + continue + } + + if (!(roomOverlayNode.worldPosition || anchorObjects.length > 0)) { + 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 + } + + if (roomOverlayNode.worldPosition) { + _anchor.set( + roomOverlayNode.worldPosition.x, + roomOverlayNode.worldPosition.y + HOME_ASSISTANT_RTS_PILL_WORLD_HEIGHT, + roomOverlayNode.worldPosition.z, + ) + _groundProjected + .set( + roomOverlayNode.worldPosition.x, + roomOverlayNode.worldPosition.y, + roomOverlayNode.worldPosition.z, + ) + .project(camera) + } else { + _anchor.set(0, 0, 0) + for (const anchorObject of anchorObjects) { + anchorObject.updateWorldMatrix(true, false) + anchorObject.getWorldPosition(_scratchVector) + _anchor.add(_scratchVector) + } + _anchor.multiplyScalar(1 / anchorObjects.length) + } + _projected.copy(_anchor).project(camera) + + const projectedX = (_projected.x * 0.5 + 0.5) * size.width + const y = (-_projected.y * 0.5 + 0.5) * size.height + const endpointX = roomOverlayNode.worldPosition + ? (_groundProjected.x * 0.5 + 0.5) * size.width + : undefined + const endpointY = roomOverlayNode.worldPosition + ? (-_groundProjected.y * 0.5 + 0.5) * size.height + : undefined + const x = endpointX ?? projectedX + 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 centerDistanceRatio = getRoomPanelCenterDistanceRatio(x, panelTop, metrics.height, size) + const centerDistanceLimit = open + ? ROOM_PANEL_OPEN_CENTER_DISTANCE_LIMIT + : ROOM_PANEL_CENTER_DISTANCE_LIMIT + + const visible = + centerDistanceRatio <= centerDistanceLimit && + _projected.z >= -1 && + _projected.z <= 1 && + (!roomOverlayNode.worldPosition || (_groundProjected.z >= -1 && _groundProjected.z <= 1)) && + isRoomPanelInsideViewportMargin(x, panelTop, metrics.width, metrics.height, size) + + const layout: OverlayLayout = { + opacity: 1, + panelHeight: metrics.height, + panelTop, + panelWidth: metrics.width, + visible, + endpointX, + endpointY, + lineEndMargin: roomOverlayNode.worldPosition ? 0 : undefined, + lineLengthRatio: roomOverlayNode.worldPosition + ? WORLD_POSITIONED_LINE_VISIBLE_RATIO + : undefined, + x, + y, + } + + if (!areLayoutsClose(layoutRef.current[roomOverlayNode.id], layout) || refs?.panel) { + applyOverlayLayout(refs, layout) + layoutRef.current[roomOverlayNode.id] = layout + } + } + }) + + if (roomOverlayNodes.length === 0) { + return null + } + + const setHoveredItemTargets = (itemIds: AnyNodeId[]) => { + const uniqueIds = Array.from(new Set(itemIds)).filter((itemId) => Boolean(sceneNodes[itemId])) + setHoveredId(uniqueIds[0] ?? null) + setHoveredIds(uniqueIds) + } + + const clearHoveredItemTargets = () => { + setHoveredId(null) + setHoveredIds([]) + } + + return ( + +
+ + {roomOverlayNodes.map((roomOverlayNode) => ( +
+
setOverlayDomRef(domRefsRef.current, roomOverlayNode.id, 'line', node)} + style={theme === 'dark' ? nightLineStyle : lineStyle} + /> +
+ setOverlayDomRef(domRefsRef.current, roomOverlayNode.id, 'endpoint', node) + } + style={theme === 'dark' ? nightEndpointStyle : endpointStyle} + /> + + onApplyRoomGrouping?.(roomOverlayNode.id, nextGroups) + } + onChange={handleCollectionControlChange} + onCopyDeviceToGroup={(sourceCollectionId, targetCollectionId) => + onCopyRoomControlToRoom?.(sourceCollectionId, targetCollectionId) + } + onOpenIntoEdit={() => { + setOpenRoomId(roomOverlayNode.id) + setEditingRoomId(roomOverlayNode.id) + }} + onSetEditing={(editing) => setEditingRoomId(editing ? roomOverlayNode.id : null)} + onSetExpandedGroupId={(groupId) => + setExpandedEditGroupByRoomId((current) => + (current[roomOverlayNode.id] ?? null) === groupId + ? current + : { + ...current, + [roomOverlayNode.id]: groupId, + }, + ) + } + onSetOpen={(open) => { + setOpenRoomId(open ? roomOverlayNode.id : null) + if (!open) { + setEditingRoomId(null) + setExpandedEditGroupByRoomId((current) => + current[roomOverlayNode.id] == null + ? current + : { + ...current, + [roomOverlayNode.id]: null, + }, + ) + clearHoveredItemTargets() + } + }} + onRemoveDeviceFromGroup={(member) => onRemoveRoomControlFromRoom?.(member)} + refsStore={domRefsRef.current} + roomId={roomOverlayNode.id} + roomName={roomOverlayNode.roomName} + totalSlotCount={roomOverlayNode.totalSlotCount} + iconOnly={roomOverlayNode.iconOnly} + setHoveredItemTargets={setHoveredItemTargets} + /> +
+ ))} +
+ + ) +} + +const RoomPanel = ({ + clearHoveredItemTargets, + controlGroups, + controlValues, + editing, + expandedGroupId, + iconOnly, + isOpen, + onApplyGrouping, + onChange, + onCopyDeviceToGroup, + onOpenIntoEdit, + onRemoveDeviceFromGroup, + onSetEditing, + onSetExpandedGroupId, + onSetOpen, + refsStore, + roomId, + roomName, + totalSlotCount, + setHoveredItemTargets, +}: { + clearHoveredItemTargets: () => void + controlGroups: RoomControlGroup[] + controlValues: Record + editing: boolean + expandedGroupId: string | null + iconOnly?: boolean + isOpen: boolean + onApplyGrouping: (nextGroups: string[][]) => void + onChange: (itemId: AnyNodeId, controlIndex: number, nextValue: ControlValue) => void + onCopyDeviceToGroup: (sourceCollectionId: CollectionId, targetCollectionId: CollectionId) => void + onOpenIntoEdit: () => void + onRemoveDeviceFromGroup: (member: RoomControlTile) => void + onSetEditing: (editing: boolean) => void + onSetExpandedGroupId: (groupId: string | null) => void + onSetOpen: (open: boolean) => void + refsStore: Record + roomId: string + roomName: string + totalSlotCount: number + setHoveredItemTargets: (itemIds: AnyNodeId[]) => void +}) => { + const [dragState, setDragState] = useState(null) + const [deviceIconDragState, setDeviceIconDragState] = useState(null) + const [orderedGroupIds, setOrderedGroupIds] = useState(() => + controlGroups.map((group) => group.id), + ) + const [pendingExpand, setPendingExpand] = useState(null) + const dragStateRef = useRef(null) + const collapsedClickTimeoutRef = useRef(null) + const deviceIconDragStateRef = useRef(null) + const iconOnlyClickSuppressedUntilRef = useRef(0) + const longPressRef = useRef(null) + const longPressTimeoutRef = useRef(null) + const suppressedClickRef = useRef(null) + const suppressedClickTimeoutRef = useRef(null) + const editExitActionSuppressedUntilRef = useRef(0) + const lastAppliedGroupingRef = useRef(null) + 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 collapsedDirectControlGroup = + displayedGroups.length === 1 && displayedGroups[0]?.members.length === 1 + ? displayedGroups[0] + : null + const collapsedDirectControlMember = collapsedDirectControlGroup?.members[0] ?? null + const collapsedDirectControlDisabled = Boolean(collapsedDirectControlMember?.disabled) + const collapsedDirectControlValue = collapsedDirectControlMember + ? controlValues[collapsedDirectControlMember.itemId]?.controlValues[ + collapsedDirectControlMember.controlIndex + ] + : undefined + const collapsedDirectCanTrigger = collapsedDirectControlMember?.directActionMode === 'trigger' + const collapsedDirectCanToggle = + collapsedDirectControlMember?.directActionMode === 'toggle' || + (collapsedDirectControlMember?.control.kind === 'toggle' && + iconOnly && + collapsedDirectControlMember.directActionMode !== 'trigger') + const collapsedDirectActionMode = collapsedDirectControlDisabled + ? null + : collapsedDirectCanTrigger + ? 'trigger' + : collapsedDirectCanToggle + ? 'toggle' + : null + const collapsedToggleMembers = displayedGroups.flatMap((group) => + group.members.filter((member) => member.control.kind === 'toggle' && !member.disabled), + ) + const collapsedToggleValues = collapsedToggleMembers.map((member) => + Boolean( + getResolvedControlValue( + member.control, + controlValues[member.itemId]?.controlValues?.[member.controlIndex], + ), + ), + ) + const collapsedAllToggleMembersOn = + collapsedToggleValues.length > 0 && collapsedToggleValues.every(Boolean) + const collapsedAnyToggleMemberOn = collapsedToggleValues.some(Boolean) + const collapsedMajorityItemKind = getMajorityItemKind(collapsedToggleMembers) + const collapsedVisualActive = iconOnly + ? Boolean(collapsedDirectControlValue) + : collapsedAnyToggleMemberOn + const collapsedHasToggleAction = collapsedToggleMembers.length > 0 + const collapsedGroupDisabled = + !iconOnly && + displayedGroups.length > 0 && + displayedGroups.every((group) => group.members.every((member) => member.disabled)) + const collapsedDirectButtonDisabled = iconOnly + ? collapsedDirectControlDisabled || !collapsedDirectActionMode + : collapsedGroupDisabled + + const clearLongPress = useCallback(() => { + if (typeof window !== 'undefined' && longPressTimeoutRef.current !== null) { + window.clearTimeout(longPressTimeoutRef.current) + } + longPressTimeoutRef.current = null + longPressRef.current = null + }, []) + + const clearCollapsedClickTimeout = useCallback(() => { + if (typeof window !== 'undefined' && collapsedClickTimeoutRef.current !== null) { + window.clearTimeout(collapsedClickTimeoutRef.current) + } + collapsedClickTimeoutRef.current = null + }, []) + + const clearSuppressedClick = useCallback(() => { + if (typeof window !== 'undefined' && suppressedClickTimeoutRef.current !== null) { + window.clearTimeout(suppressedClickTimeoutRef.current) + } + suppressedClickTimeoutRef.current = null + suppressedClickRef.current = null + }, []) + + const applyRoomGrouping = useCallback( + (nextGroups: string[][]) => { + const normalizedGroups = nextGroups.filter((group) => group.length > 0) + lastAppliedGroupingRef.current = normalizedGroups + onApplyGrouping(normalizedGroups) + }, + [onApplyGrouping], + ) + + const scheduleSuppressedClickReset = useCallback((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 = useCallback( + (key: string) => { + if (suppressedClickRef.current !== key) { + return false + } + clearSuppressedClick() + return true + }, + [clearSuppressedClick], + ) + + const suppressEditExitActions = useCallback(() => { + 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() + const pointerId = event.pointerId + event.currentTarget.setPointerCapture?.(pointerId) + const nextPendingLongPress = { + action, + dragGroupId, + key, + pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + startEventTime: event.timeStamp, + 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 !== 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() + } + } + + 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 + } + if ( + event.timeStamp - activeLongPress.startEventTime >= GROUP_EXPAND_HOLD_MS && + Math.hypot(event.clientX - activeLongPress.startX, event.clientY - activeLongPress.startY) < + GROUP_EXPAND_DRAG_THRESHOLD_PX + ) { + commitLongPress(activeLongPress) + return + } + clearLongPress() + } + + const clearPendingExpand = useCallback(() => { + if (typeof window !== 'undefined' && expandTimeoutRef.current !== null) { + window.clearTimeout(expandTimeoutRef.current) + } + expandTimeoutRef.current = null + pendingExpandRef.current = null + setPendingExpand(null) + }, []) + + const startGroupDrag = useCallback( + (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) + }, + [clearPendingExpand], + ) + + const startMemberDrag = useCallback( + (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) + }, + [clearPendingExpand], + ) + + const startDeviceIconDrag = ( + member: RoomControlTile, + event: ReactPointerEvent, + ) => { + const nextState = { + dragging: false, + member, + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + sourceCollectionId: member.collectionId, + startX: event.clientX, + startY: event.clientY, + targetCollectionId: null, + } + deviceIconDragStateRef.current = nextState + setDeviceIconDragState(nextState) + } + + const exitEditMode = useCallback(() => { + onApplyGrouping( + lastAppliedGroupingRef.current ?? + orderedGroups + .map((group) => group.members.map((member) => member.id)) + .filter((group) => group.length > 0), + ) + clearPendingExpand() + onSetExpandedGroupId(null) + clearHoveredItemTargets() + onSetEditing(false) + }, [ + clearHoveredItemTargets, + clearPendingExpand, + onApplyGrouping, + onSetEditing, + onSetExpandedGroupId, + orderedGroups, + ]) + + useEffect(() => { + setOrderedGroupIds((current) => + reconcileGroupOrder( + current, + controlGroups.map((group) => group.id), + ), + ) + lastAppliedGroupingRef.current = null + }, [controlGroups]) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + deviceIconDragStateRef.current = deviceIconDragState + }, [deviceIconDragState]) + + useEffect( + () => () => { + clearCollapsedClickTimeout() + }, + [clearCollapsedClickTimeout], + ) + + useEffect(() => { + pendingExpandRef.current = pendingExpand + }, [pendingExpand]) + + useEffect(() => { + const pointerId = deviceIconDragState?.pointerId + if (pointerId === undefined) { + return + } + + const handlePointerMove = (event: PointerEvent) => { + const current = deviceIconDragStateRef.current + if (!current || event.pointerId !== current.pointerId) { + return + } + + const distance = Math.hypot(event.clientX - current.startX, event.clientY - current.startY) + const dragging = current.dragging || distance >= DEVICE_ICON_DRAG_THRESHOLD_PX + const targetCollectionId = dragging + ? getDeviceDropTargetCollectionId(event.clientX, event.clientY, current.sourceCollectionId) + : null + const nextState = { + ...current, + dragging, + pointerX: event.clientX, + pointerY: event.clientY, + targetCollectionId, + } + + deviceIconDragStateRef.current = nextState + setDeviceIconDragState(nextState) + if (dragging) { + event.preventDefault() + } + } + + const handlePointerUp = (event: PointerEvent) => { + const current = deviceIconDragStateRef.current + if (!current || event.pointerId !== current.pointerId) { + return + } + + if (current.dragging) { + event.preventDefault() + suppressRoomPanelNodeEvents() + iconOnlyClickSuppressedUntilRef.current = Date.now() + EDIT_EXIT_ACTION_SUPPRESS_MS + const targetCollectionId = + current.targetCollectionId ?? + getDeviceDropTargetCollectionId(event.clientX, event.clientY, current.sourceCollectionId) + if (targetCollectionId) { + onCopyDeviceToGroup(current.sourceCollectionId, targetCollectionId) + } + } + + deviceIconDragStateRef.current = null + setDeviceIconDragState(null) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [deviceIconDragState?.pointerId, onCopyDeviceToGroup]) + + 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, + ) + const heldLongEnough = + event.timeStamp - activePendingExpand.startEventTime >= GROUP_EXPAND_HOLD_MS + const movedDistance = Math.hypot( + event.clientX - activePendingExpand.startX, + event.clientY - activePendingExpand.startY, + ) + if (heldLongEnough && movedDistance < GROUP_EXPAND_DRAG_THRESHOLD_PX) { + const groupId = activePendingExpand.groupId + clearPendingExpand() + onSetExpandedGroupId(groupId) + return + } + 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) + } + }, [ + clearPendingExpand, + editing, + exitEditMode, + onSetExpandedGroupId, + pendingExpand, + startGroupDrag, + suppressEditExitActions, + ]) + + useEffect(() => { + if (!expandedGroupId) { + return + } + + const expandedGroup = groupById.get(expandedGroupId) + if (!expandedGroup || expandedGroup.members.length < 2) { + onSetExpandedGroupId(null) + } + }, [expandedGroupId, groupById, onSetExpandedGroupId]) + + 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 + } + onSetExpandedGroupId(null) + } + + window.addEventListener('pointerdown', handlePointerDown, true) + return () => { + window.removeEventListener('pointerdown', handlePointerDown, true) + } + }, [dragState, editing, expandedGroupId, onSetExpandedGroupId]) + + 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, + event.clientX, + event.clientY, + ) + ) { + dragStateRef.current = null + setDragState(null) + if (!activeDragState.sourceMemberId) { + suppressEditExitActions() + 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, + ) + const hoveredGroupElement = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest('[data-room-control-group-id]') as HTMLElement | 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) + + const droppedSingleMemberGroupIntoBlankPanel = + sourceMember && + !targetGroup && + sourceGroup.members.length === 1 && + sourceMember.canDetachFromRoom + + if (sourceMember && (releasedOutsidePanel || droppedSingleMemberGroupIntoBlankPanel)) { + const nextGroups = controlGroups.flatMap((group) => { + if (group.id === sourceGroup.id) { + return sourceRemainingIds.length > 0 ? [sourceRemainingIds] : [] + } + return [group.members.map((member) => member.id)] + }) + const nextOrderIds = nextGroups.map((group) => group.join('|')) + + setOrderedGroupIds(nextOrderIds) + applyRoomGrouping(nextGroups) + onRemoveDeviceFromGroup(sourceMember) + } else 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] + }), + ) + + applyRoomGrouping( + 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) + applyRoomGrouping( + controlGroups.flatMap((group) => { + if (group.id === sourceGroup.id) { + return [sourceRemainingIds, [sourceMember.id]] + } + return [group.members.map((member) => member.id)] + }), + ) + } + + onSetExpandedGroupId(null) + } else if ( + sourceGroup && + sourceGroup.members.length === 1 && + sourceGroup.members[0]?.canDetachFromRoom && + (releasedOutsidePanel || !hoveredGroupElement) + ) { + const sourceMember = sourceGroup.members[0] + const nextGroups = controlGroups.flatMap((group) => + group.id === sourceGroup.id ? [] : [group.members.map((member) => member.id)], + ) + const nextOrderIds = nextGroups.map((group) => group.join('|')) + + setOrderedGroupIds(nextOrderIds) + applyRoomGrouping(nextGroups) + if (sourceMember) { + onRemoveDeviceFromGroup(sourceMember) + } + } 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])), + ) + + applyRoomGrouping( + 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] + }), + ) + + applyRoomGrouping( + 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, + applyRoomGrouping, + controlGroups, + currentOrderIds, + dragState, + editing, + exitEditMode, + groupById, + onRemoveDeviceFromGroup, + refsStore, + roomId, + suppressEditExitActions, + ]) + + useEffect(() => { + if (!editing) { + setDragState(null) + onSetExpandedGroupId(null) + clearPendingExpand() + } + }, [clearPendingExpand, editing, onSetExpandedGroupId]) + + useEffect( + () => () => { + clearLongPress() + clearSuppressedClick() + }, + [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) + } + + 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)) + + const handlePanelBodyPointerDown = (event: ReactPointerEvent) => { + if (!editing || dragState || event.target !== event.currentTarget) { + return + } + event.stopPropagation() + suppressRoomPanelNodeEvents() + exitEditMode() + } + + const runCollapsedPillPrimaryAction = () => { + if (collapsedDirectButtonDisabled) { + return + } + + if (iconOnly) { + if (collapsedDirectActionMode && collapsedDirectControlMember) { + onChange( + collapsedDirectControlMember.itemId, + collapsedDirectControlMember.controlIndex, + !collapsedDirectControlValue, + ) + } + return + } + + if (collapsedHasToggleAction) { + const nextValue = !collapsedAllToggleMembersOn + for (const member of collapsedToggleMembers) { + onChange(member.itemId, member.controlIndex, nextValue) + } + } + } + + const scheduleCollapsedPillPrimaryAction = () => { + clearCollapsedClickTimeout() + + if (typeof window === 'undefined') { + runCollapsedPillPrimaryAction() + return + } + + collapsedClickTimeoutRef.current = window.setTimeout(() => { + collapsedClickTimeoutRef.current = null + runCollapsedPillPrimaryAction() + }, COLLAPSED_PILL_SINGLE_CLICK_DELAY_MS) + } + + const collapsedDirectAriaLabel = collapsedDirectControlDisabled + ? `${roomName} is not linked to a controllable device` + : iconOnly && !collapsedDirectActionMode + ? `${roomName} has no direct action` + : iconOnly + ? collapsedDirectActionMode === 'trigger' + ? `Run ${roomName}` + : `Toggle ${roomName}` + : collapsedHasToggleAction + ? `Toggle ${roomName}` + : collapsedDirectActionMode === 'trigger' + ? `Run ${roomName}` + : `Open ${roomName} controls` + + 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, + startEventTime: event.timeStamp, + 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() + onSetExpandedGroupId(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} + {deviceIconDragState?.dragging && 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 ( +