diff --git a/test/cast/test.cast.discovery.node.ts b/test/cast/test.cast.discovery.node.ts new file mode 100644 index 0000000..406f492 --- /dev/null +++ b/test/cast/test.cast.discovery.node.ts @@ -0,0 +1,24 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createCastDiscoveryDescriptor } from '../../ts/integrations/cast/index.js'; + +tap.test('matches Google Cast mDNS records', async () => { + const descriptor = createCastDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_googlecast._tcp.local.', + name: 'Living Room TV._googlecast._tcp.local.', + host: 'living-room-tv.local', + port: 8009, + txt: { + id: '1234567890abcdef1234567890abcdef', + fn: 'Living Room TV', + md: 'Chromecast', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('living-room-tv.local'); + expect(result.candidate?.port).toEqual(8009); + expect(result.normalizedDeviceId).toEqual('1234567890abcdef1234567890abcdef'); +}); + +export default tap.start(); diff --git a/test/cast/test.cast.mapper.node.ts b/test/cast/test.cast.mapper.node.ts new file mode 100644 index 0000000..b3d3a50 --- /dev/null +++ b/test/cast/test.cast.mapper.node.ts @@ -0,0 +1,39 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { CastMapper } from '../../ts/integrations/cast/index.js'; + +const snapshot = { + deviceInfo: { + uuid: '1234567890abcdef1234567890abcdef', + friendlyName: 'Living Room TV', + modelName: 'Chromecast', + manufacturer: 'Google', + }, + receiverStatus: { + applications: [{ appId: 'CC1AD845', displayName: 'Default Media Receiver', transportId: 'transport-1' }], + volume: { level: 0.44, muted: false }, + isActiveInput: true, + }, + mediaStatus: { + mediaSessionId: 1, + playerState: 'PLAYING', + currentTime: 12, + media: { + contentId: 'https://example.com/movie.mp4', + contentType: 'video/mp4', + duration: 120, + metadata: { title: 'Sample Movie' }, + }, + }, +}; + +tap.test('maps Google Cast snapshots to media devices and entities', async () => { + const devices = CastMapper.toDevices(snapshot); + const entities = CastMapper.toEntities(snapshot); + expect(devices[0].id).toEqual('cast.device.1234567890abcdef1234567890abcdef'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 44)).toBeTrue(); + expect(entities[0].platform).toEqual('media_player'); + expect(entities[0].state).toEqual('playing'); + expect(entities[0].attributes?.mediaTitle).toEqual('Sample Movie'); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 73e5d36..f9d29e1 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,6 +3,7 @@ export * from './protocols/index.js'; export * from './integrations/index.js'; import { HueIntegration } from './integrations/hue/index.js'; +import { CastIntegration } from './integrations/cast/index.js'; import { RokuIntegration } from './integrations/roku/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js'; import { SonosIntegration } from './integrations/sonos/index.js'; @@ -11,6 +12,7 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated import { IntegrationRegistry } from './core/index.js'; export const integrations = [ + new CastIntegration(), new HueIntegration(), new RokuIntegration(), new ShellyIntegration(), diff --git a/ts/integrations/cast/.generated-by-smarthome-exchange b/ts/integrations/cast/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/cast/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/cast/cast.classes.client.ts b/ts/integrations/cast/cast.classes.client.ts new file mode 100644 index 0000000..3c52962 --- /dev/null +++ b/ts/integrations/cast/cast.classes.client.ts @@ -0,0 +1,488 @@ +import * as plugins from '../../plugins.js'; +import type { ICastConfig, ICastDeviceInfo, ICastMediaLoadOptions, ICastMediaStatus, ICastReceiverStatus, ICastSnapshot, ICastVolume } from './cast.types.js'; + +const castPort = 8009; +const httpInfoPort = 8008; +const defaultMediaReceiverAppId = 'CC1AD845'; +const senderId = 'sender-0'; +const receiverId = 'receiver-0'; +const connectionNamespace = 'urn:x-cast:com.google.cast.tp.connection'; +const heartbeatNamespace = 'urn:x-cast:com.google.cast.tp.heartbeat'; +const receiverNamespace = 'urn:x-cast:com.google.cast.receiver'; +const mediaNamespace = 'urn:x-cast:com.google.cast.media'; + +interface ICastV2Message { + protocolVersion?: number; + sourceId?: string; + destinationId?: string; + namespace?: string; + payloadType?: number; + payloadUtf8?: string; +} + +interface ICastJsonPayload extends Record { + type?: string; + requestId?: number; +} + +interface IPendingRequest { + expectedTypes: string[]; + resolve(payloadArg: ICastJsonPayload): void; + reject(errorArg: Error): void; + timer: ReturnType; +} + +export class CastClient { + private channel?: CastV2Channel; + + constructor(private readonly config: ICastConfig) {} + + public async getSnapshot(): Promise { + const deviceInfo = await this.getDeviceInfo(); + const receiverStatus = await this.getReceiverStatus(); + const mediaStatus = await this.getMediaStatus(receiverStatus); + return { deviceInfo, receiverStatus, mediaStatus }; + } + + public async getDeviceInfo(): Promise { + if (this.config.deviceInfo) { + return this.config.deviceInfo; + } + const payload = await this.requestJson('/setup/eureka_info?options=detail'); + return this.mapEurekaInfo(payload); + } + + public async getReceiverStatus(): Promise { + if (this.config.receiverStatus) { + return this.config.receiverStatus; + } + return (await this.getChannel()).getReceiverStatus(); + } + + public async getMediaStatus(receiverStatusArg?: ICastReceiverStatus): Promise { + if (this.config.mediaStatus) { + return this.config.mediaStatus; + } + const receiverStatus = receiverStatusArg || await this.getReceiverStatus(); + const app = receiverStatus.applications?.find((appArg) => appArg.transportId); + if (!app?.transportId) { + return undefined; + } + return (await this.getChannel()).getMediaStatus(app.transportId); + } + + public async turnOn(): Promise { + await this.launchApp(this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId); + } + + public async turnOff(): Promise { + const receiverStatus = await this.getReceiverStatus(); + const app = receiverStatus.applications?.[0]; + if (app?.sessionId) { + await (await this.getChannel()).stopApp(app.sessionId); + } + } + + public async launchApp(appIdArg: string): Promise { + return (await this.getChannel()).launchApp(appIdArg); + } + + public async loadMedia(optionsArg: ICastMediaLoadOptions): Promise { + const channel = await this.getChannel(); + const appId = optionsArg.appId || this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId; + const receiverStatus = await channel.launchApp(appId); + const app = receiverStatus.applications?.find((appArg) => appArg.appId === appId) || receiverStatus.applications?.[0]; + if (!app?.transportId) { + throw new Error('Cast receiver did not return an application transport id.'); + } + return channel.loadMedia(app.transportId, app.sessionId, optionsArg); + } + + public async play(): Promise { + await this.sendMediaCommand('PLAY'); + } + + public async pause(): Promise { + await this.sendMediaCommand('PAUSE'); + } + + public async stop(): Promise { + await this.sendMediaCommand('STOP'); + } + + public async seek(positionArg: number): Promise { + await this.sendMediaCommand('SEEK', { currentTime: positionArg }); + } + + public async setVolumeLevel(levelArg: number): Promise { + await (await this.getChannel()).setVolume({ level: Math.max(0, Math.min(1, levelArg)) }); + } + + public async setMuted(mutedArg: boolean): Promise { + await (await this.getChannel()).setVolume({ muted: mutedArg }); + } + + public async stepVolume(deltaArg: number): Promise { + const status = await this.getReceiverStatus(); + const currentLevel = typeof status.volume?.level === 'number' ? status.volume.level : 0.5; + await this.setVolumeLevel(currentLevel + deltaArg); + } + + public async destroy(): Promise { + this.channel?.destroy(); + this.channel = undefined; + } + + private async sendMediaCommand(typeArg: string, dataArg: Record = {}): Promise { + const receiverStatus = await this.getReceiverStatus(); + const app = receiverStatus.applications?.find((appArg) => appArg.transportId); + if (!app?.transportId) { + throw new Error('Cast media command requires an active receiver application.'); + } + const mediaStatus = await this.getMediaStatus(receiverStatus); + if (typeof mediaStatus?.mediaSessionId !== 'number') { + throw new Error('Cast media command requires an active media session.'); + } + await (await this.getChannel()).sendMediaCommand(app.transportId, typeArg, mediaStatus.mediaSessionId, dataArg); + } + + private async getChannel(): Promise { + if (this.channel) { + return this.channel; + } + if (!this.config.host) { + throw new Error('Cast host is required when fixture data is not provided.'); + } + const channel = new CastV2Channel(this.config.host, this.config.port || castPort); + await channel.connect(); + this.channel = channel; + return channel; + } + + private async requestJson(pathArg: string): Promise { + if (!this.config.host) { + throw new Error('Cast host is required when fixture data is not provided.'); + } + const response = await globalThis.fetch(`${this.httpBaseUrl()}${pathArg}`); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Cast request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + return JSON.parse(text) as TResult; + } + + private mapEurekaInfo(valueArg: unknown): ICastDeviceInfo { + const root = this.asRecord(valueArg); + const deviceInfo = this.asRecord(root.device_info); + const uuid = this.stripUuid(this.stringValue(deviceInfo.ssdp_udn) || this.stringValue(root.ssdp_udn) || this.config.uuid); + return { + uuid, + friendlyName: this.stringValue(root.name) || this.stringValue(deviceInfo.name) || this.config.host, + modelName: this.stringValue(deviceInfo.model_name) || this.stringValue(root.model_name), + manufacturer: this.stringValue(deviceInfo.manufacturer) || 'Google', + castType: this.stringValue(deviceInfo.cast_type) || this.stringValue(root.cast_type), + host: this.config.host, + port: this.config.port || castPort, + buildVersion: this.stringValue(root.build_version), + firmwareVersion: this.stringValue(root.cast_build_revision), + }; + } + + private httpBaseUrl(): string { + return `http://${this.config.host}:${this.config.httpPort || httpInfoPort}`; + } + + private asRecord(valueArg: unknown): Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record : {}; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; + } + + private stripUuid(valueArg?: string): string | undefined { + return valueArg?.replace(/^uuid:/i, '').replace(/-/g, '').toLowerCase(); + } +} + +class CastV2Channel { + private socket?: plugins.tls.TLSSocket; + private buffer = Buffer.alloc(0); + private nextRequestId = 1; + private readonly pendingRequests = new Map(); + + constructor(private readonly host: string, private readonly port: number) {} + + public async connect(): Promise { + if (this.socket && !this.socket.destroyed) { + return; + } + this.socket = await new Promise((resolve, reject) => { + const socket = plugins.tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false, servername: this.host }); + socket.once('secureConnect', () => resolve(socket)); + socket.once('error', reject); + }); + this.socket.on('data', (chunkArg) => this.handleData(chunkArg)); + this.socket.on('error', (errorArg) => this.rejectPending(errorArg)); + this.socket.on('close', () => this.rejectPending(new Error('Cast socket closed.'))); + await this.connectNamespace(receiverId); + } + + public async getReceiverStatus(): Promise { + const payload = await this.sendReceiverRequest('GET_STATUS', {}, ['RECEIVER_STATUS']); + return (payload.status || {}) as ICastReceiverStatus; + } + + public async launchApp(appIdArg: string): Promise { + const payload = await this.sendReceiverRequest('LAUNCH', { appId: appIdArg }, ['RECEIVER_STATUS']); + return (payload.status || {}) as ICastReceiverStatus; + } + + public async stopApp(sessionIdArg: string): Promise { + await this.sendReceiverRequest('STOP', { sessionId: sessionIdArg }, ['RECEIVER_STATUS']); + } + + public async setVolume(volumeArg: ICastVolume): Promise { + await this.sendReceiverRequest('SET_VOLUME', { volume: volumeArg }, ['RECEIVER_STATUS']); + } + + public async getMediaStatus(transportIdArg: string): Promise { + await this.connectNamespace(transportIdArg); + const payload = await this.sendRequest(transportIdArg, mediaNamespace, { type: 'GET_STATUS' }, ['MEDIA_STATUS']); + return this.firstMediaStatus(payload); + } + + public async loadMedia(transportIdArg: string, sessionIdArg: string | undefined, optionsArg: ICastMediaLoadOptions): Promise { + await this.connectNamespace(transportIdArg); + const metadata: Record = {}; + if (optionsArg.title) { + metadata.title = optionsArg.title; + } + if (optionsArg.subtitle) { + metadata.subtitle = optionsArg.subtitle; + } + if (optionsArg.artist) { + metadata.artist = optionsArg.artist; + } + if (optionsArg.albumName) { + metadata.albumName = optionsArg.albumName; + } + if (optionsArg.imageUrl) { + metadata.images = [{ url: optionsArg.imageUrl }]; + } + const media: Record = { + contentId: optionsArg.contentId, + contentType: optionsArg.contentType, + streamType: optionsArg.streamType || 'BUFFERED', + }; + if (Object.keys(metadata).length) { + media.metadata = metadata; + } + const payload = await this.sendRequest(transportIdArg, mediaNamespace, { + type: 'LOAD', + sessionId: sessionIdArg, + media, + autoplay: true, + currentTime: 0, + }, ['MEDIA_STATUS']); + return this.firstMediaStatus(payload); + } + + public async sendMediaCommand(transportIdArg: string, typeArg: string, mediaSessionIdArg: number, dataArg: Record): Promise { + await this.connectNamespace(transportIdArg); + await this.sendRequest(transportIdArg, mediaNamespace, { ...dataArg, type: typeArg, mediaSessionId: mediaSessionIdArg }, ['MEDIA_STATUS']); + } + + public destroy(): void { + this.rejectPending(new Error('Cast socket destroyed.')); + this.socket?.destroy(); + this.socket = undefined; + } + + private async sendReceiverRequest(typeArg: string, dataArg: Record, expectedTypesArg: string[]): Promise { + return this.sendRequest(receiverId, receiverNamespace, { ...dataArg, type: typeArg }, expectedTypesArg); + } + + private async connectNamespace(destinationIdArg: string): Promise { + await this.sendMessage(destinationIdArg, connectionNamespace, { type: 'CONNECT' }); + } + + private async sendRequest(destinationIdArg: string, namespaceArg: string, payloadArg: Record, expectedTypesArg: string[]): Promise { + const requestId = this.nextRequestId++; + const payload = { ...payloadArg, requestId }; + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(requestId); + reject(new Error(`Cast request ${String(payloadArg.type)} timed out.`)); + }, 7000); + this.pendingRequests.set(requestId, { expectedTypes: expectedTypesArg, resolve, reject, timer }); + }); + await this.sendMessage(destinationIdArg, namespaceArg, payload); + return promise; + } + + private async sendMessage(destinationIdArg: string, namespaceArg: string, payloadArg: Record): Promise { + if (!this.socket || this.socket.destroyed) { + throw new Error('Cast socket is not connected.'); + } + const message = this.encodeMessage({ + protocolVersion: 0, + sourceId: senderId, + destinationId: destinationIdArg, + namespace: namespaceArg, + payloadType: 0, + payloadUtf8: JSON.stringify(payloadArg), + }); + const frame = Buffer.alloc(4 + message.length); + frame.writeUInt32BE(message.length, 0); + message.copy(frame, 4); + await new Promise((resolve, reject) => { + this.socket?.write(frame, (errorArg) => errorArg ? reject(errorArg) : resolve()); + }); + } + + private handleData(chunkArg: Buffer | string): void { + const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg); + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32BE(0); + if (this.buffer.length < 4 + length) { + return; + } + const frame = this.buffer.subarray(4, 4 + length); + this.buffer = this.buffer.subarray(4 + length); + this.handleMessage(this.decodeMessage(frame)); + } + } + + private handleMessage(messageArg: ICastV2Message): void { + if (!messageArg.payloadUtf8) { + return; + } + const payload = JSON.parse(messageArg.payloadUtf8) as ICastJsonPayload; + if (messageArg.namespace === heartbeatNamespace && payload.type === 'PING') { + void this.sendMessage(messageArg.sourceId || receiverId, heartbeatNamespace, { type: 'PONG' }); + return; + } + const requestId = typeof payload.requestId === 'number' ? payload.requestId : undefined; + if (requestId === undefined) { + return; + } + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return; + } + if (payload.type === 'INVALID_REQUEST' || payload.type === 'ERROR') { + clearTimeout(pending.timer); + this.pendingRequests.delete(requestId); + pending.reject(new Error(`Cast request failed: ${messageArg.payloadUtf8}`)); + return; + } + if (!pending.expectedTypes.length || pending.expectedTypes.includes(payload.type || '')) { + clearTimeout(pending.timer); + this.pendingRequests.delete(requestId); + pending.resolve(payload); + } + } + + private firstMediaStatus(payloadArg: ICastJsonPayload): ICastMediaStatus | undefined { + const status = payloadArg.status; + return Array.isArray(status) ? status[0] as ICastMediaStatus | undefined : undefined; + } + + private rejectPending(errorArg: Error): void { + for (const [requestId, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + this.pendingRequests.delete(requestId); + pending.reject(errorArg); + } + } + + private encodeMessage(messageArg: ICastV2Message): Buffer { + const chunks = [ + this.encodeVarintField(1, messageArg.protocolVersion || 0), + this.encodeStringField(2, messageArg.sourceId || senderId), + this.encodeStringField(3, messageArg.destinationId || receiverId), + this.encodeStringField(4, messageArg.namespace || receiverNamespace), + this.encodeVarintField(5, messageArg.payloadType || 0), + ]; + if (messageArg.payloadUtf8 !== undefined) { + chunks.push(this.encodeStringField(6, messageArg.payloadUtf8)); + } + return Buffer.concat(chunks); + } + + private decodeMessage(bufferArg: Buffer): ICastV2Message { + const message: ICastV2Message = {}; + let offset = 0; + while (offset < bufferArg.length) { + const key = this.decodeVarint(bufferArg, offset); + offset = key.offset; + const fieldNumber = key.value >> 3; + const wireType = key.value & 7; + if (wireType === 0) { + const value = this.decodeVarint(bufferArg, offset); + offset = value.offset; + if (fieldNumber === 1) { + message.protocolVersion = value.value; + } else if (fieldNumber === 5) { + message.payloadType = value.value; + } + continue; + } + if (wireType === 2) { + const length = this.decodeVarint(bufferArg, offset); + offset = length.offset; + const value = bufferArg.subarray(offset, offset + length.value).toString('utf8'); + offset += length.value; + if (fieldNumber === 2) { + message.sourceId = value; + } else if (fieldNumber === 3) { + message.destinationId = value; + } else if (fieldNumber === 4) { + message.namespace = value; + } else if (fieldNumber === 6) { + message.payloadUtf8 = value; + } + continue; + } + throw new Error(`Unsupported Cast protobuf wire type: ${wireType}`); + } + return message; + } + + private encodeVarintField(fieldNumberArg: number, valueArg: number): Buffer { + return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 0), this.encodeVarint(valueArg)]); + } + + private encodeStringField(fieldNumberArg: number, valueArg: string): Buffer { + const value = Buffer.from(valueArg, 'utf8'); + return Buffer.concat([this.encodeVarint((fieldNumberArg << 3) | 2), this.encodeVarint(value.length), value]); + } + + private encodeVarint(valueArg: number): Buffer { + const bytes: number[] = []; + let value = valueArg >>> 0; + while (value > 127) { + bytes.push((value & 0x7f) | 0x80); + value >>>= 7; + } + bytes.push(value); + return Buffer.from(bytes); + } + + private decodeVarint(bufferArg: Buffer, offsetArg: number): { value: number; offset: number } { + let result = 0; + let shift = 0; + let offset = offsetArg; + while (offset < bufferArg.length) { + const byte = bufferArg[offset++]; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return { value: result >>> 0, offset }; + } + shift += 7; + } + throw new Error('Invalid Cast protobuf varint.'); + } +} diff --git a/ts/integrations/cast/cast.classes.configflow.ts b/ts/integrations/cast/cast.classes.configflow.ts new file mode 100644 index 0000000..6940bce --- /dev/null +++ b/ts/integrations/cast/cast.classes.configflow.ts @@ -0,0 +1,49 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { ICastConfig } from './cast.types.js'; + +export class CastConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Google Cast', + description: 'Configure Google Cast mDNS discovery and optional known hosts.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: false }, + { name: 'port', label: 'Cast port', type: 'number' }, + { name: 'httpPort', label: 'HTTP info port', type: 'number' }, + { name: 'knownHosts', label: 'Known hosts, comma separated', type: 'text' }, + { name: 'uuid', label: 'Allowed UUID', type: 'text' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'Google Cast configured', + config: { + host: this.stringValue(valuesArg.host) || candidateArg.host, + port: this.numberValue(valuesArg.port) || candidateArg.port || 8009, + httpPort: this.numberValue(valuesArg.httpPort) || 8008, + knownHosts: this.listValue(valuesArg.knownHosts), + uuid: this.stringValue(valuesArg.uuid) || candidateArg.id, + }, + }), + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private listValue(valueArg: unknown): string[] { + if (Array.isArray(valueArg)) { + return valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim())).map((itemArg) => itemArg.trim()); + } + if (typeof valueArg !== 'string') { + return []; + } + return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean); + } +} diff --git a/ts/integrations/cast/cast.classes.integration.ts b/ts/integrations/cast/cast.classes.integration.ts index 72503e9..26de1ba 100644 --- a/ts/integrations/cast/cast.classes.integration.ts +++ b/ts/integrations/cast/cast.classes.integration.ts @@ -1,33 +1,163 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { CastClient } from './cast.classes.client.js'; +import { CastConfigFlow } from './cast.classes.configflow.js'; +import { createCastDiscoveryDescriptor } from './cast.discovery.js'; +import { CastMapper } from './cast.mapper.js'; +import type { ICastConfig } from './cast.types.js'; -export class HomeAssistantCastIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "cast", - displayName: "Google Cast", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/cast", - "upstreamDomain": "cast", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "PyChromecast==14.0.10" - ], - "dependencies": [], - "afterDependencies": [ - "cloud", - "http", - "media_source", - "plex", - "tts", - "zeroconf" - ], - "codeowners": [ - "@emontnemery" - ] -}, - }); +export class CastIntegration extends BaseIntegration { + public readonly domain = 'cast'; + public readonly displayName = 'Google Cast'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createCastDiscoveryDescriptor(); + public readonly configFlow = new CastConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/cast', + upstreamDomain: 'cast', + integrationType: 'hub', + iotClass: 'local_polling', + qualityScale: 'unknown', + }; + + public async setup(configArg: ICastConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new CastRuntime(new CastClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantCastIntegration extends CastIntegration {} + +class CastRuntime implements IIntegrationRuntime { + public domain = 'cast'; + + constructor(private readonly client: CastClient) {} + + public async devices(): Promise { + return CastMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return CastMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain !== 'media_player' && requestArg.domain !== 'cast') { + return { success: false, error: `Unsupported Cast service domain: ${requestArg.domain}` }; + } + + if (requestArg.domain === 'cast') { + if (requestArg.service === 'launch_app') { + const appId = requestArg.data?.app_id; + if (typeof appId !== 'string' || !appId) { + return { success: false, error: 'Cast launch_app requires data.app_id.' }; + } + await this.client.launchApp(appId); + return { success: true }; + } + return { success: false, error: `Unsupported Cast service: ${requestArg.service}` }; + } + + if (requestArg.service === 'turn_on') { + await this.client.turnOn(); + return { success: true }; + } + if (requestArg.service === 'turn_off') { + await this.client.turnOff(); + return { success: true }; + } + if (requestArg.service === 'play') { + await this.client.play(); + return { success: true }; + } + if (requestArg.service === 'pause') { + await this.client.pause(); + return { success: true }; + } + if (requestArg.service === 'stop') { + await this.client.stop(); + return { success: true }; + } + if (requestArg.service === 'seek') { + const position = requestArg.data?.seek_position ?? requestArg.data?.position; + if (typeof position !== 'number') { + return { success: false, error: 'Cast seek requires data.seek_position.' }; + } + await this.client.seek(position); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const level = requestArg.data?.volume_level; + if (typeof level !== 'number') { + return { success: false, error: 'Cast volume_set requires data.volume_level.' }; + } + await this.client.setVolumeLevel(level); + return { success: true }; + } + if (requestArg.service === 'volume_mute') { + const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted; + if (typeof muted !== 'boolean') { + return { success: false, error: 'Cast volume_mute requires data.is_volume_muted.' }; + } + await this.client.setMuted(muted); + return { success: true }; + } + if (requestArg.service === 'volume_up' || requestArg.service === 'volume_down') { + await this.client.stepVolume(requestArg.service === 'volume_up' ? 0.05 : -0.05); + return { success: true }; + } + if (requestArg.service === 'select_source') { + const source = requestArg.data?.source; + if (typeof source !== 'string' || !source) { + return { success: false, error: 'Cast select_source requires data.source.' }; + } + await this.client.launchApp(source); + return { success: true }; + } + if (requestArg.service === 'play_media') { + const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.uri; + const mediaType = requestArg.data?.media_content_type ?? requestArg.data?.media_type; + if (typeof mediaId !== 'string' || !mediaId) { + return { success: false, error: 'Cast play_media requires data.media_content_id or data.uri.' }; + } + if (mediaType === 'cast') { + const appData = this.parseAppData(mediaId); + if (typeof appData.app_id === 'string') { + await this.client.launchApp(appData.app_id); + return { success: true }; + } + return { success: false, error: 'Cast app playback currently supports app_id launch payloads.' }; + } + await this.client.loadMedia({ + contentId: mediaId, + contentType: typeof mediaType === 'string' && mediaType ? mediaType : 'video/mp4', + title: typeof requestArg.data?.title === 'string' ? requestArg.data.title : undefined, + subtitle: typeof requestArg.data?.subtitle === 'string' ? requestArg.data.subtitle : undefined, + artist: typeof requestArg.data?.artist === 'string' ? requestArg.data.artist : undefined, + albumName: typeof requestArg.data?.albumName === 'string' ? requestArg.data.albumName : undefined, + imageUrl: typeof requestArg.data?.imageUrl === 'string' ? requestArg.data.imageUrl : undefined, + streamType: mediaType === 'music' || mediaType === 'audio' || mediaType === 'audio/mp3' ? 'LIVE' : 'BUFFERED', + }); + return { success: true }; + } + + return { success: false, error: `Unsupported Cast media_player service: ${requestArg.service}` }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private parseAppData(valueArg: string): Record { + try { + const parsed = JSON.parse(valueArg) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } } } diff --git a/ts/integrations/cast/cast.discovery.ts b/ts/integrations/cast/cast.discovery.ts new file mode 100644 index 0000000..9f8e69a --- /dev/null +++ b/ts/integrations/cast/cast.discovery.ts @@ -0,0 +1,114 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { ICastManualEntry, ICastMdnsRecord } from './cast.types.js'; + +export class CastMdnsMatcher implements IDiscoveryMatcher { + public id = 'cast-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Google Cast mDNS advertisements.'; + + public async matches(recordArg: ICastMdnsRecord): Promise { + const type = this.normalizeType(recordArg.type); + const txtId = this.stripUuid(this.txt(recordArg, 'id')); + const friendlyName = this.txt(recordArg, 'fn') || recordArg.name; + const model = this.txt(recordArg, 'md'); + const matched = type === '_googlecast._tcp.local' || Boolean(txtId || model || friendlyName?.toLowerCase().includes('chromecast')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Google Cast advertisement.' }; + } + return { + matched: true, + confidence: txtId ? 'certain' : 'high', + reason: 'mDNS record matches Google Cast metadata.', + normalizedDeviceId: txtId, + candidate: { + source: 'mdns', + integrationDomain: 'cast', + id: txtId, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port || 8009, + name: friendlyName, + manufacturer: 'Google', + model, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + }, + }, + }; + } + + private txt(recordArg: ICastMdnsRecord, keyArg: string): string | undefined { + return recordArg.txt?.[keyArg] || recordArg.txt?.[keyArg.toUpperCase()]; + } + + private normalizeType(valueArg?: string): string { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); + } + + private stripUuid(valueArg?: string): string | undefined { + return valueArg?.replace(/^uuid:/i, '').replace(/-/g, '').toLowerCase(); + } +} + +export class CastManualMatcher implements IDiscoveryMatcher { + public id = 'cast-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Google Cast setup entries.'; + + public async matches(inputArg: ICastManualEntry): Promise { + const model = inputArg.model?.toLowerCase() || ''; + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const host = inputArg.host || inputArg.knownHosts?.[0]; + const matched = Boolean(host || inputArg.metadata?.cast || manufacturer.includes('google') || model.includes('chromecast') || model.includes('google home') || model.includes('nest')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Google Cast setup hints.' }; + } + return { + matched: true, + confidence: host ? 'high' : 'medium', + reason: 'Manual entry can start Google Cast setup.', + normalizedDeviceId: inputArg.id, + candidate: { + source: 'manual', + integrationDomain: 'cast', + id: inputArg.id, + host, + port: inputArg.port || 8009, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Google', + model: inputArg.model, + metadata: { + ...inputArg.metadata, + knownHosts: inputArg.knownHosts, + }, + }, + }; + } +} + +export class CastCandidateValidator implements IDiscoveryValidator { + public id = 'cast-candidate-validator'; + public description = 'Validate Google Cast candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'cast' || manufacturer.includes('google') || model.includes('chromecast') || model.includes('google home') || model.includes('nest') || Boolean(candidateArg.metadata?.cast); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Google Cast metadata.' : 'Candidate is not Google Cast.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createCastDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'cast', displayName: 'Google Cast' }) + .addMatcher(new CastMdnsMatcher()) + .addMatcher(new CastManualMatcher()) + .addValidator(new CastCandidateValidator()); +}; diff --git a/ts/integrations/cast/cast.mapper.ts b/ts/integrations/cast/cast.mapper.ts new file mode 100644 index 0000000..cce331f --- /dev/null +++ b/ts/integrations/cast/cast.mapper.ts @@ -0,0 +1,111 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { ICastApplication, ICastMediaMetadata, ICastSnapshot } from './cast.types.js'; + +export class CastMapper { + public static toDevices(snapshotArg: ICastSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const volume = snapshotArg.receiverStatus?.volume; + const media = snapshotArg.mediaStatus?.media; + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'cast', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Google', + model: snapshotArg.deviceInfo.modelName, + online: true, + features: [ + { id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true }, + { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' }, + { id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true }, + { id: 'active_app', capability: 'media', name: 'Active app', readable: true, writable: true }, + { id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false }, + ], + state: [ + { featureId: 'playback', value: this.mediaState(snapshotArg), updatedAt }, + { featureId: 'volume', value: typeof volume?.level === 'number' ? Math.round(volume.level * 100) : null, updatedAt }, + { featureId: 'muted', value: typeof volume?.muted === 'boolean' ? volume.muted : null, updatedAt }, + { featureId: 'active_app', value: this.activeApp(snapshotArg)?.displayName || null, updatedAt }, + { featureId: 'current_title', value: this.metadata(snapshotArg).title || media?.contentId || null, updatedAt }, + ], + metadata: { + uuid: snapshotArg.deviceInfo.uuid, + castType: snapshotArg.deviceInfo.castType, + buildVersion: snapshotArg.deviceInfo.buildVersion, + firmwareVersion: snapshotArg.deviceInfo.firmwareVersion, + appId: this.activeApp(snapshotArg)?.appId, + }, + }]; + } + + public static toEntities(snapshotArg: ICastSnapshot): IIntegrationEntity[] { + const media = snapshotArg.mediaStatus?.media; + const metadata = this.metadata(snapshotArg); + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `cast_${this.slug(snapshotArg.deviceInfo.uuid || this.deviceName(snapshotArg))}`, + integrationDomain: 'cast', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(snapshotArg), + attributes: { + volumeLevel: snapshotArg.receiverStatus?.volume?.level, + muted: snapshotArg.receiverStatus?.volume?.muted, + appId: this.activeApp(snapshotArg)?.appId, + appName: this.activeApp(snapshotArg)?.displayName, + mediaTitle: metadata.title, + mediaArtist: metadata.artist, + mediaAlbumName: metadata.albumName, + mediaContentId: media?.contentId, + mediaContentType: media?.contentType, + mediaDuration: media?.duration, + mediaPosition: snapshotArg.mediaStatus?.currentTime, + mediaImageUrl: metadata.images?.[0]?.url || media?.images?.[0]?.url, + source: this.activeApp(snapshotArg)?.displayName, + }, + available: true, + }]; + } + + private static mediaState(snapshotArg: ICastSnapshot): string { + if (snapshotArg.receiverStatus?.isActiveInput === false) { + return 'off'; + } + const playerState = snapshotArg.mediaStatus?.playerState; + if (playerState === 'PLAYING') { + return 'playing'; + } + if (playerState === 'BUFFERING') { + return 'buffering'; + } + if (playerState === 'PAUSED') { + return 'paused'; + } + if (playerState === 'IDLE') { + return 'idle'; + } + return this.activeApp(snapshotArg) ? 'idle' : 'off'; + } + + private static activeApp(snapshotArg: ICastSnapshot): ICastApplication | undefined { + return snapshotArg.receiverStatus?.applications?.[0]; + } + + private static metadata(snapshotArg: ICastSnapshot): ICastMediaMetadata { + return snapshotArg.mediaStatus?.media?.metadata || {}; + } + + private static deviceId(snapshotArg: ICastSnapshot): string { + return `cast.device.${this.slug(snapshotArg.deviceInfo.uuid || this.deviceName(snapshotArg))}`; + } + + private static deviceName(snapshotArg: ICastSnapshot): string { + return snapshotArg.deviceInfo.friendlyName || snapshotArg.deviceInfo.modelName || 'Google Cast'; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'cast'; + } +} diff --git a/ts/integrations/cast/cast.types.ts b/ts/integrations/cast/cast.types.ts index a585cd3..7c78df3 100644 --- a/ts/integrations/cast/cast.types.ts +++ b/ts/integrations/cast/cast.types.ts @@ -1,4 +1,120 @@ -export interface IHomeAssistantCastConfig { - // TODO: replace with the TypeScript-native config for cast. +export interface ICastConfig { + host?: string; + port?: number; + httpPort?: number; + knownHosts?: string[]; + uuid?: string; + ignoreCec?: boolean; + defaultMediaReceiverAppId?: string; + deviceInfo?: ICastDeviceInfo; + receiverStatus?: ICastReceiverStatus; + mediaStatus?: ICastMediaStatus; +} + +export interface ICastDeviceInfo { + uuid?: string; + friendlyName?: string; + modelName?: string; + manufacturer?: string; + castType?: string; + host?: string; + port?: number; + buildVersion?: string; + firmwareVersion?: string; +} + +export interface ICastVolume { + level?: number; + muted?: boolean; + controlType?: string; +} + +export interface ICastApplication { + appId?: string; + displayName?: string; + sessionId?: string; + transportId?: string; + statusText?: string; +} + +export interface ICastReceiverStatus { + applications?: ICastApplication[]; + volume?: ICastVolume; + isActiveInput?: boolean; + displayName?: string; +} + +export interface ICastMediaImage { + url?: string; +} + +export interface ICastMediaMetadata { + title?: string; + subtitle?: string; + artist?: string; + albumName?: string; + albumArtist?: string; + seriesTitle?: string; + season?: number; + episode?: number; + images?: ICastMediaImage[]; [key: string]: unknown; } + +export interface ICastMediaInformation { + contentId?: string; + contentType?: string; + streamType?: string; + duration?: number; + metadata?: ICastMediaMetadata; + images?: ICastMediaImage[]; +} + +export interface ICastMediaStatus { + mediaSessionId?: number; + playerState?: string; + idleReason?: string; + currentTime?: number; + playbackRate?: number; + supportedMediaCommands?: number; + volume?: ICastVolume; + media?: ICastMediaInformation; +} + +export interface ICastSnapshot { + deviceInfo: ICastDeviceInfo; + receiverStatus?: ICastReceiverStatus; + mediaStatus?: ICastMediaStatus; +} + +export interface ICastMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + txt?: Record; +} + +export interface ICastManualEntry { + host?: string; + port?: number; + knownHosts?: string[]; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + metadata?: Record; +} + +export interface ICastMediaLoadOptions { + contentId: string; + contentType: string; + title?: string; + subtitle?: string; + artist?: string; + albumName?: string; + imageUrl?: string; + streamType?: 'BUFFERED' | 'LIVE'; + appId?: string; +} diff --git a/ts/integrations/cast/index.ts b/ts/integrations/cast/index.ts index 04e3407..ae47ce5 100644 --- a/ts/integrations/cast/index.ts +++ b/ts/integrations/cast/index.ts @@ -1,2 +1,6 @@ export * from './cast.classes.integration.js'; +export * from './cast.classes.client.js'; +export * from './cast.classes.configflow.js'; +export * from './cast.discovery.js'; +export * from './cast.mapper.js'; export * from './cast.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 6331b76..69a259e 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -174,7 +174,6 @@ import { HomeAssistantCambridgeAudioIntegration } from '../cambridge_audio/index import { HomeAssistantCameraIntegration } from '../camera/index.js'; import { HomeAssistantCanaryIntegration } from '../canary/index.js'; import { HomeAssistantCasperGlowIntegration } from '../casper_glow/index.js'; -import { HomeAssistantCastIntegration } from '../cast/index.js'; import { HomeAssistantCcm15Integration } from '../ccm15/index.js'; import { HomeAssistantCertExpiryIntegration } from '../cert_expiry/index.js'; import { HomeAssistantChaconDioIntegration } from '../chacon_dio/index.js'; @@ -1630,7 +1629,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantCambridgeAudioInteg generatedHomeAssistantPortIntegrations.push(new HomeAssistantCameraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantCanaryIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantCasperGlowIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantCastIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantCcm15Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantCertExpiryIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantChaconDioIntegration()); @@ -2912,8 +2910,9 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegrati generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveJsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1454; +export const generatedHomeAssistantPortCount = 1453; export const handwrittenHomeAssistantPortDomains = [ + "cast", "hue", "roku", "shelly", diff --git a/ts/plugins.ts b/ts/plugins.ts index cfba5a7..adadcd2 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -2,8 +2,9 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; +import * as tls from 'node:tls'; -export { crypto, fs, path }; +export { crypto, fs, path, tls }; // Project scope import * as shxInterfaces from '@smarthome.exchange/interfaces';