213 lines
9.7 KiB
TypeScript
213 lines
9.7 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 { MpdClient } from './mpd.classes.client.js';
|
|
import { MpdConfigFlow } from './mpd.classes.configflow.js';
|
|
import { createMpdDiscoveryDescriptor } from './mpd.discovery.js';
|
|
import { MpdMapper } from './mpd.mapper.js';
|
|
import type { IMpdConfig, IMpdOutput, IMpdSnapshot } from './mpd.types.js';
|
|
|
|
export class MpdIntegration extends BaseIntegration<IMpdConfig> {
|
|
public readonly domain = 'mpd';
|
|
public readonly displayName = 'Music Player Daemon (MPD)';
|
|
public readonly status = 'control-runtime' as const;
|
|
public readonly discoveryDescriptor = createMpdDiscoveryDescriptor();
|
|
public readonly configFlow = new MpdConfigFlow();
|
|
public readonly metadata = {
|
|
source: 'home-assistant/core',
|
|
upstreamPath: 'homeassistant/components/mpd',
|
|
upstreamDomain: 'mpd',
|
|
integrationType: 'device',
|
|
iotClass: 'local_polling',
|
|
requirements: ['python-mpd2==3.1.1'],
|
|
dependencies: [],
|
|
afterDependencies: [],
|
|
codeowners: [],
|
|
configFlow: true,
|
|
documentation: 'https://www.home-assistant.io/integrations/mpd',
|
|
};
|
|
|
|
public async setup(configArg: IMpdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
void contextArg;
|
|
return new MpdRuntime(new MpdClient(configArg));
|
|
}
|
|
|
|
public async destroy(): Promise<void> {}
|
|
}
|
|
|
|
export class HomeAssistantMpdIntegration extends MpdIntegration {}
|
|
|
|
class MpdRuntime implements IIntegrationRuntime {
|
|
public domain = 'mpd';
|
|
|
|
constructor(private readonly client: MpdClient) {}
|
|
|
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
|
return MpdMapper.toDevices(await this.client.getSnapshot());
|
|
}
|
|
|
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
return MpdMapper.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 === 'mpd') {
|
|
return await this.callMpdService(requestArg);
|
|
}
|
|
return { success: false, error: `Unsupported MPD 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 === 'play' || requestArg.service === 'media_play') {
|
|
await this.client.play();
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
|
await this.client.pause(true);
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
|
const snapshot = await this.client.getSnapshot();
|
|
await this.client.pause(snapshot.status.state === 'play');
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
|
await this.client.stop();
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'next' || requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
|
await this.client.next();
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'previous' || requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
|
await this.client.previous();
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume') {
|
|
await this.client.setVolumeLevel(this.numberValue(requestArg.data?.volume_level ?? requestArg.data?.volume, 'MPD volume control requires data.volume_level or data.volume.'));
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
|
|
await this.client.selectSource(this.stringValue(requestArg.data?.source ?? requestArg.data?.playlist, 'MPD source control requires data.source or data.playlist.'));
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'play_media') {
|
|
await this.client.playMedia(this.stringValue(requestArg.data?.media_content_id ?? requestArg.data?.uri, 'MPD play_media requires data.media_content_id or data.uri.'));
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'enable_output' || requestArg.service === 'disable_output' || requestArg.service === 'toggle_output' || requestArg.service === 'set_output' || requestArg.service === 'output') {
|
|
await this.callOutputService(requestArg);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: `Unsupported MPD media_player service: ${requestArg.service}` };
|
|
}
|
|
|
|
private async callMpdService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
if (requestArg.service === 'snapshot') {
|
|
return { success: true, data: await this.client.snapshot() };
|
|
}
|
|
if (requestArg.service === 'restore') {
|
|
const snapshot = requestArg.data?.snapshot as IMpdSnapshot | undefined;
|
|
await this.client.restore(snapshot);
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'command') {
|
|
const command = this.stringValue(requestArg.data?.command, 'MPD command service requires data.command.');
|
|
const args = this.commandArgs(requestArg.data?.args);
|
|
return { success: true, data: await this.client.command(command, args) };
|
|
}
|
|
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'next' || requestArg.service === 'previous' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume' || requestArg.service === 'select_source' || requestArg.service === 'source') {
|
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
|
}
|
|
if (requestArg.service === 'enable_output' || requestArg.service === 'disable_output' || requestArg.service === 'toggle_output' || requestArg.service === 'set_output' || requestArg.service === 'output') {
|
|
await this.callOutputService(requestArg);
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: `Unsupported MPD service: ${requestArg.service}` };
|
|
}
|
|
|
|
private async callOutputService(requestArg: IServiceCallRequest): Promise<void> {
|
|
const outputId = await this.outputIdFromRequest(requestArg);
|
|
if (requestArg.service === 'toggle_output' || requestArg.service === 'output' && requestArg.data?.enabled === undefined) {
|
|
await this.client.toggleOutput(outputId);
|
|
return;
|
|
}
|
|
const enabled = requestArg.service === 'enable_output'
|
|
? true
|
|
: requestArg.service === 'disable_output'
|
|
? false
|
|
: this.booleanValue(requestArg.data?.enabled, 'MPD set_output/output requires data.enabled.');
|
|
await this.client.setOutput(outputId, enabled);
|
|
}
|
|
|
|
private async outputIdFromRequest(requestArg: IServiceCallRequest): Promise<string | number> {
|
|
const direct = requestArg.data?.output_id ?? requestArg.data?.outputId ?? requestArg.data?.id;
|
|
if (typeof direct === 'string' || typeof direct === 'number') {
|
|
return direct;
|
|
}
|
|
const targetId = requestArg.target.entityId || requestArg.target.deviceId;
|
|
if (!targetId) {
|
|
throw new Error('MPD output control requires data.output_id or a target output switch entity.');
|
|
}
|
|
const snapshot = await this.client.getSnapshot();
|
|
const entity = MpdMapper.toEntities(snapshot).find((entityArg) => entityArg.id === targetId || entityArg.deviceId === targetId);
|
|
const entityOutputId = entity?.attributes?.mpdOutputId;
|
|
if (typeof entityOutputId === 'string' || typeof entityOutputId === 'number') {
|
|
return entityOutputId;
|
|
}
|
|
const output = snapshot.outputs.find((outputArg) => MpdMapper.outputDeviceId(snapshot, outputArg) === targetId || outputEntityId(snapshot, outputArg) === targetId);
|
|
if (!output) {
|
|
throw new Error(`MPD output target was not found: ${targetId}`);
|
|
}
|
|
return output.outputid;
|
|
}
|
|
|
|
private commandArgs(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('MPD command args must be strings, numbers, or booleans.');
|
|
}
|
|
return values as Array<string | number | boolean>;
|
|
}
|
|
|
|
private numberValue(valueArg: unknown, errorArg: string): number {
|
|
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
|
throw new Error(errorArg);
|
|
}
|
|
return valueArg;
|
|
}
|
|
|
|
private booleanValue(valueArg: unknown, errorArg: string): boolean {
|
|
if (typeof valueArg !== 'boolean') {
|
|
throw new Error(errorArg);
|
|
}
|
|
return valueArg;
|
|
}
|
|
|
|
private stringValue(valueArg: unknown, errorArg: string): string {
|
|
if (typeof valueArg !== 'string' || !valueArg) {
|
|
throw new Error(errorArg);
|
|
}
|
|
return valueArg;
|
|
}
|
|
}
|
|
|
|
const outputEntityId = (snapshotArg: IMpdSnapshot, outputArg: IMpdOutput): string => {
|
|
const serverName = snapshotArg.server.name || snapshotArg.server.host || 'Music Player Daemon';
|
|
return `switch.${MpdMapper.slug(serverName)}_${MpdMapper.slug(outputArg.outputname)}_mpd_output`;
|
|
};
|