248 lines
14 KiB
TypeScript
248 lines
14 KiB
TypeScript
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 { SqueezeboxClient } from './squeezebox.classes.client.js';
|
|
import { SqueezeboxConfigFlow } from './squeezebox.classes.configflow.js';
|
|
import { createSqueezeboxDiscoveryDescriptor } from './squeezebox.discovery.js';
|
|
import { SqueezeboxMapper } from './squeezebox.mapper.js';
|
|
import type { ISqueezeboxConfig, ISqueezeboxSnapshot } from './squeezebox.types.js';
|
|
|
|
export class SqueezeboxIntegration extends BaseIntegration<ISqueezeboxConfig> {
|
|
public readonly domain = 'squeezebox';
|
|
public readonly displayName = 'Squeezebox (Lyrion Music Server)';
|
|
public readonly status = 'control-runtime' as const;
|
|
public readonly discoveryDescriptor = createSqueezeboxDiscoveryDescriptor();
|
|
public readonly configFlow = new SqueezeboxConfigFlow();
|
|
public readonly metadata = {
|
|
source: 'home-assistant/core',
|
|
upstreamPath: 'homeassistant/components/squeezebox',
|
|
upstreamDomain: 'squeezebox',
|
|
integrationType: 'hub',
|
|
iotClass: 'local_polling',
|
|
qualityScale: 'silver',
|
|
requirements: ['pysqueezebox==0.14.0'],
|
|
dependencies: [],
|
|
afterDependencies: [],
|
|
codeowners: ['@rajlaud', '@pssc', '@peteS-UK'],
|
|
configFlow: true,
|
|
documentation: 'https://www.home-assistant.io/integrations/squeezebox',
|
|
};
|
|
|
|
public async setup(configArg: ISqueezeboxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
void contextArg;
|
|
return new SqueezeboxRuntime(new SqueezeboxClient(configArg));
|
|
}
|
|
|
|
public async destroy(): Promise<void> {}
|
|
}
|
|
|
|
export class HomeAssistantSqueezeboxIntegration extends SqueezeboxIntegration {}
|
|
|
|
class SqueezeboxRuntime implements IIntegrationRuntime {
|
|
public domain = 'squeezebox';
|
|
|
|
constructor(private readonly client: SqueezeboxClient) {}
|
|
|
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
|
return SqueezeboxMapper.toDevices(await this.client.getSnapshot());
|
|
}
|
|
|
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
return SqueezeboxMapper.toEntities(await this.client.getSnapshot());
|
|
}
|
|
|
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
try {
|
|
if (requestArg.domain === 'media_player') {
|
|
return await this.callMediaPlayerService(requestArg);
|
|
}
|
|
if (requestArg.domain === 'squeezebox') {
|
|
return await this.callSqueezeboxService(requestArg);
|
|
}
|
|
return { success: false, error: `Unsupported Squeezebox service domain: ${requestArg.domain}` };
|
|
} catch (errorArg) {
|
|
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
|
}
|
|
}
|
|
|
|
public async destroy(): Promise<void> {
|
|
await this.client.destroy();
|
|
}
|
|
|
|
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
if (requestArg.service === 'media_play' || requestArg.service === 'play') {
|
|
return { success: true, data: await this.client.execute({ command: 'play', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
|
|
return { success: true, data: await this.client.execute({ command: 'pause', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_play_pause' || requestArg.service === 'play_pause') {
|
|
return { success: true, data: await this.client.execute({ command: 'play_pause', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
|
|
return { success: true, data: await this.client.execute({ command: 'stop', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track' || requestArg.service === 'next') {
|
|
return { success: true, data: await this.client.execute({ command: 'next_track', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track' || requestArg.service === 'previous') {
|
|
return { success: true, data: await this.client.execute({ command: 'previous_track', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'media_seek' || requestArg.service === 'seek') {
|
|
return { success: true, data: await this.client.execute({ command: 'seek', playerId: await this.playerIdFromRequest(requestArg), position: this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position') }) };
|
|
}
|
|
if (requestArg.service === 'turn_on') {
|
|
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: true }) };
|
|
}
|
|
if (requestArg.service === 'turn_off') {
|
|
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: false }) };
|
|
}
|
|
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
|
|
return { success: true, data: await this.client.execute({ command: 'set_volume', playerId: await this.playerIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') }) };
|
|
}
|
|
if (requestArg.service === 'volume_up') {
|
|
return { success: true, data: await this.client.execute({ command: 'volume_up', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
|
|
}
|
|
if (requestArg.service === 'volume_down') {
|
|
return { success: true, data: await this.client.execute({ command: 'volume_down', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
|
|
}
|
|
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
|
return { success: true, data: await this.client.execute({ command: 'mute', playerId: await this.playerIdFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') }) };
|
|
}
|
|
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
|
|
return { success: true, data: await this.client.execute({ command: 'select_source', playerId: await this.playerIdFromRequest(requestArg), source: this.stringData(requestArg, 'source') }) };
|
|
}
|
|
if (requestArg.service === 'play_media') {
|
|
return { success: true, data: await this.client.execute({ command: 'play_media', playerId: await this.playerIdFromRequest(requestArg), mediaId: this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'), mediaType: this.stringData(requestArg, 'media_content_type'), enqueue: this.enqueueData(requestArg) }) };
|
|
}
|
|
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
|
|
return { success: true, data: await this.joinPlayers(requestArg) };
|
|
}
|
|
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
|
return { success: true, data: await this.client.execute({ command: 'unsync', playerId: await this.playerIdFromRequest(requestArg) }) };
|
|
}
|
|
return { success: false, error: `Unsupported Squeezebox media_player service: ${requestArg.service}` };
|
|
}
|
|
|
|
private async callSqueezeboxService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
if (requestArg.service === 'snapshot') {
|
|
return { success: true, data: await this.client.snapshot() };
|
|
}
|
|
if (requestArg.service === 'restore') {
|
|
await this.client.restore(requestArg.data?.snapshot as ISqueezeboxSnapshot | undefined);
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'call_method' || requestArg.service === 'call_query' || requestArg.service === 'query' || requestArg.service === 'command') {
|
|
const command = this.stringData(requestArg, 'command');
|
|
if (!command) {
|
|
throw new Error('Squeezebox raw command service requires data.command.');
|
|
}
|
|
const parameters = this.parameterArray(requestArg.data?.parameters ?? requestArg.data?.args);
|
|
const playerId = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || await this.optionalPlayerIdFromRequest(requestArg);
|
|
const data = await this.client.query(playerId, [command, ...parameters.map((itemArg) => String(itemArg))]);
|
|
return { success: true, data };
|
|
}
|
|
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
|
}
|
|
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'media_stop' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume_mute' || requestArg.service === 'mute' || requestArg.service === 'select_source' || requestArg.service === 'source') {
|
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
|
}
|
|
return { success: false, error: `Unsupported Squeezebox service: ${requestArg.service}` };
|
|
}
|
|
|
|
private async joinPlayers(requestArg: IServiceCallRequest): Promise<unknown[]> {
|
|
const leaderId = await this.playerIdFromRequest(requestArg);
|
|
const memberIds = await this.joinMemberIdsFromRequest(requestArg);
|
|
const results: unknown[] = [];
|
|
for (const playerId of memberIds.filter((playerIdArg) => playerIdArg !== leaderId)) {
|
|
results.push(await this.client.execute({ command: 'sync', playerId: leaderId, targetPlayerId: playerId }));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<string> {
|
|
const playerId = await this.optionalPlayerIdFromRequest(requestArg);
|
|
if (playerId) {
|
|
return playerId;
|
|
}
|
|
throw new Error('Squeezebox service call requires data.player_id or a target Squeezebox media_player entity.');
|
|
}
|
|
|
|
private async optionalPlayerIdFromRequest(requestArg: IServiceCallRequest): Promise<string | undefined> {
|
|
const direct = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || this.stringData(requestArg, 'player');
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
const snapshot = await this.client.getSnapshot();
|
|
if (requestArg.target.entityId) {
|
|
const entityPlayerId = SqueezeboxMapper.entityPlayerId(snapshot, requestArg.target.entityId);
|
|
if (entityPlayerId) {
|
|
return entityPlayerId;
|
|
}
|
|
}
|
|
if (requestArg.target.deviceId) {
|
|
const player = snapshot.players.find((playerArg) => SqueezeboxMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
|
|
if (player) {
|
|
return player.playerId;
|
|
}
|
|
}
|
|
return snapshot.players.length === 1 ? snapshot.players[0].playerId : undefined;
|
|
}
|
|
|
|
private async joinMemberIdsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
|
|
const direct = this.stringArrayData(requestArg, 'player_ids') || this.stringArrayData(requestArg, 'playerIds');
|
|
if (direct?.length) {
|
|
return direct;
|
|
}
|
|
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers') || this.stringArrayData(requestArg, 'sync_members');
|
|
if (!members?.length) {
|
|
throw new Error('Squeezebox join service requires data.group_members or data.player_ids.');
|
|
}
|
|
const snapshot = await this.client.getSnapshot();
|
|
return members.map((memberArg) => SqueezeboxMapper.entityPlayerId(snapshot, memberArg) || memberArg);
|
|
}
|
|
|
|
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
|
const value = requestArg.data?.[keyArg];
|
|
return typeof value === 'string' && value ? value : undefined;
|
|
}
|
|
|
|
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
|
const value = requestArg.data?.[keyArg];
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
|
const value = requestArg.data?.[keyArg];
|
|
return typeof value === 'boolean' ? value : undefined;
|
|
}
|
|
|
|
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
|
|
const value = requestArg.data?.[keyArg];
|
|
if (typeof value === 'string') {
|
|
return [value];
|
|
}
|
|
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
|
|
}
|
|
|
|
private parameterArray(valueArg: unknown): Array<string | number | boolean> {
|
|
if (valueArg === undefined) {
|
|
return [];
|
|
}
|
|
const values = Array.isArray(valueArg) ? valueArg : [valueArg];
|
|
if (values.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
|
|
throw new Error('Squeezebox raw command parameters must be strings, numbers, or booleans.');
|
|
}
|
|
return values as Array<string | number | boolean>;
|
|
}
|
|
|
|
private enqueueData(requestArg: IServiceCallRequest): 'play' | 'add' | 'next' | undefined {
|
|
const enqueue = this.stringData(requestArg, 'enqueue') || this.stringData(requestArg, 'media_enqueue');
|
|
if (enqueue === 'add' || enqueue === 'next' || enqueue === 'play') {
|
|
return enqueue;
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|