Add native Cast integration
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -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<string, unknown> {
|
||||
type?: string;
|
||||
requestId?: number;
|
||||
}
|
||||
|
||||
interface IPendingRequest {
|
||||
expectedTypes: string[];
|
||||
resolve(payloadArg: ICastJsonPayload): void;
|
||||
reject(errorArg: Error): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class CastClient {
|
||||
private channel?: CastV2Channel;
|
||||
|
||||
constructor(private readonly config: ICastConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<ICastSnapshot> {
|
||||
const deviceInfo = await this.getDeviceInfo();
|
||||
const receiverStatus = await this.getReceiverStatus();
|
||||
const mediaStatus = await this.getMediaStatus(receiverStatus);
|
||||
return { deviceInfo, receiverStatus, mediaStatus };
|
||||
}
|
||||
|
||||
public async getDeviceInfo(): Promise<ICastDeviceInfo> {
|
||||
if (this.config.deviceInfo) {
|
||||
return this.config.deviceInfo;
|
||||
}
|
||||
const payload = await this.requestJson<unknown>('/setup/eureka_info?options=detail');
|
||||
return this.mapEurekaInfo(payload);
|
||||
}
|
||||
|
||||
public async getReceiverStatus(): Promise<ICastReceiverStatus> {
|
||||
if (this.config.receiverStatus) {
|
||||
return this.config.receiverStatus;
|
||||
}
|
||||
return (await this.getChannel()).getReceiverStatus();
|
||||
}
|
||||
|
||||
public async getMediaStatus(receiverStatusArg?: ICastReceiverStatus): Promise<ICastMediaStatus | undefined> {
|
||||
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<void> {
|
||||
await this.launchApp(this.config.defaultMediaReceiverAppId || defaultMediaReceiverAppId);
|
||||
}
|
||||
|
||||
public async turnOff(): Promise<void> {
|
||||
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<ICastReceiverStatus> {
|
||||
return (await this.getChannel()).launchApp(appIdArg);
|
||||
}
|
||||
|
||||
public async loadMedia(optionsArg: ICastMediaLoadOptions): Promise<ICastMediaStatus | undefined> {
|
||||
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<void> {
|
||||
await this.sendMediaCommand('PLAY');
|
||||
}
|
||||
|
||||
public async pause(): Promise<void> {
|
||||
await this.sendMediaCommand('PAUSE');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await this.sendMediaCommand('STOP');
|
||||
}
|
||||
|
||||
public async seek(positionArg: number): Promise<void> {
|
||||
await this.sendMediaCommand('SEEK', { currentTime: positionArg });
|
||||
}
|
||||
|
||||
public async setVolumeLevel(levelArg: number): Promise<void> {
|
||||
await (await this.getChannel()).setVolume({ level: Math.max(0, Math.min(1, levelArg)) });
|
||||
}
|
||||
|
||||
public async setMuted(mutedArg: boolean): Promise<void> {
|
||||
await (await this.getChannel()).setVolume({ muted: mutedArg });
|
||||
}
|
||||
|
||||
public async stepVolume(deltaArg: number): Promise<void> {
|
||||
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<void> {
|
||||
this.channel?.destroy();
|
||||
this.channel = undefined;
|
||||
}
|
||||
|
||||
private async sendMediaCommand(typeArg: string, dataArg: Record<string, unknown> = {}): Promise<void> {
|
||||
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<CastV2Channel> {
|
||||
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<TResult>(pathArg: string): Promise<TResult> {
|
||||
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<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<number, IPendingRequest>();
|
||||
|
||||
constructor(private readonly host: string, private readonly port: number) {}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.socket && !this.socket.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.socket = await new Promise<plugins.tls.TLSSocket>((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<ICastReceiverStatus> {
|
||||
const payload = await this.sendReceiverRequest('GET_STATUS', {}, ['RECEIVER_STATUS']);
|
||||
return (payload.status || {}) as ICastReceiverStatus;
|
||||
}
|
||||
|
||||
public async launchApp(appIdArg: string): Promise<ICastReceiverStatus> {
|
||||
const payload = await this.sendReceiverRequest('LAUNCH', { appId: appIdArg }, ['RECEIVER_STATUS']);
|
||||
return (payload.status || {}) as ICastReceiverStatus;
|
||||
}
|
||||
|
||||
public async stopApp(sessionIdArg: string): Promise<void> {
|
||||
await this.sendReceiverRequest('STOP', { sessionId: sessionIdArg }, ['RECEIVER_STATUS']);
|
||||
}
|
||||
|
||||
public async setVolume(volumeArg: ICastVolume): Promise<void> {
|
||||
await this.sendReceiverRequest('SET_VOLUME', { volume: volumeArg }, ['RECEIVER_STATUS']);
|
||||
}
|
||||
|
||||
public async getMediaStatus(transportIdArg: string): Promise<ICastMediaStatus | undefined> {
|
||||
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<ICastMediaStatus | undefined> {
|
||||
await this.connectNamespace(transportIdArg);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
|
||||
return this.sendRequest(receiverId, receiverNamespace, { ...dataArg, type: typeArg }, expectedTypesArg);
|
||||
}
|
||||
|
||||
private async connectNamespace(destinationIdArg: string): Promise<void> {
|
||||
await this.sendMessage(destinationIdArg, connectionNamespace, { type: 'CONNECT' });
|
||||
}
|
||||
|
||||
private async sendRequest(destinationIdArg: string, namespaceArg: string, payloadArg: Record<string, unknown>, expectedTypesArg: string[]): Promise<ICastJsonPayload> {
|
||||
const requestId = this.nextRequestId++;
|
||||
const payload = { ...payloadArg, requestId };
|
||||
const promise = new Promise<ICastJsonPayload>((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<string, unknown>): Promise<void> {
|
||||
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<void>((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.');
|
||||
}
|
||||
}
|
||||
@@ -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<ICastConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ICastConfig>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ICastConfig> {
|
||||
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<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new CastRuntime(new CastClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantCastIntegration extends CastIntegration {}
|
||||
|
||||
class CastRuntime implements IIntegrationRuntime {
|
||||
public domain = 'cast';
|
||||
|
||||
constructor(private readonly client: CastClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return CastMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return CastMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
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<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private parseAppData(valueArg: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(valueArg) as unknown;
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ICastMdnsRecord> {
|
||||
public id = 'cast-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Google Cast mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: ICastMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
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<ICastManualEntry> {
|
||||
public id = 'cast-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Google Cast setup entries.';
|
||||
|
||||
public async matches(inputArg: ICastManualEntry): Promise<IDiscoveryMatch> {
|
||||
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<IDiscoveryMatch> {
|
||||
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());
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ICastManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
knownHosts?: string[];
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICastMediaLoadOptions {
|
||||
contentId: string;
|
||||
contentType: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
artist?: string;
|
||||
albumName?: string;
|
||||
imageUrl?: string;
|
||||
streamType?: 'BUFFERED' | 'LIVE';
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user