Add native local AV and device integrations

This commit is contained in:
2026-05-07 16:10:37 +00:00
parent 631ceaebbe
commit d030af1832
72 changed files with 12402 additions and 177 deletions
+12
View File
@@ -18,16 +18,22 @@ import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_track
import { BluesoundIntegration } from './integrations/bluesound/index.js';
import { BoschShcIntegration } from './integrations/bosch_shc/index.js';
import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BrotherIntegration } from './integrations/brother/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js';
import { DaikinIntegration } from './integrations/daikin/index.js';
import { DeconzIntegration } from './integrations/deconz/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
import { DirectvIntegration } from './integrations/directv/index.js';
import { DlnaDmsIntegration } from './integrations/dlna_dms/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
import { DoorbirdIntegration } from './integrations/doorbird/index.js';
import { DsmrIntegration } from './integrations/dsmr/index.js';
import { DunehdIntegration } from './integrations/dunehd/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js';
import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js';
import { FrontierSiliconIntegration } from './integrations/frontier_silicon/index.js';
import { FritzIntegration } from './integrations/fritz/index.js';
import { GlancesIntegration } from './integrations/glances/index.js';
import { Go2rtcIntegration } from './integrations/go2rtc/index.js';
@@ -98,16 +104,22 @@ export const integrations = [
new BluesoundIntegration(),
new BoschShcIntegration(),
new BraviatvIntegration(),
new BrotherIntegration(),
new BroadlinkIntegration(),
new CastIntegration(),
new DaikinIntegration(),
new DeconzIntegration(),
new DenonavrIntegration(),
new DevoloHomeNetworkIntegration(),
new DirectvIntegration(),
new DlnaDmsIntegration(),
new DlnaDmrIntegration(),
new DoorbirdIntegration(),
new DsmrIntegration(),
new DunehdIntegration(),
new EsphomeIntegration(),
new ForkedDaapdIntegration(),
new FrontierSiliconIntegration(),
new FritzIntegration(),
new GlancesIntegration(),
new Go2rtcIntegration(),
@@ -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,109 @@
import { BrotherMapper } from './brother.mapper.js';
import type { IBrotherClientLike, IBrotherConfig, IBrotherRawData, IBrotherRefreshResult, IBrotherSensors, IBrotherSnapshot } from './brother.types.js';
export class BrotherClient {
private currentSnapshot?: IBrotherSnapshot;
constructor(private readonly config: IBrotherConfig) {}
public async getSnapshot(): Promise<IBrotherSnapshot> {
if (this.config.snapshot) {
this.currentSnapshot = BrotherMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasManualData()) {
this.currentSnapshot = BrotherMapper.toSnapshot({
config: this.config,
rawData: this.config.rawData,
sensors: this.config.sensors,
online: this.config.online ?? true,
source: 'manual',
});
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot(
this.config.host
? 'Native Brother SNMP transport is not implemented; provide snapshot, sensors, rawData, or an injected client.'
: 'Brother setup requires config.host with an injected client, or snapshot/sensors/rawData manual data.'
);
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IBrotherRefreshResult> {
const snapshot = await this.getSnapshot();
const success = snapshot.online && snapshot.source !== 'runtime' && !snapshot.error;
return { success, snapshot, error: success ? undefined : snapshot.error };
}
public async ping(): Promise<boolean> {
const snapshot = await this.getSnapshot();
return snapshot.online && snapshot.source !== 'runtime' && !snapshot.error;
}
public hasUsableSource(): boolean {
return Boolean(this.config.snapshot || this.config.client || this.hasManualData());
}
public async destroy(): Promise<void> {}
private async snapshotFromClient(clientArg: IBrotherClientLike): Promise<IBrotherSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (isBrotherSnapshot(result)) {
return BrotherMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return BrotherMapper.toSnapshot({
config: this.config,
printer: result.printer,
rawData: result.rawData,
sensors: result.sensors,
online: result.online ?? true,
source: result.source || 'client',
error: result.error,
});
}
const rawData = clientArg.getRawData ? await clientArg.getRawData() : undefined;
const sensors = clientArg.getSensors ? await clientArg.getSensors() : clientArg.asyncUpdate ? await clientArg.asyncUpdate() : undefined;
if (!rawData && !sensors) {
throw new Error('Brother client must expose getSnapshot(), getRawData(), getSensors(), or asyncUpdate().');
}
return BrotherMapper.toSnapshot({ config: this.config, rawData, sensors, online: true, source: 'client' });
}
private offlineSnapshot(errorArg: string): IBrotherSnapshot {
return BrotherMapper.toSnapshot({
config: this.config,
online: false,
source: 'runtime',
error: errorArg,
});
}
private hasManualData(): boolean {
return Boolean(this.config.rawData || this.config.sensors);
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IBrotherSnapshot): IBrotherSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IBrotherSnapshot;
}
}
const isBrotherSnapshot = (valueArg: unknown): valueArg is IBrotherSnapshot => {
return Boolean(valueArg && typeof valueArg === 'object' && 'printer' in valueArg && 'sensors' in valueArg && 'online' in valueArg);
};
@@ -0,0 +1,99 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { brotherDefaultCommunity, brotherDefaultPort, brotherDefaultTimeoutMs, brotherPrinterTypes } from './brother.constants.js';
import type { IBrotherConfig, IBrotherRawData, IBrotherSensors, IBrotherSnapshot, TBrotherPrinterType } from './brother.types.js';
export class BrotherConfigFlow implements IConfigFlow<IBrotherConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBrotherConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Brother printer',
description: 'Configure Brother printer polling data. Live SNMP transport requires an injected client; snapshots, raw SNMP data, and manual sensor values are supported locally.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'printerType', label: 'Printer type', type: 'select', required: true, options: brotherPrinterTypes.map((typeArg) => ({ label: typeArg, value: typeArg })) },
{ name: 'port', label: 'SNMP port', type: 'number' },
{ name: 'community', label: 'SNMP community', type: 'text' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'model', label: 'Model', type: 'text' },
{ name: 'serial', label: 'Serial number', type: 'text' },
],
submit: async (valuesArg) => {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotFromMetadata(metadata);
const rawData = rawDataFromMetadata(metadata);
const sensors = sensorsFromMetadata(metadata);
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.printer.host;
const hasManualSource = Boolean(snapshot || rawData || sensors || metadata.client);
if (!host && !hasManualSource) {
return { kind: 'error', title: 'Brother setup failed', error: 'Brother setup requires a host, snapshot, sensors, rawData, or injected client.' };
}
const printerType = this.printerTypeValue(valuesArg.printerType) || this.printerTypeValue(metadata.printerType) || this.printerTypeValue(metadata.type) || snapshot?.printer.printerType || 'laser';
const port = this.numberValue(valuesArg.port) || candidateArg.port || this.numberValue(metadata.port) || snapshot?.printer.port || brotherDefaultPort;
const community = this.stringValue(valuesArg.community) || this.stringValue(metadata.community) || brotherDefaultCommunity;
const serial = this.stringValue(valuesArg.serial) || candidateArg.serialNumber || snapshot?.printer.serialNumber || snapshot?.printer.serial || this.stringValue(metadata.serial) || this.stringValue(metadata.serialNumber);
const macAddress = candidateArg.macAddress || snapshot?.printer.macAddress || this.stringValue(metadata.macAddress);
const model = this.stringValue(valuesArg.model) || candidateArg.model || snapshot?.printer.model || this.stringValue(metadata.model);
const name = this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.printer.name;
return {
kind: 'done',
title: 'Brother printer configured',
config: {
host,
port,
community,
printerType,
name,
manufacturer: candidateArg.manufacturer || snapshot?.printer.manufacturer || 'Brother',
model,
serial,
serialNumber: serial,
macAddress,
firmware: snapshot?.printer.firmware || this.stringValue(metadata.firmware),
uniqueId: candidateArg.id || serial || macAddress || (host ? `${host}:${port}` : undefined),
timeoutMs: brotherDefaultTimeoutMs,
snapshot,
rawData,
sensors,
client: metadata.client as IBrotherConfig['client'],
},
};
},
};
}
private printerTypeValue(valueArg: unknown): TBrotherPrinterType | undefined {
const value = this.stringValue(valueArg)?.toLowerCase();
return value === 'ink' || value === 'laser' ? value : undefined;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
}
}
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IBrotherSnapshot | undefined => {
const snapshot = metadataArg.snapshot;
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'sensors' in snapshot ? snapshot as IBrotherSnapshot : undefined;
};
const rawDataFromMetadata = (metadataArg: Record<string, unknown>): IBrotherRawData | undefined => {
const rawData = metadataArg.rawData;
return rawData && typeof rawData === 'object' && !Array.isArray(rawData) ? rawData as IBrotherRawData : undefined;
};
const sensorsFromMetadata = (metadataArg: Record<string, unknown>): IBrotherSensors | undefined => {
const sensors = metadataArg.sensors;
return sensors && typeof sensors === 'object' && !Array.isArray(sensors) ? sensors as IBrotherSensors : undefined;
};
@@ -1,29 +1,100 @@
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 { BrotherClient } from './brother.classes.client.js';
import { BrotherConfigFlow } from './brother.classes.configflow.js';
import { brotherDefaultCommunity, brotherDefaultPort, brotherDomain } from './brother.constants.js';
import { createBrotherDiscoveryDescriptor } from './brother.discovery.js';
import { BrotherMapper } from './brother.mapper.js';
import type { IBrotherConfig } from './brother.types.js';
export class HomeAssistantBrotherIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "brother",
displayName: "Brother Printer",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/brother",
"upstreamDomain": "brother",
"integrationType": "device",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"brother==6.1.0"
],
"dependencies": [],
"afterDependencies": [
"snmp"
],
"codeowners": [
"@bieniu"
]
},
});
export class BrotherIntegration extends BaseIntegration<IBrotherConfig> {
public readonly domain = brotherDomain;
public readonly displayName = 'Brother Printer';
public readonly status = 'read-only-runtime' as const;
public readonly discoveryDescriptor = createBrotherDiscoveryDescriptor();
public readonly configFlow = new BrotherConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/brother',
upstreamDomain: brotherDomain,
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: 'platinum',
requirements: ['brother==6.1.0'],
dependencies: [] as string[],
afterDependencies: ['snmp'],
codeowners: ['@bieniu'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/brother',
zeroconf: [{ name: 'brother*', type: '_printer._tcp.local.' }],
defaults: {
port: brotherDefaultPort,
community: brotherDefaultCommunity,
printerType: 'laser',
},
runtime: {
type: 'read-only-runtime',
polling: 'snapshot/manual data and injected SNMP-style clients; native UDP SNMP transport is not implemented',
services: ['snapshot', 'status', 'refresh'],
controls: false,
},
localApi: {
implemented: [
'Home Assistant Brother zeroconf shape (_printer._tcp.local. with brother* service name)',
'Brother Python library raw SNMP OID snapshot parsing for counters, maintenance, nextcare, status, model, serial, MAC, firmware, and uptime',
'manual sensor snapshots and injected clients exposing getSnapshot(), getRawData(), getSensors(), or asyncUpdate()',
],
explicitUnsupported: [
'native live UDP SNMP transport without an injected client',
'SNMP writes such as setting printer datetime',
'claiming command success for any read-only Brother printer operation',
],
},
};
public async setup(configArg: IBrotherConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new BrotherRuntime(new BrotherClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantBrotherIntegration extends BrotherIntegration {}
class BrotherRuntime implements IIntegrationRuntime {
public domain = brotherDomain;
constructor(private readonly client: BrotherClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return BrotherMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BrotherMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain !== brotherDomain) {
return { success: false, error: `Unsupported Brother service domain: ${requestArg.domain}` };
}
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'refresh') {
const result = await this.client.refresh();
return result.success ? { success: true, data: result.snapshot } : { success: false, error: result.error || 'Brother refresh requires snapshot, sensors, rawData, or an injected client.', data: result.snapshot };
}
return { success: false, error: `Unsupported Brother service: ${requestArg.service}. This integration is read-only.` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,163 @@
import type { IBrotherSensorDefinition } from './brother.types.js';
export const brotherDomain = 'brother';
export const brotherDefaultPort = 161;
export const brotherDefaultCommunity = 'public';
export const brotherDefaultTimeoutMs = 20000;
export const brotherPrinterTypes = ['laser', 'ink'] as const;
export const brotherOids = {
charset: '1.3.6.1.2.1.43.7.1.1.4.1.1',
counters: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.10.0',
firmware: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.17.0',
mac: '1.3.6.1.2.1.2.2.1.6.1',
maintenance: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.8.0',
model: '1.3.6.1.4.1.2435.2.3.9.1.1.7.0',
nextcare: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0',
pageCounter: '1.3.6.1.2.1.43.10.2.1.4.1.1',
serial: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.1.0',
status: '1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0',
uptime: '1.3.6.1.2.1.1.3.0',
} as const;
export const brotherCounterValues: Record<string, string> = {
'00': 'page_counter',
'01': 'bw_counter',
'02': 'color_counter',
'06': 'duplex_unit_pages_counter',
'12': 'black_counter',
'13': 'cyan_counter',
'14': 'magenta_counter',
'15': 'yellow_counter',
'16': 'image_counter',
};
export const brotherLaserMaintenanceValues: Record<string, string> = {
'11': 'drum_counter',
'31': 'black_toner_status',
'32': 'cyan_toner_status',
'33': 'magenta_toner_status',
'34': 'yellow_toner_status',
'41': 'drum_remaining_life',
'63': 'drum_status',
'69': 'belt_unit_remaining_life',
'6a': 'fuser_remaining_life',
'6b': 'laser_remaining_life',
'6c': 'pf_kit_mp_remaining_life',
'6d': 'pf_kit_1_remaining_life',
'6f': 'black_toner_remaining',
'70': 'cyan_toner_remaining',
'71': 'magenta_toner_remaining',
'72': 'yellow_toner_remaining',
'73': 'cyan_drum_counter',
'74': 'magenta_drum_counter',
'75': 'yellow_drum_counter',
'79': 'cyan_drum_remaining_life',
'7a': 'magenta_drum_remaining_life',
'7b': 'yellow_drum_remaining_life',
'7e': 'black_drum_counter',
'80': 'black_drum_remaining_life',
'81': 'black_toner',
'82': 'cyan_toner',
'83': 'magenta_toner',
'84': 'yellow_toner',
a1: 'black_toner_remaining',
a2: 'cyan_toner_remaining',
a3: 'magenta_toner_remaining',
a4: 'yellow_toner_remaining',
};
export const brotherInkMaintenanceValues: Record<string, string> = {
'31': 'black_ink_status',
'32': 'cyan_ink_status',
'33': 'magenta_ink_status',
'34': 'yellow_ink_status',
'6f': 'black_ink_remaining',
'70': 'cyan_ink_remaining',
'71': 'magenta_ink_remaining',
'72': 'yellow_ink_remaining',
'81': 'black_ink',
'82': 'cyan_ink',
'83': 'magenta_ink',
'84': 'yellow_ink',
a1: 'black_ink_remaining',
a2: 'cyan_ink_remaining',
a3: 'magenta_ink_remaining',
a4: 'yellow_ink_remaining',
};
export const brotherLaserNextcareValues: Record<string, string> = {
'73': 'laser_unit_remaining_pages',
'77': 'pf_kit_1_remaining_pages',
'82': 'drum_remaining_pages',
'86': 'pf_kit_mp_remaining_pages',
'88': 'belt_unit_remaining_pages',
'89': 'fuser_unit_remaining_pages',
a4: 'black_drum_remaining_pages',
a5: 'cyan_drum_remaining_pages',
a6: 'magenta_drum_remaining_pages',
a7: 'yellow_drum_remaining_pages',
};
export const brotherPercentSensorKeys = new Set([
'belt_unit_remaining_life',
'black_drum_remaining_life',
'black_ink_remaining',
'black_toner_remaining',
'cyan_drum_remaining_life',
'cyan_ink_remaining',
'cyan_toner_remaining',
'drum_remaining_life',
'fuser_remaining_life',
'laser_remaining_life',
'magenta_drum_remaining_life',
'magenta_ink_remaining',
'magenta_toner_remaining',
'pf_kit_1_remaining_life',
'pf_kit_mp_remaining_life',
'yellow_drum_remaining_life',
'yellow_ink_remaining',
'yellow_toner_remaining',
]);
export const brotherSensorDefinitions: IBrotherSensorDefinition[] = [
{ key: 'status', name: 'Status', entityCategory: 'diagnostic' },
{ key: 'page_counter', name: 'Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'bw_counter', name: 'B/W Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'color_counter', name: 'Color Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'duplex_unit_pages_counter', name: 'Duplex Unit Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'drum_remaining_life', name: 'Drum Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'drum_remaining_pages', name: 'Drum Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'drum_counter', name: 'Drum Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'black_drum_remaining_life', name: 'Black Drum Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'black_drum_remaining_pages', name: 'Black Drum Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'black_drum_counter', name: 'Black Drum Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'cyan_drum_remaining_life', name: 'Cyan Drum Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'cyan_drum_remaining_pages', name: 'Cyan Drum Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'cyan_drum_counter', name: 'Cyan Drum Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'magenta_drum_remaining_life', name: 'Magenta Drum Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'magenta_drum_remaining_pages', name: 'Magenta Drum Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'magenta_drum_counter', name: 'Magenta Drum Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'yellow_drum_remaining_life', name: 'Yellow Drum Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'yellow_drum_remaining_pages', name: 'Yellow Drum Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'yellow_drum_counter', name: 'Yellow Drum Page Counter', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'belt_unit_remaining_life', name: 'Belt Unit Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'belt_unit_remaining_pages', name: 'Belt Unit Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'fuser_remaining_life', name: 'Fuser Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'fuser_unit_remaining_pages', name: 'Fuser Unit Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'laser_remaining_life', name: 'Laser Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'laser_unit_remaining_pages', name: 'Laser Unit Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'pf_kit_1_remaining_life', name: 'PF Kit 1 Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'pf_kit_1_remaining_pages', name: 'PF Kit 1 Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'pf_kit_mp_remaining_life', name: 'PF Kit MP Remaining Life', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'pf_kit_mp_remaining_pages', name: 'PF Kit MP Remaining Pages', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'black_toner_remaining', name: 'Black Toner Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'cyan_toner_remaining', name: 'Cyan Toner Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'magenta_toner_remaining', name: 'Magenta Toner Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'yellow_toner_remaining', name: 'Yellow Toner Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'black_ink_remaining', name: 'Black Ink Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'cyan_ink_remaining', name: 'Cyan Ink Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'magenta_ink_remaining', name: 'Magenta Ink Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'yellow_ink_remaining', name: 'Yellow Ink Remaining', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'uptime', name: 'Uptime', deviceClass: 'timestamp', entityCategory: 'diagnostic', enabledDefault: false },
];
@@ -0,0 +1,269 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { brotherDefaultCommunity, brotherDefaultPort, brotherDomain } from './brother.constants.js';
import { BrotherMapper } from './brother.mapper.js';
import type { IBrotherManualEntry, IBrotherMdnsRecord, IBrotherRawData, IBrotherSensors, IBrotherSnapshot, TBrotherPrinterType } from './brother.types.js';
const brotherPrinterMdnsType = '_printer._tcp.local';
export class BrotherMdnsMatcher implements IDiscoveryMatcher<IBrotherMdnsRecord> {
public id = 'brother-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Brother printer mDNS advertisements matching Home Assistant zeroconf metadata.';
public async matches(recordArg: IBrotherMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeType(recordArg.type || stringMetadata(recordArg.metadata || {}, 'type'));
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
const serviceName = cleanName(recordArg.name || recordArg.hostname, type);
const product = stringValue(valueForKey(properties, 'product')) || stringValue(valueForKey(properties, 'ty'));
const usbManufacturer = stringValue(valueForKey(properties, 'usb_MFG')) || stringValue(valueForKey(properties, 'mfg'));
const model = stringValue(valueForKey(properties, 'usb_MDL')) || stringValue(valueForKey(properties, 'mdl')) || modelFromProduct(product);
const haystack = [recordArg.name, serviceName, product, usbManufacturer, model].filter(Boolean).join(' ').toLowerCase();
const matched = type === brotherPrinterMdnsType && (serviceName.toLowerCase().startsWith('brother') || haystack.includes('brother'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Brother printer advertisement.' };
}
const serial = stringValue(valueForKey(properties, 'serial')) || stringValue(valueForKey(properties, 'serialNumber')) || stringValue(valueForKey(properties, 'usb_SN'));
const macAddress = stringValue(valueForKey(properties, 'mac')) || stringValue(valueForKey(properties, 'macAddress'));
const host = recordArg.host || recordArg.addresses?.[0];
const id = serial || macAddress || host;
return {
matched: true,
confidence: id && host ? 'certain' : host ? 'high' : 'medium',
reason: 'mDNS record matches Brother printer zeroconf hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: brotherDomain,
id,
host,
port: brotherDefaultPort,
name: serviceName || product || model || 'Brother Printer',
manufacturer: 'Brother',
model,
serialNumber: serial,
macAddress,
metadata: {
brother: true,
mdnsName: recordArg.name,
mdnsType: recordArg.type,
mdnsPort: recordArg.port,
txt: properties,
product,
printerType: printerTypeFromMetadata(properties) || 'laser',
community: brotherDefaultCommunity,
},
},
};
}
}
export class BrotherManualMatcher implements IDiscoveryMatcher<IBrotherManualEntry> {
public id = 'brother-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Brother printer host, snapshot, raw SNMP, or sensor setup entries.';
public async matches(inputArg: IBrotherManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const parsed = parseEndpoint(inputArg.url || inputArg.host);
const snapshot = inputArg.snapshot || snapshotFromMetadata(metadata);
const rawData = inputArg.rawData || rawDataFromMetadata(metadata);
const sensors = inputArg.sensors || sensorsFromMetadata(metadata);
const hasManualData = Boolean(snapshot || rawData || sensors || metadata.client);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(inputArg.host || inputArg.url || metadata.brother || hasManualData || text.includes('brother'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Brother printer setup hints.' };
}
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.printer.host;
const port = inputArg.port || parsed?.port || numberMetadata(metadata, 'port') || snapshot?.printer.port || brotherDefaultPort;
const serial = inputArg.serialNumber || inputArg.serial || snapshot?.printer.serialNumber || snapshot?.printer.serial || stringMetadata(metadata, 'serial') || stringMetadata(metadata, 'serialNumber');
const macAddress = inputArg.macAddress || snapshot?.printer.macAddress || stringMetadata(metadata, 'macAddress');
const id = inputArg.id || inputArg.uniqueId || BrotherMapper.snapshotId(snapshot || manualSnapshot(inputArg, host, port, rawData, sensors)) || serial || macAddress || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start Brother printer setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: brotherDomain,
id,
host,
port,
name: inputArg.name || snapshot?.printer.name,
manufacturer: inputArg.manufacturer || snapshot?.printer.manufacturer || 'Brother',
model: inputArg.model || snapshot?.printer.model,
serialNumber: serial,
macAddress,
metadata: {
...metadata,
brother: true,
community: inputArg.community || stringMetadata(metadata, 'community') || brotherDefaultCommunity,
printerType: printerType(inputArg.printerType || inputArg.type || metadata.printerType || metadata.type || snapshot?.printer.printerType) || 'laser',
snapshot,
rawData,
sensors,
client: inputArg.client || metadata.client,
},
},
};
}
}
export class BrotherCandidateValidator implements IDiscoveryValidator {
public id = 'brother-candidate-validator';
public description = 'Validate Brother candidates have a usable host, snapshot, raw SNMP data, sensors, or injected client.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const type = normalizeType(stringMetadata(metadata, 'mdnsType'));
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === brotherDomain
|| Boolean(metadata.brother)
|| (type === brotherPrinterMdnsType && text.includes('brother'))
|| text.includes('brother');
const snapshot = snapshotFromMetadata(metadata);
const hasManualData = Boolean(snapshot || metadata.rawData || metadata.sensors || metadata.client);
const hasUsableSource = Boolean(candidateArg.host || hasManualData);
const normalizedDeviceId = candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || (snapshot ? BrotherMapper.snapshotId(snapshot) : undefined) || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || brotherDefaultPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Brother candidate lacks host, snapshot, raw SNMP data, sensors, or client information.' : 'Candidate is not Brother.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: normalizedDeviceId && candidateArg.host ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Brother printer metadata and a usable source.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: brotherDomain,
port: candidateArg.port || brotherDefaultPort,
manufacturer: candidateArg.manufacturer || 'Brother',
metadata: {
...metadata,
brother: true,
community: stringMetadata(metadata, 'community') || brotherDefaultCommunity,
printerType: printerType(metadata.printerType || metadata.type) || 'laser',
},
},
};
}
}
export const createBrotherDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: brotherDomain, displayName: 'Brother Printer' })
.addMatcher(new BrotherMdnsMatcher())
.addMatcher(new BrotherManualMatcher())
.addValidator(new BrotherCandidateValidator());
};
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
const cleanName = (valueArg: string | undefined, typeArg: string): string => {
const escaped = typeArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return valueArg?.replace(new RegExp(`\\.${escaped}\\.?$`, 'i'), '').replace(/\.local\.?$/i, '').trim() || '';
};
const valueForKey = (recordArg: Record<string, unknown>, keyArg: string): unknown => {
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => stringValue(metadataArg[keyArg]);
const numberMetadata = (metadataArg: Record<string, unknown>, keyArg: string): number | undefined => {
const value = metadataArg[keyArg];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.round(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
};
const modelFromProduct = (valueArg: string | undefined): string | undefined => {
if (!valueArg) {
return undefined;
}
return valueArg.replace(/^\(?brother\s+/i, '').replace(/[()]$/g, '').trim() || valueArg;
};
const printerTypeFromMetadata = (metadataArg: Record<string, unknown>): TBrotherPrinterType | undefined => {
const value = printerType(valueForKey(metadataArg, 'printerType') || valueForKey(metadataArg, 'type'));
if (value) {
return value;
}
const text = Object.values(metadataArg).filter((itemArg) => typeof itemArg === 'string').join(' ').toLowerCase();
return text.includes('ink') ? 'ink' : undefined;
};
const printerType = (valueArg: unknown): TBrotherPrinterType | undefined => {
const value = stringValue(valueArg)?.toLowerCase();
return value === 'ink' || value === 'laser' ? value : undefined;
};
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IBrotherSnapshot | undefined => {
const snapshot = metadataArg.snapshot;
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'sensors' in snapshot ? snapshot as IBrotherSnapshot : undefined;
};
const rawDataFromMetadata = (metadataArg: Record<string, unknown>): IBrotherRawData | undefined => {
const rawData = metadataArg.rawData;
return rawData && typeof rawData === 'object' && !Array.isArray(rawData) ? rawData as IBrotherRawData : undefined;
};
const sensorsFromMetadata = (metadataArg: Record<string, unknown>): IBrotherSensors | undefined => {
const sensors = metadataArg.sensors;
return sensors && typeof sensors === 'object' && !Array.isArray(sensors) ? sensors as IBrotherSensors : undefined;
};
const manualSnapshot = (inputArg: IBrotherManualEntry, hostArg: string | undefined, portArg: number, rawDataArg?: IBrotherRawData, sensorsArg?: IBrotherSensors): IBrotherSnapshot => {
return BrotherMapper.toSnapshot({
config: {
host: hostArg,
port: portArg,
name: inputArg.name,
manufacturer: inputArg.manufacturer,
model: inputArg.model,
serial: inputArg.serial,
serialNumber: inputArg.serialNumber,
macAddress: inputArg.macAddress,
printerType: printerType(inputArg.printerType || inputArg.type) || 'laser',
},
rawData: rawDataArg,
sensors: sensorsArg,
online: Boolean(rawDataArg || sensorsArg),
source: rawDataArg || sensorsArg ? 'manual' : 'runtime',
});
};
+444
View File
@@ -0,0 +1,444 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import {
brotherCounterValues,
brotherDefaultPort,
brotherDomain,
brotherInkMaintenanceValues,
brotherLaserMaintenanceValues,
brotherLaserNextcareValues,
brotherOids,
brotherPercentSensorKeys,
brotherSensorDefinitions,
} from './brother.constants.js';
import type { IBrotherConfig, IBrotherPrinterInfo, IBrotherRawData, IBrotherSensorDefinition, IBrotherSensors, IBrotherSnapshot, TBrotherPrinterType, TBrotherSnapshotSource } from './brother.types.js';
interface IBrotherSnapshotOptions {
config: IBrotherConfig;
rawData?: IBrotherRawData;
sensors?: IBrotherSensors;
printer?: Partial<IBrotherPrinterInfo>;
online?: boolean;
source?: TBrotherSnapshotSource;
error?: string;
}
export class BrotherMapper {
public static toSnapshot(optionsArg: IBrotherSnapshotOptions): IBrotherSnapshot {
if (optionsArg.config.snapshot && !optionsArg.rawData && !optionsArg.sensors && !optionsArg.printer) {
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot');
}
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
const rawSensors = this.sensorsFromRawData(rawData, this.printerType(optionsArg.config));
const sensors = this.cleanAttributes({
...rawSensors,
...optionsArg.config.sensors,
...optionsArg.sensors,
}) as IBrotherSensors;
const hasData = this.hasRawData(rawData) || this.hasSensorData(sensors);
const online = optionsArg.online ?? optionsArg.config.online ?? hasData;
const source = optionsArg.source || (hasData ? 'manual' : 'runtime');
const updatedAt = new Date().toISOString();
const snapshot: IBrotherSnapshot = {
printer: this.printerInfo(optionsArg.config, rawData, optionsArg.printer),
sensors,
rawData: this.hasRawData(rawData) ? this.clone(rawData) : undefined,
online,
updatedAt,
source,
error: optionsArg.error,
};
return this.normalizeSnapshot(snapshot, optionsArg.config, source);
}
public static toDevices(snapshotArg: IBrotherSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
];
for (const definition of brotherSensorDefinitions) {
const value = this.sensorValue(snapshotArg.sensors, definition.key);
if (value === undefined || value === null) {
continue;
}
features.push({ id: definition.key, capability: 'sensor', name: definition.name, readable: true, writable: false, unit: definition.unit });
state.push({ featureId: definition.key, value: this.deviceStateValue(value), updatedAt });
}
return [{
id: this.printerDeviceId(snapshotArg),
integrationDomain: brotherDomain,
name: this.printerName(snapshotArg),
protocol: 'unknown',
manufacturer: snapshotArg.printer.manufacturer || 'Brother',
model: snapshotArg.printer.model || 'Brother Printer',
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
serialNumber: snapshotArg.printer.serialNumber || snapshotArg.printer.serial,
macAddress: snapshotArg.printer.macAddress,
firmware: snapshotArg.printer.firmware,
host: snapshotArg.printer.host,
port: snapshotArg.printer.port,
printerType: snapshotArg.printer.printerType,
configurationUrl: snapshotArg.printer.configurationUrl,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IBrotherSnapshot): IIntegrationEntity[] {
const deviceId = this.printerDeviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const baseName = this.printerName(snapshotArg);
const entities: IIntegrationEntity[] = [];
for (const definition of brotherSensorDefinitions) {
const value = this.sensorValue(snapshotArg.sensors, definition.key);
if (value === undefined || value === null) {
continue;
}
entities.push(this.entity(snapshotArg, definition, `${baseName} ${definition.name}`, value, deviceId, uniqueBase));
}
if (!entities.length) {
entities.push(this.entity(snapshotArg, brotherSensorDefinitions[0], `${baseName} Status`, 'unknown', deviceId, uniqueBase, false));
}
return entities;
}
public static printerDeviceId(snapshotArg: IBrotherSnapshot): string {
return `${brotherDomain}.printer.${this.uniqueBase(snapshotArg)}`;
}
public static snapshotId(snapshotArg: IBrotherSnapshot): string | undefined {
return snapshotArg.printer.id || snapshotArg.printer.serialNumber || snapshotArg.printer.serial || snapshotArg.printer.macAddress || snapshotArg.printer.host;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || brotherDomain;
}
private static normalizeSnapshot(snapshotArg: IBrotherSnapshot, configArg: IBrotherConfig, sourceArg: TBrotherSnapshotSource): IBrotherSnapshot {
const rawData = this.rawData(configArg, snapshotArg.rawData);
const derivedPrinter = this.hasRawData(rawData) ? this.printerInfo(configArg, rawData) : undefined;
const derivedSensors = this.hasRawData(rawData) ? this.sensorsFromRawData(rawData, this.printerType(configArg, snapshotArg.printer.printerType)) : undefined;
const printer = {
...derivedPrinter,
...snapshotArg.printer,
};
const parsed = this.parseEndpoint(configArg.host || printer.host);
printer.host = printer.host || parsed?.host || configArg.host;
printer.port = printer.port || parsed?.port || (printer.host ? configArg.port || brotherDefaultPort : configArg.port);
printer.printerType = this.printerType(configArg, printer.printerType);
printer.manufacturer = printer.manufacturer || configArg.manufacturer || 'Brother';
printer.model = printer.model || configArg.model || 'Brother Printer';
printer.name = printer.name || configArg.name || this.defaultPrinterName(printer);
printer.id = printer.id || configArg.uniqueId || printer.serialNumber || printer.serial || printer.macAddress || (printer.host ? `${printer.host}:${printer.port || brotherDefaultPort}` : undefined) || printer.name;
printer.configurationUrl = printer.configurationUrl || (printer.host ? `http://${this.hostForUrl(printer.host)}/` : undefined);
return {
...snapshotArg,
printer,
sensors: this.cleanAttributes({ ...derivedSensors, ...snapshotArg.sensors }) as IBrotherSensors,
rawData: this.hasRawData(rawData) ? this.clone(rawData) : snapshotArg.rawData,
online: snapshotArg.online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
};
}
private static printerInfo(configArg: IBrotherConfig, rawDataArg: IBrotherRawData, printerArg: Partial<IBrotherPrinterInfo> = {}): IBrotherPrinterInfo {
const parsed = this.parseEndpoint(configArg.host);
const host = printerArg.host || parsed?.host || configArg.host;
const port = printerArg.port || configArg.port || parsed?.port || (host ? brotherDefaultPort : undefined);
const modelSource = this.stringValue(this.rawValue(rawDataArg, 'model'));
const model = printerArg.model || configArg.model || this.deviceIdField(modelSource, 'MDL') || this.stringValue(modelSource);
const manufacturer = printerArg.manufacturer || configArg.manufacturer || this.deviceIdField(modelSource, 'MFG') || 'Brother';
const serial = printerArg.serialNumber || printerArg.serial || configArg.serialNumber || configArg.serial || this.stringValue(this.rawValue(rawDataArg, 'serial'));
const macAddress = printerArg.macAddress || configArg.macAddress || this.macValue(this.rawValue(rawDataArg, 'mac'));
const firmware = printerArg.firmware || configArg.firmware || this.stringValue(this.rawValue(rawDataArg, 'firmware'));
const printerType = this.printerType(configArg, printerArg.printerType);
const name = printerArg.name || configArg.name || this.defaultPrinterName({ model, serialNumber: serial, host });
return this.cleanAttributes({
...printerArg,
id: printerArg.id || configArg.uniqueId || serial || macAddress || (host ? `${host}:${port || brotherDefaultPort}` : undefined) || name,
name,
manufacturer,
model,
serial,
serialNumber: serial,
macAddress,
firmware,
host,
port,
printerType,
configurationUrl: printerArg.configurationUrl || (host ? `http://${this.hostForUrl(host)}/` : undefined),
}) as IBrotherPrinterInfo;
}
private static sensorsFromRawData(rawDataArg: IBrotherRawData, printerTypeArg: TBrotherPrinterType): IBrotherSensors {
const sensors: IBrotherSensors = {};
const status = this.stringValue(this.rawValue(rawDataArg, 'status'));
if (status) {
sensors.status = this.cleanseStatus(status.toLowerCase());
}
const uptime = this.numberValue(this.rawValue(rawDataArg, 'uptime'));
if (uptime !== undefined) {
sensors.uptime = new Date(Date.now() - (uptime / 100) * 1000).toISOString();
}
this.applyMappedWords(sensors, this.hexWords(this.rawValue(rawDataArg, 'counters'), 14), brotherCounterValues, false);
const maintenanceWords = this.hexWords(this.rawValue(rawDataArg, 'maintenance'), 14);
const legacyMaintenanceWords = this.hexWords(this.rawValue(rawDataArg, 'maintenance'), 10);
if (this.isLegacyMaintenance(legacyMaintenanceWords)) {
this.applyLegacyMappedWords(sensors, legacyMaintenanceWords, printerTypeArg === 'ink' ? brotherInkMaintenanceValues : brotherLaserMaintenanceValues);
} else {
this.applyMappedWords(sensors, maintenanceWords, printerTypeArg === 'ink' ? brotherInkMaintenanceValues : brotherLaserMaintenanceValues, true);
}
if (printerTypeArg === 'laser') {
this.applyMappedWords(sensors, this.hexWords(this.rawValue(rawDataArg, 'nextcare'), 14), brotherLaserNextcareValues, false);
}
const legacyPageCounter = this.numberValue(this.rawValue(rawDataArg, 'pageCounter'));
if (sensors.page_counter === undefined && legacyPageCounter !== undefined) {
sensors.page_counter = legacyPageCounter;
}
return this.cleanAttributes(sensors) as IBrotherSensors;
}
private static applyMappedWords(sensorsArg: IBrotherSensors, wordsArg: string[], valuesMapArg: Record<string, string>, percentAwareArg: boolean): void {
for (const word of wordsArg) {
const key = valuesMapArg[word.slice(0, 2).toLowerCase()];
if (!key || word.length < 10) {
continue;
}
const value = Number.parseInt(word.slice(-8), 16);
if (!Number.isFinite(value)) {
continue;
}
sensorsArg[key] = percentAwareArg && brotherPercentSensorKeys.has(key) ? Math.round(value / 100) : value;
}
}
private static applyLegacyMappedWords(sensorsArg: IBrotherSensors, wordsArg: string[], valuesMapArg: Record<string, string>): void {
for (const word of wordsArg) {
const key = valuesMapArg[word.slice(0, 2).toLowerCase()];
if (!key || word.length < 10) {
continue;
}
const numerator = Number.parseInt(word.slice(6, 8), 16);
const denominator = Number.parseInt(word.slice(8, 10), 16);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
continue;
}
sensorsArg[key] = Math.round((numerator / denominator) * 100);
}
}
private static isLegacyMaintenance(wordsArg: string[]): boolean {
return Boolean(wordsArg.length && wordsArg.every((wordArg) => wordArg.length === 10 && wordArg.endsWith('14')));
}
private static hexWords(valueArg: unknown, chunkSizeArg: number): string[] {
if (valueArg instanceof Uint8Array) {
return this.hexStringToWords(this.bytesToHex(valueArg, true), chunkSizeArg);
}
if (Array.isArray(valueArg)) {
if (valueArg.every((itemArg) => typeof itemArg === 'number')) {
return this.hexStringToWords(this.bytesToHex(new Uint8Array(valueArg as number[]), true), chunkSizeArg);
}
return valueArg.map((itemArg) => this.cleanHex(String(itemArg))).filter((itemArg) => itemArg.length >= chunkSizeArg);
}
if (typeof valueArg === 'string') {
return this.hexStringToWords(this.cleanHex(valueArg), chunkSizeArg);
}
return [];
}
private static hexStringToWords(valueArg: string, chunkSizeArg: number): string[] {
const result: string[] = [];
for (let index = 0; index + chunkSizeArg <= valueArg.length; index += chunkSizeArg) {
result.push(valueArg.slice(index, index + chunkSizeArg));
}
return result;
}
private static bytesToHex(valueArg: Uint8Array, dropChecksumArg: boolean): string {
const bytes = dropChecksumArg && valueArg.length > 1 ? valueArg.slice(0, -1) : valueArg;
return [...bytes].map((byteArg) => byteArg.toString(16).padStart(2, '0')).join('');
}
private static cleanHex(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^0-9a-f]/g, '');
}
private static rawData(configArg: IBrotherConfig, rawDataArg?: IBrotherRawData): IBrotherRawData {
return this.cleanAttributes({ ...configArg.rawData, ...rawDataArg });
}
private static rawValue(rawDataArg: IBrotherRawData, keyArg: keyof typeof brotherOids): unknown {
const oid = brotherOids[keyArg];
return rawDataArg[oid] ?? rawDataArg[keyArg] ?? rawDataArg[this.snakeKey(keyArg)];
}
private static snakeKey(keyArg: string): string {
return keyArg.replace(/[A-Z]/g, (matchArg) => `_${matchArg.toLowerCase()}`);
}
private static entity(snapshotArg: IBrotherSnapshot, definitionArg: IBrotherSensorDefinition, nameArg: string, valueArg: unknown, deviceIdArg: string, uniqueBaseArg: string, availableArg = snapshotArg.online): IIntegrationEntity {
return {
id: `sensor.${this.slug(nameArg)}`,
uniqueId: `${brotherDomain}_${uniqueBaseArg}_${this.slug(definitionArg.key)}`,
integrationDomain: brotherDomain,
deviceId: deviceIdArg,
platform: 'sensor',
name: nameArg,
state: this.stateValue(valueArg),
attributes: this.cleanAttributes({
key: definitionArg.key,
unitOfMeasurement: definitionArg.unit,
deviceClass: definitionArg.deviceClass,
stateClass: definitionArg.stateClass,
entityCategory: definitionArg.entityCategory,
entityRegistryEnabledDefault: definitionArg.enabledDefault,
source: snapshotArg.source,
error: definitionArg.key === 'status' ? snapshotArg.error : undefined,
}),
available: availableArg && valueArg !== undefined && valueArg !== null,
};
}
private static stateValue(valueArg: unknown): unknown {
return valueArg instanceof Date ? valueArg.toISOString() : valueArg;
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
const value = this.stateValue(valueArg);
if (value === undefined || value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return String(value);
}
private static sensorValue(sensorsArg: IBrotherSensors, keyArg: string): unknown {
return sensorsArg[keyArg];
}
private static printerName(snapshotArg: IBrotherSnapshot): string {
return snapshotArg.printer.name || this.defaultPrinterName(snapshotArg.printer) || 'Brother Printer';
}
private static defaultPrinterName(printerArg: Partial<IBrotherPrinterInfo>): string {
const model = this.stringValue(printerArg.model);
const serial = this.stringValue(printerArg.serialNumber || printerArg.serial);
if (model && serial && model !== 'Brother Printer') {
return `${model} ${serial}`;
}
return model || this.stringValue(printerArg.host) || 'Brother Printer';
}
private static uniqueBase(snapshotArg: IBrotherSnapshot): string {
return this.slug(snapshotArg.printer.serialNumber || snapshotArg.printer.serial || snapshotArg.printer.macAddress || snapshotArg.printer.id || snapshotArg.printer.host || this.printerName(snapshotArg));
}
private static printerType(configArg: IBrotherConfig, fallbackArg?: TBrotherPrinterType): TBrotherPrinterType {
const value = configArg.printerType || configArg.type || fallbackArg;
return value === 'ink' ? 'ink' : 'laser';
}
private static parseEndpoint(valueArg: string | undefined): { host: string; port?: number } | undefined {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
}
private static hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private static deviceIdField(valueArg: string | undefined, keyArg: string): string | undefined {
if (!valueArg) {
return undefined;
}
const pattern = new RegExp(`${keyArg}:([^;]+)`, 'i');
return valueArg.match(pattern)?.[1]?.trim();
}
private static macValue(valueArg: unknown): string | undefined {
if (typeof valueArg === 'string') {
return valueArg.trim() || undefined;
}
if (valueArg instanceof Uint8Array) {
return [...valueArg].map((itemArg) => itemArg.toString(16).padStart(2, '0')).join(':');
}
if (Array.isArray(valueArg) && valueArg.every((itemArg) => typeof itemArg === 'number')) {
return (valueArg as number[]).map((itemArg) => itemArg.toString(16).padStart(2, '0')).join(':');
}
return undefined;
}
private static cleanseStatus(valueArg: string): string {
return valueArg.split(/\s+/g).join(' ').trim();
}
private static stringValue(valueArg: unknown): string | undefined {
if (typeof valueArg === 'string') {
return valueArg.trim() || undefined;
}
if (valueArg instanceof Date) {
return valueArg.toISOString();
}
if (typeof valueArg === 'number' || typeof valueArg === 'boolean') {
return String(valueArg);
}
return undefined;
}
private static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private static hasRawData(rawDataArg: IBrotherRawData | undefined): boolean {
return Boolean(rawDataArg && Object.keys(rawDataArg).length);
}
private static hasSensorData(sensorsArg: IBrotherSensors | undefined): boolean {
return Boolean(sensorsArg && Object.values(sensorsArg).some((valueArg) => valueArg !== undefined && valueArg !== null));
}
private static cleanAttributes<TValue extends Record<string, unknown>>(attributesArg: TValue): Partial<TValue> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as Partial<TValue>;
}
private static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
+169 -2
View File
@@ -1,4 +1,171 @@
export interface IHomeAssistantBrotherConfig {
// TODO: replace with the TypeScript-native config for brother.
export type TBrotherPrinterType = 'laser' | 'ink';
export type TBrotherSnapshotSource = 'snmp' | 'client' | 'manual' | 'snapshot' | 'runtime';
export interface IBrotherConfig {
host?: string;
port?: number;
community?: string;
printerType?: TBrotherPrinterType;
type?: TBrotherPrinterType;
timeoutMs?: number;
name?: string;
manufacturer?: string;
model?: string;
serial?: string;
serialNumber?: string;
macAddress?: string;
firmware?: string;
uniqueId?: string;
snapshot?: IBrotherSnapshot;
sensors?: IBrotherSensors;
rawData?: IBrotherRawData;
client?: IBrotherClientLike;
online?: boolean;
}
export interface IBrotherClientLike {
getSnapshot?: () => Promise<IBrotherSnapshot | IBrotherClientSnapshotData>;
asyncUpdate?: () => Promise<IBrotherSensors>;
getSensors?: () => Promise<IBrotherSensors>;
getRawData?: () => Promise<IBrotherRawData>;
}
export interface IBrotherClientSnapshotData {
printer?: Partial<IBrotherPrinterInfo>;
sensors?: IBrotherSensors;
rawData?: IBrotherRawData;
online?: boolean;
updatedAt?: string;
source?: TBrotherSnapshotSource;
error?: string;
}
export interface IBrotherRawData {
[key: string]: unknown;
}
export interface IBrotherPrinterInfo {
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serial?: string;
serialNumber?: string;
macAddress?: string;
firmware?: string;
host?: string;
port?: number;
printerType?: TBrotherPrinterType;
configurationUrl?: string;
}
export interface IBrotherSensors {
status?: string | null;
uptime?: string | Date | null;
belt_unit_remaining_life?: number | null;
belt_unit_remaining_pages?: number | null;
black_counter?: number | null;
black_drum_counter?: number | null;
black_drum_remaining_life?: number | null;
black_drum_remaining_pages?: number | null;
black_ink?: number | null;
black_ink_remaining?: number | null;
black_ink_status?: number | null;
black_toner?: number | null;
black_toner_remaining?: number | null;
black_toner_status?: number | null;
bw_counter?: number | null;
color_counter?: number | null;
cyan_counter?: number | null;
cyan_drum_counter?: number | null;
cyan_drum_remaining_life?: number | null;
cyan_drum_remaining_pages?: number | null;
cyan_ink?: number | null;
cyan_ink_remaining?: number | null;
cyan_ink_status?: number | null;
cyan_toner?: number | null;
cyan_toner_remaining?: number | null;
cyan_toner_status?: number | null;
drum_counter?: number | null;
drum_remaining_life?: number | null;
drum_remaining_pages?: number | null;
drum_status?: number | null;
duplex_unit_pages_counter?: number | null;
fuser_remaining_life?: number | null;
fuser_unit_remaining_pages?: number | null;
image_counter?: number | null;
laser_remaining_life?: number | null;
laser_unit_remaining_pages?: number | null;
magenta_counter?: number | null;
magenta_drum_counter?: number | null;
magenta_drum_remaining_life?: number | null;
magenta_drum_remaining_pages?: number | null;
magenta_ink?: number | null;
magenta_ink_remaining?: number | null;
magenta_ink_status?: number | null;
magenta_toner?: number | null;
magenta_toner_remaining?: number | null;
magenta_toner_status?: number | null;
page_counter?: number | null;
pf_kit_1_remaining_life?: number | null;
pf_kit_1_remaining_pages?: number | null;
pf_kit_mp_remaining_life?: number | null;
pf_kit_mp_remaining_pages?: number | null;
yellow_counter?: number | null;
yellow_drum_counter?: number | null;
yellow_drum_remaining_life?: number | null;
yellow_drum_remaining_pages?: number | null;
yellow_ink?: number | null;
yellow_ink_remaining?: number | null;
yellow_ink_status?: number | null;
yellow_toner?: number | null;
yellow_toner_remaining?: number | null;
yellow_toner_status?: number | null;
[key: string]: unknown;
}
export interface IBrotherSnapshot {
printer: IBrotherPrinterInfo;
sensors: IBrotherSensors;
rawData?: IBrotherRawData;
online: boolean;
updatedAt?: string;
source?: TBrotherSnapshotSource;
error?: string;
}
export interface IBrotherSensorDefinition {
key: keyof IBrotherSensors & string;
name: string;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
enabledDefault?: boolean;
}
export interface IBrotherMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, unknown>;
properties?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface IBrotherManualEntry extends Partial<IBrotherConfig> {
id?: string;
url?: string;
metadata?: Record<string, unknown>;
}
export interface IBrotherRefreshResult {
success: boolean;
snapshot: IBrotherSnapshot;
error?: string;
}
export type IHomeAssistantBrotherConfig = IBrotherConfig;
+5
View File
@@ -1,2 +1,7 @@
export * from './brother.classes.client.js';
export * from './brother.classes.configflow.js';
export * from './brother.classes.integration.js';
export * from './brother.constants.js';
export * from './brother.discovery.js';
export * from './brother.mapper.js';
export * from './brother.types.js';
@@ -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,683 @@
import { DaikinMapper } from './daikin.mapper.js';
import type {
IDaikinCommandRequest,
IDaikinConfig,
IDaikinControlSettings,
IDaikinRawData,
IDaikinRefreshResult,
IDaikinSnapshot,
IDaikinValueMap,
TDaikinApiVariant,
} from './daikin.types.js';
import { daikinDefaultHttpPort, daikinDefaultHttpsPort, daikinDefaultTimeoutMs, daikinSkyFiPort } from './daikin.types.js';
export class DaikinApiError extends Error {}
export class DaikinApiConnectionError extends DaikinApiError {}
export class DaikinApiAuthorizationError extends DaikinApiError {}
export class DaikinUnsupportedCommandError extends DaikinApiError {}
const legacyResources = [
'common/basic_info',
'aircon/get_sensor_info',
'aircon/get_model_info',
'aircon/get_control_info',
'aircon/get_target',
'common/get_holiday',
'common/get_notify',
'aircon/get_day_power_ex',
'aircon/get_week_power',
'aircon/get_year_power',
];
const airbaseResources = [
'skyfi/common/basic_info',
'skyfi/aircon/get_control_info',
'skyfi/aircon/get_model_info',
'skyfi/aircon/get_sensor_info',
'skyfi/aircon/get_zone_setting',
];
const resourceKeyMap: Record<string, keyof IDaikinRawData> = {
'common/basic_info': 'basicInfo',
'skyfi/common/basic_info': 'basicInfo',
'aircon/get_control_info': 'controlInfo',
'skyfi/aircon/get_control_info': 'controlInfo',
'aircon/get_sensor_info': 'sensorInfo',
'skyfi/aircon/get_sensor_info': 'sensorInfo',
'aircon/get_model_info': 'modelInfo',
'skyfi/aircon/get_model_info': 'modelInfo',
'common/get_holiday': 'holiday',
'common/get_notify': 'notify',
'aircon/get_day_power_ex': 'dayPowerEx',
'aircon/get_week_power': 'weekPower',
'aircon/get_year_power': 'yearPower',
'skyfi/aircon/get_zone_setting': 'zoneSetting',
};
const brp084ModeMap: Record<string, string> = {
'0300': 'auto',
'0200': 'cool',
'0100': 'heat',
'0000': 'fan',
'0500': 'dry',
};
const brp084FanModeMap: Record<string, string> = {
'0A00': 'auto',
'0B00': 'quiet',
'0300': '1',
'0400': '2',
'0500': '3',
'0600': '4',
'0700': '5',
};
const brp084Paths = {
power: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_A002', 'p_01'],
mode: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_01'],
indoorTemp: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_A00B', 'p_01'],
indoorHumidity: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_A00B', 'p_02'],
outdoorTemp: ['/dsiot/edge/adr_0200.dgc_status', 'dgc_status', 'e_1003', 'e_A00D', 'p_01'],
mac: ['/dsiot/edge.adp_i', 'adp_i', 'mac'],
coolTemp: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_02'],
heatTemp: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_03'],
autoTemp: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_1D'],
coolFan: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_09'],
heatFan: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_0A'],
autoFan: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_26'],
fanFan: ['/dsiot/edge/adr_0100.dgc_status', 'dgc_status', 'e_1002', 'e_3001', 'p_28'],
todayRuntime: ['/dsiot/edge/adr_0100.i_power.week_power', 'week_power', 'today_runtime'],
weeklyData: ['/dsiot/edge/adr_0100.i_power.week_power', 'week_power', 'datas'],
};
export class DaikinClient {
private currentSnapshot?: IDaikinSnapshot;
constructor(private readonly config: IDaikinConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IDaikinSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = DaikinMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.hasManualData()) {
this.currentSnapshot = DaikinMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.host || this.config.url) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('No Daikin local HTTP endpoint, client, snapshot, or manual data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IDaikinRefreshResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot(Boolean(this.config.host || this.config.url || this.config.client));
const success = snapshot.online && !snapshot.error;
return { success, snapshot, error: success ? undefined : snapshot.error, data: { source: snapshot.source } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = this.offlineSnapshot(error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
}
}
public async fetchSnapshot(): Promise<IDaikinSnapshot> {
const variant = this.config.apiVariant || 'auto';
if (variant === 'skyfi' || this.config.password) {
return this.snapshotFromRawData(await this.fetchSkyFiRawData(), 'http');
}
if (variant === 'airbase') {
return this.snapshotFromRawData(await this.fetchLegacyRawData('airbase'), 'http');
}
if (variant === 'brp084') {
return this.snapshotFromRawData(await this.fetchBrp084RawData(), 'http');
}
if (variant === 'brp072c' || this.config.apiKey) {
return this.snapshotFromRawData(await this.fetchLegacyRawData('brp072c'), 'http');
}
const legacy = await this.fetchLegacyRawData('brp069');
const legacyValues = DaikinMapper.valuesFromRawData(legacy);
if (legacyValues.mode || legacyValues.pow || legacyValues.htemp || legacyValues.mac) {
return this.snapshotFromRawData(legacy, 'http');
}
return this.snapshotFromRawData(await this.fetchLegacyRawData('airbase'), 'http');
}
public async execute(commandArg: IDaikinCommandRequest): Promise<unknown> {
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client?.execute) {
return this.config.client.execute(commandArg);
}
if (commandArg.action === 'refresh') {
return (await this.refresh()).snapshot;
}
if (!this.config.host && !this.config.url) {
throw new DaikinApiConnectionError('Daikin commands require config.host/config.url, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
}
if (commandArg.path && commandArg.method) {
return this.requestKeyValue(commandArg.path, commandArg.query, this.requestVariant(commandArg.protocol), true);
}
switch (commandArg.action) {
case 'set_control':
return this.setControl(commandArg.settings || {}, this.requestVariant(commandArg.protocol));
case 'set_holiday':
return this.setHoliday(Boolean(commandArg.enabled), this.requestVariant(commandArg.protocol));
case 'set_advanced_mode':
return this.setAdvancedMode(commandArg.mode || 'powerful', Boolean(commandArg.enabled), this.requestVariant(commandArg.protocol));
case 'set_streamer':
return this.setStreamer(Boolean(commandArg.enabled), this.requestVariant(commandArg.protocol));
case 'set_zone':
return this.setZone(commandArg.zoneId, commandArg.zoneKey || 'zone_onoff', commandArg.zoneValue ?? (commandArg.enabled ? '1' : '0'), this.requestVariant(commandArg.protocol));
default:
throw new DaikinUnsupportedCommandError(`Unsupported Daikin command: ${commandArg.action}`);
}
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async snapshotFromClient(): Promise<IDaikinSnapshot> {
const client = this.config.client;
if (!client) {
throw new DaikinApiConnectionError('No Daikin client configured.');
}
if (client.getSnapshot) {
const result = await client.getSnapshot();
if (this.isSnapshot(result)) {
return DaikinMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return DaikinMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
}
if (client.getRawData) {
return DaikinMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
}
throw new DaikinApiConnectionError('Daikin client must expose getSnapshot() or getRawData().');
}
private snapshotFromRawData(rawDataArg: Partial<IDaikinRawData>, sourceArg: 'http' | 'manual' | 'client'): IDaikinSnapshot {
return DaikinMapper.toSnapshot({ config: this.config, rawData: rawDataArg, online: true, source: sourceArg });
}
private async fetchLegacyRawData(variantArg: TDaikinApiVariant): Promise<Partial<IDaikinRawData>> {
const resources = variantArg === 'airbase' ? airbaseResources : legacyResources;
if (variantArg === 'brp072c' && this.config.apiKey) {
await this.requestKeyValue('common/register_terminal', { key: this.config.apiKey }, variantArg, true);
}
const rawData: Partial<IDaikinRawData> = { variant: variantArg, resources: {}, fetchedAt: new Date().toISOString() };
for (const resource of resources) {
const data = await this.requestKeyValue(resource, undefined, variantArg, true).catch((errorArg) => {
if (resource === resources[0]) {
throw errorArg;
}
return {};
});
rawData.resources![resource] = data;
const key = resourceKeyMap[resource];
if (key) {
(rawData as Record<string, unknown>)[key] = data;
}
}
return rawData;
}
private async fetchSkyFiRawData(): Promise<Partial<IDaikinRawData>> {
const ac = await this.requestSkyFi('ac.cgi');
const zones = await this.requestSkyFi('zones.cgi').catch(() => ({}));
return {
variant: 'skyfi',
skyfiAc: ac,
skyfiZones: zones,
values: { ...ac, ...zones },
fetchedAt: new Date().toISOString(),
};
}
private async fetchBrp084RawData(): Promise<Partial<IDaikinRawData>> {
const payload = {
requests: [
{ op: 2, to: '/dsiot/edge/adr_0100.dgc_status?filter=pv,pt,md' },
{ op: 2, to: '/dsiot/edge/adr_0200.dgc_status?filter=pv,pt,md' },
{ op: 2, to: '/dsiot/edge/adr_0100.i_power.week_power?filter=pv,pt,md' },
{ op: 2, to: '/dsiot/edge.adp_i' },
],
};
const response = await this.requestJson<Record<string, unknown>>('/dsiot/multireq', payload, 'brp084');
const modeValue = this.findBrp084Value(response, brp084Paths.mode);
const mode = brp084ModeMap[modeValue] || modeValue;
const isOff = this.findBrp084Value(response, brp084Paths.power) === '00';
const tempPath = mode === 'cool' ? brp084Paths.coolTemp : mode === 'heat' ? brp084Paths.heatTemp : brp084Paths.autoTemp;
const fanPath = mode === 'cool' ? brp084Paths.coolFan : mode === 'heat' ? brp084Paths.heatFan : mode === 'fan' ? brp084Paths.fanFan : brp084Paths.autoFan;
const values: IDaikinValueMap = {
mac: this.findBrp084Value(response, brp084Paths.mac),
pow: isOff ? '0' : '1',
mode: isOff ? 'off' : mode,
otemp: this.hexTemp(this.findBrp084Value(response, brp084Paths.outdoorTemp)),
htemp: this.hexTemp(this.findBrp084Value(response, brp084Paths.indoorTemp), 1),
hhum: this.hexInt(this.findBrp084Value(response, brp084Paths.indoorHumidity)),
stemp: this.hexTemp(this.findBrp084Value(response, tempPath)),
f_rate: brp084FanModeMap[this.findBrp084Value(response, fanPath)] || 'auto',
datas: this.brp084WeeklyData(response),
};
return { variant: 'brp084', brp084: response, values, fetchedAt: new Date().toISOString() };
}
private async setControl(settingsArg: Partial<IDaikinControlSettings>, variantArg: TDaikinApiVariant): Promise<unknown> {
if (variantArg === 'skyfi') {
return this.setSkyFiControl(settingsArg);
}
if (variantArg === 'brp084') {
throw new DaikinUnsupportedCommandError('Daikin BRP084 dsiot control writes are not implemented by this native port. Provide commandExecutor for BRP084 controls.');
}
return this.setLegacyControl(settingsArg, variantArg === 'airbase' ? 'airbase' : 'brp069');
}
private async setLegacyControl(settingsArg: Partial<IDaikinControlSettings>, variantArg: 'brp069' | 'airbase'): Promise<unknown> {
const prefix = variantArg === 'airbase' ? 'skyfi/' : '';
const current = await this.requestKeyValue(`${prefix}aircon/get_control_info`, undefined, variantArg, true);
const values: IDaikinValueMap = { ...current };
this.applyControlSettings(values, settingsArg, variantArg);
if (settingsArg.mode === 'off') {
values.pow = '0';
values.mode = current.mode || values.mode;
} else if ('mode' in settingsArg || Object.keys(settingsArg).length === 0) {
values.pow = '1';
}
this.fillModeDefaults(values, current);
const query = variantArg === 'airbase'
? {
f_airside: values.f_airside || 0,
f_auto: values.f_auto || '0',
f_dir: values.f_dir || '0',
f_rate: (values.f_rate || 'A')[0],
lpw: '',
mode: values.mode || current.mode || '0',
pow: values.pow || current.pow || '1',
shum: values.shum || current.shum || '--',
stemp: values.stemp || current.stemp || '--',
}
: this.cleanQuery({
mode: values.mode || current.mode || '0',
pow: values.pow || current.pow || '1',
shum: values.shum || current.shum || '--',
stemp: values.stemp || current.stemp || '--',
f_rate: values.f_rate !== undefined || current.f_rate !== undefined ? values.f_rate || current.f_rate : undefined,
f_dir: values.f_dir !== undefined || current.f_dir !== undefined ? values.f_dir || current.f_dir : undefined,
f_dir_ud: current.f_dir_ud !== undefined ? this.swingAxis(values.f_dir, 'ud') : undefined,
f_dir_lr: current.f_dir_lr !== undefined ? this.swingAxis(values.f_dir, 'lr') : undefined,
});
return this.requestKeyValue(`${prefix}aircon/set_control_info`, query, variantArg, true);
}
private async setSkyFiControl(settingsArg: Partial<IDaikinControlSettings>): Promise<unknown> {
const current = await this.requestSkyFi('ac.cgi');
const values: IDaikinValueMap = { ...current };
this.applyControlSettings(values, settingsArg, 'skyfi');
if (settingsArg.mode === 'off') {
return this.requestSkyFi('set.cgi', { p: '0' });
}
if ('mode' in settingsArg || Object.keys(settingsArg).length === 0) {
values.opmode = '1';
values.pow = '1';
}
return this.requestSkyFi('set.cgi', {
p: values.opmode || values.pow || '1',
t: values.settemp || values.stemp || current.settemp || current.stemp,
f: values.fanspeed || values.f_rate || current.fanspeed || current.f_rate,
m: values.acmode || values.mode || current.acmode || current.mode,
});
}
private async setHoliday(enabledArg: boolean, variantArg: TDaikinApiVariant): Promise<unknown> {
if (variantArg === 'skyfi' || variantArg === 'airbase' || variantArg === 'brp084') {
throw new DaikinUnsupportedCommandError(`Daikin ${variantArg} holiday mode is not represented by a supported local request in this port.`);
}
return this.requestKeyValue('common/set_holiday', { en_hol: enabledArg ? '1' : '0' }, variantArg, true);
}
private async setAdvancedMode(modeArg: string, enabledArg: boolean, variantArg: TDaikinApiVariant): Promise<unknown> {
if (variantArg === 'skyfi' || variantArg === 'brp084') {
throw new DaikinUnsupportedCommandError(`Daikin ${variantArg} advanced modes are not represented by a supported local request in this port.`);
}
const mode = DaikinMapper.humanToDaikin('spmode_kind', modeArg, variantArg) || modeArg;
return this.requestKeyValue(`${variantArg === 'airbase' ? 'skyfi/' : ''}aircon/set_special_mode`, { spmode_kind: mode, set_spmode: enabledArg ? '1' : '0' }, variantArg, true);
}
private async setStreamer(enabledArg: boolean, variantArg: TDaikinApiVariant): Promise<unknown> {
if (variantArg === 'skyfi' || variantArg === 'brp084') {
throw new DaikinUnsupportedCommandError(`Daikin ${variantArg} streamer mode is not represented by a supported local request in this port.`);
}
return this.requestKeyValue(`${variantArg === 'airbase' ? 'skyfi/' : ''}aircon/set_special_mode`, { en_streamer: enabledArg ? '1' : '0' }, variantArg, true);
}
private async setZone(zoneIdArg: number | undefined, keyArg: string, valueArg: string, variantArg: TDaikinApiVariant): Promise<unknown> {
if (zoneIdArg === undefined || !Number.isInteger(zoneIdArg) || zoneIdArg < 0) {
throw new DaikinUnsupportedCommandError('Daikin zone commands require a non-negative zoneId.');
}
if (variantArg === 'skyfi') {
if (keyArg !== 'zone_onoff') {
throw new DaikinUnsupportedCommandError('Daikin SkyFi only supports zone_onoff through setzone.cgi.');
}
return this.requestSkyFi('setzone.cgi', { z: zoneIdArg + 1, s: valueArg });
}
if (variantArg !== 'airbase') {
throw new DaikinUnsupportedCommandError(`Daikin ${variantArg} zone commands are not represented by a supported local request in this port.`);
}
const current = await this.requestKeyValue('skyfi/aircon/get_zone_setting', undefined, variantArg, true);
if (!current[keyArg]) {
throw new DaikinUnsupportedCommandError(`Daikin AirBase zone setting ${keyArg} is not available on this device.`);
}
const group = this.listValue(current[keyArg]);
if (zoneIdArg >= group.length) {
throw new DaikinUnsupportedCommandError(`Daikin AirBase zone ${zoneIdArg} is outside the available range 0-${Math.max(0, group.length - 1)}.`);
}
group[zoneIdArg] = valueArg;
const values = { ...current, [keyArg]: encodeURIComponent(group.join(';')).toLowerCase() };
const query = [`zone_name=${values.zone_name || ''}`, `zone_onoff=${values.zone_onoff || ''}`];
if (values.lztemp_c !== undefined && values.lztemp_h !== undefined) {
query.push(`lztemp_c=${values.lztemp_c}`, `lztemp_h=${values.lztemp_h}`);
}
return this.requestKeyValue(`skyfi/aircon/set_zone_setting?${query.join('&')}`, undefined, variantArg, true);
}
private applyControlSettings(valuesArg: IDaikinValueMap, settingsArg: Partial<IDaikinControlSettings>, variantArg: TDaikinApiVariant): void {
for (const [key, value] of Object.entries(settingsArg)) {
if (value === undefined) {
continue;
}
if (key === 'stemp') {
valuesArg.stemp = DaikinMapper.formatTargetTemperature(Number(value));
valuesArg.settemp = valuesArg.stemp;
} else if (key === 'shum') {
valuesArg.shum = String(value);
} else if (key === 'mode') {
const daikinMode = DaikinMapper.humanToDaikin('mode', String(value), variantArg);
valuesArg.mode = daikinMode;
valuesArg.acmode = daikinMode;
} else if (key === 'f_rate') {
const fanRate = DaikinMapper.humanToDaikin('f_rate', String(value), variantArg);
valuesArg.f_rate = fanRate;
valuesArg.fanspeed = fanRate;
} else if (key === 'f_dir') {
valuesArg.f_dir = DaikinMapper.humanToDaikin('f_dir', String(value), variantArg);
} else {
valuesArg[key] = String(value);
}
}
}
private fillModeDefaults(valuesArg: IDaikinValueMap, currentArg: IDaikinValueMap): void {
const mode = valuesArg.mode || currentArg.mode;
if (!mode) {
return;
}
const defaults: Record<string, string> = { stemp: `dt${mode}`, shum: `dh${mode}`, f_rate: `dfr${mode}` };
for (const [target, source] of Object.entries(defaults)) {
if (valuesArg[target] === undefined && currentArg[source] !== undefined) {
valuesArg[target] = currentArg[source];
}
}
}
private async requestSkyFi(pathArg: string, queryArg: Record<string, string | number | boolean | undefined> = {}): Promise<IDaikinValueMap> {
const query = { pass: this.config.password || '', ...queryArg };
const response = await this.fetchText(pathArg, query, 'skyfi', true);
return DaikinMapper.parseSkyFiResponse(response);
}
private async requestKeyValue(pathArg: string, queryArg: Record<string, string | number | boolean | undefined> | undefined, variantArg: TDaikinApiVariant, allowEmptyArg = false): Promise<IDaikinValueMap> {
if (this.config.client?.getResource && !this.config.host && !this.config.url) {
return this.config.client.getResource(pathArg, queryArg);
}
const text = await this.fetchText(pathArg, queryArg, variantArg, allowEmptyArg);
if (!text.trim()) {
return {};
}
try {
return DaikinMapper.parseKeyValueResponse(text);
} catch (errorArg) {
if (allowEmptyArg) {
return {};
}
throw errorArg;
}
}
private async requestJson<TValue>(pathArg: string, bodyArg: unknown, variantArg: TDaikinApiVariant): Promise<TValue> {
const url = this.url(pathArg, undefined, variantArg);
let response: Response;
try {
response = await globalThis.fetch(url, {
method: 'POST',
headers: this.headers(variantArg, { 'content-type': 'application/json', accept: 'application/json' }),
body: JSON.stringify(bodyArg),
signal: AbortSignal.timeout(this.config.timeoutMs || daikinDefaultTimeoutMs),
});
} catch (errorArg) {
throw new DaikinApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
}
await this.assertOk(response, url, false);
return await response.json() as TValue;
}
private async fetchText(pathArg: string, queryArg: Record<string, string | number | boolean | undefined> | undefined, variantArg: TDaikinApiVariant, allowEmptyArg: boolean): Promise<string> {
const url = this.url(pathArg, queryArg, variantArg);
let response: Response;
try {
response = await globalThis.fetch(url, {
method: 'GET',
headers: this.headers(variantArg),
signal: AbortSignal.timeout(this.config.timeoutMs || daikinDefaultTimeoutMs),
});
} catch (errorArg) {
throw new DaikinApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
}
if (response.status === 404 && allowEmptyArg) {
await response.arrayBuffer().catch(() => undefined);
return '';
}
await this.assertOk(response, url, allowEmptyArg);
return await response.text();
}
private async assertOk(responseArg: Response, urlArg: string, allowEmptyArg: boolean): Promise<void> {
if (responseArg.status === 403 || responseArg.status === 401) {
throw new DaikinApiAuthorizationError('Daikin authentication failed. Check API key, UUID, or SkyFi password.');
}
if (!responseArg.ok) {
const text = await responseArg.text().catch(() => '');
if (allowEmptyArg && responseArg.status === 404) {
return;
}
throw new DaikinApiConnectionError(`Daikin endpoint ${urlArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
}
}
private url(pathArg: string, queryArg: Record<string, string | number | boolean | undefined> | undefined, variantArg: TDaikinApiVariant): string {
if (/^https?:\/\//i.test(pathArg)) {
return pathArg;
}
const base = this.baseUrl(variantArg);
const url = new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, base.endsWith('/') ? base : `${base}/`);
for (const [key, value] of Object.entries(queryArg || {})) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
private baseUrl(variantArg: TDaikinApiVariant): string {
const configured = this.config.url || this.config.host || 'localhost';
if (/^https?:\/\//i.test(configured)) {
const url = new URL(configured);
return `${url.protocol}//${url.host}`;
}
const ssl = this.config.ssl ?? this.config.tls ?? variantArg === 'brp072c';
const protocol = ssl ? 'https' : 'http';
const defaultPort = variantArg === 'skyfi' ? daikinSkyFiPort : ssl ? daikinDefaultHttpsPort : daikinDefaultHttpPort;
const port = this.config.port && this.config.port !== defaultPort ? `:${this.config.port}` : '';
return `${protocol}://${this.hostForUrl(configured)}${port}`;
}
private headers(variantArg: TDaikinApiVariant, extraArg: Record<string, string> = {}): Record<string, string> {
const headers = { ...extraArg };
if (variantArg === 'brp072c' && this.config.uuid) {
headers['x-daikin-uuid'] = this.config.uuid.replace(/-/g, '');
}
return headers;
}
private requestVariant(valueArg: IDaikinCommandRequest['protocol']): TDaikinApiVariant {
if (valueArg && valueArg !== 'legacy' && valueArg !== 'auto') {
return valueArg;
}
if (this.config.apiVariant && this.config.apiVariant !== 'auto') {
return this.config.apiVariant;
}
if (this.config.password) {
return 'skyfi';
}
if (this.config.apiKey) {
return 'brp072c';
}
return 'brp069';
}
private findBrp084Value(dataArg: Record<string, unknown>, pathArg: string[]): string {
const responses = Array.isArray(dataArg.responses) ? dataArg.responses as Array<Record<string, unknown>> : [];
const from = pathArg[0];
let nodes = responses.filter((responseArg) => responseArg.fr === from || responseArg.to === from).map((responseArg) => responseArg.pc as Record<string, unknown>).filter(Boolean);
for (const key of pathArg.slice(1)) {
let found: Record<string, unknown> | undefined;
for (const node of nodes) {
if (node.pn === key) {
found = node;
break;
}
}
if (!found) {
throw new DaikinApiConnectionError(`Daikin BRP084 response missing key ${key}.`);
}
if (pathArg[pathArg.length - 1] === key) {
return String(found.pv ?? '');
}
nodes = Array.isArray(found.pch) ? found.pch as Array<Record<string, unknown>> : [];
}
return '';
}
private brp084WeeklyData(dataArg: Record<string, unknown>): string | undefined {
try {
const value = this.findBrp084Value(dataArg, brp084Paths.weeklyData);
return Array.isArray(value) ? value.join('/') : String(value);
} catch {
return undefined;
}
}
private hexTemp(valueArg: string, divisorArg = 2): string | undefined {
if (!valueArg) {
return undefined;
}
const parsed = Number.parseInt(valueArg.slice(0, 2), 16);
return Number.isFinite(parsed) ? String(parsed / divisorArg) : undefined;
}
private hexInt(valueArg: string): string | undefined {
if (!valueArg) {
return undefined;
}
const parsed = Number.parseInt(valueArg, 16);
return Number.isFinite(parsed) ? String(parsed) : undefined;
}
private swingAxis(valueArg: string | undefined, axisArg: 'ud' | 'lr'): string {
const value = valueArg || '0';
if (axisArg === 'ud') {
return value === '1' || value === '3' ? 'S' : '0';
}
return value === '2' || value === '3' ? 'S' : '0';
}
private listValue(valueArg?: string): string[] {
if (!valueArg) {
return [];
}
let decoded = valueArg;
try {
decoded = decodeURIComponent(valueArg.replace(/\+/g, '%20'));
} catch {
decoded = valueArg;
}
return decoded.includes(';') ? decoded.split(';') : decoded.split('');
}
private cleanQuery(queryArg: Record<string, string | number | boolean | undefined>): Record<string, string | number | boolean | undefined> {
const cleaned: Record<string, string | number | boolean | undefined> = {};
for (const [key, value] of Object.entries(queryArg)) {
if (value !== undefined) {
cleaned[key] = value;
}
}
return cleaned;
}
private offlineSnapshot(errorArg: string): IDaikinSnapshot {
return DaikinMapper.toSnapshot({ config: this.config, online: false, source: 'runtime', error: errorArg });
}
private hasManualData(): boolean {
return Boolean(this.config.snapshot || this.config.rawData || this.config.values);
}
private isSnapshot(valueArg: unknown): valueArg is IDaikinSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'climate' in valueArg);
}
private hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IDaikinSnapshot): IDaikinSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IDaikinSnapshot;
}
}
@@ -0,0 +1,168 @@
import * as plugins from '../../plugins.js';
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IDaikinConfig, IDaikinRawData, IDaikinSnapshot, IDaikinValueMap, TDaikinApiVariant } from './daikin.types.js';
import { daikinDefaultHttpPort, daikinDefaultHttpsPort, daikinDefaultTimeoutMs, daikinSkyFiPort } from './daikin.types.js';
export class DaikinConfigFlow implements IConfigFlow<IDaikinConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDaikinConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Configure Daikin AC',
description: 'Configure a local Daikin endpoint, or use snapshot/manual data from the discovery candidate. Use either API key or SkyFi password, not both.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'apiVariant', label: 'API variant', type: 'select', options: [
{ label: 'Auto / BRP069 local HTTP', value: 'auto' },
{ label: 'BRP069 local HTTP', value: 'brp069' },
{ label: 'BRP072C HTTPS with API key', value: 'brp072c' },
{ label: 'AirBase / skyfi path prefix', value: 'airbase' },
{ label: 'SkyFi password API', value: 'skyfi' },
{ label: 'BRP084 dsiot read-only', value: 'brp084' },
] },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'apiKey', label: 'API key', type: 'password' },
{ name: 'uuid', label: 'Terminal UUID', type: 'text' },
{ name: 'password', label: 'SkyFi password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDaikinConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringValue(metadata.url));
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const apiKey = this.stringValue(valuesArg.apiKey) || this.stringMetadata(metadata, 'apiKey');
const password = this.stringValue(valuesArg.password) || this.stringMetadata(metadata, 'password');
const apiVariant = this.apiVariant(this.stringValue(valuesArg.apiVariant) || this.stringMetadata(metadata, 'apiVariant'), apiKey, password);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.device.host;
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanMetadata(metadata, 'ssl') ?? parsed?.ssl ?? snapshot?.device.ssl ?? apiVariant === 'brp072c';
const port = this.numberValue(valuesArg.port) || candidateArg.port || parsed?.port || snapshot?.device.port || this.defaultPort(apiVariant, ssl);
const hasManualData = Boolean(snapshot || rawData || metadata.values || metadata.client);
if (apiKey && password) {
return { kind: 'error', title: 'Daikin setup failed', error: 'Invalid authentication: use either API key or password, not both.' };
}
if (!host && !hasManualData) {
return { kind: 'error', title: 'Daikin setup failed', error: 'Daikin host, injected client, snapshot, or manual data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'Daikin setup failed', error: 'Daikin port must be between 1 and 65535.' };
}
const config: IDaikinConfig = {
host,
port,
ssl,
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(metadata, 'verifySsl') ?? false,
apiKey,
uuid: this.stringValue(valuesArg.uuid) || this.stringMetadata(metadata, 'uuid') || (apiKey ? plugins.crypto.randomUUID().replace(/-/g, '') : undefined),
password,
apiVariant,
timeoutMs: daikinDefaultTimeoutMs,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || this.stringMetadata(metadata, 'name'),
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer || 'Daikin',
model: candidateArg.model || snapshot?.device.model,
uniqueId: candidateArg.id || snapshot?.device.id || snapshot?.device.macAddress || (host ? `${host}:${port}` : undefined),
macAddress: candidateArg.macAddress || snapshot?.device.macAddress,
snapshot,
rawData,
values: valueMapValue(metadata.values),
client: metadata.client as IDaikinConfig['client'],
commandExecutor: metadata.commandExecutor as IDaikinConfig['commandExecutor'],
};
return { kind: 'done', title: 'Daikin configured', config };
}
private apiVariant(valueArg: string | undefined, apiKeyArg: string | undefined, passwordArg: string | undefined): TDaikinApiVariant {
if (passwordArg) {
return 'skyfi';
}
if (apiKeyArg) {
return 'brp072c';
}
const allowed: TDaikinApiVariant[] = ['auto', 'brp069', 'brp072c', 'airbase', 'skyfi', 'brp084'];
return allowed.includes(valueArg as TDaikinApiVariant) ? valueArg as TDaikinApiVariant : 'auto';
}
private defaultPort(apiVariantArg: TDaikinApiVariant, sslArg: boolean): number {
if (apiVariantArg === 'skyfi') {
return daikinSkyFiPort;
}
return sslArg ? daikinDefaultHttpsPort : daikinDefaultHttpPort;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
}
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
return this.stringValue(metadataArg[keyArg]);
}
private booleanMetadata(metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined {
const value = metadataArg[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: url.protocol === 'https:' };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IDaikinSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'climate' in valueArg ? valueArg as IDaikinSnapshot : undefined;
};
const rawDataValue = (valueArg: unknown): Partial<IDaikinRawData> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IDaikinRawData> : undefined;
};
const valueMapValue = (valueArg: unknown): IDaikinValueMap | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IDaikinValueMap : undefined;
};
@@ -1,26 +1,107 @@
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 { DaikinClient } from './daikin.classes.client.js';
import { DaikinConfigFlow } from './daikin.classes.configflow.js';
import { createDaikinDiscoveryDescriptor } from './daikin.discovery.js';
import { DaikinMapper } from './daikin.mapper.js';
import type { IDaikinConfig } from './daikin.types.js';
import { daikinDisplayName, daikinDomain } from './daikin.types.js';
export class HomeAssistantDaikinIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "daikin",
displayName: "Daikin AC",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/daikin",
"upstreamDomain": "daikin",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pydaikin==2.17.2"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@fredrike"
]
},
});
export class DaikinIntegration extends BaseIntegration<IDaikinConfig> {
public readonly domain = daikinDomain;
public readonly displayName = daikinDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDaikinDiscoveryDescriptor();
public readonly configFlow = new DaikinConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/daikin',
upstreamDomain: daikinDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['pydaikin==2.17.2'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@fredrike'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/daikin',
zeroconf: ['_dkapi._tcp.local.'],
discovery: {
zeroconf: '_dkapi._tcp.local.',
udp: 'DAIKIN_UDP/common/basic_info broadcast on UDP 30050 from source port 30000.',
http: 'Manual HTTP candidates for /common/basic_info, /aircon/*, /skyfi/*, and /dsiot/* are recognized.',
manual: true,
},
runtime: {
type: 'control-runtime',
polling: 'local Daikin HTTP key/value APIs plus SkyFi and BRP084 dsiot read modeling',
services: ['snapshot', 'status', 'refresh', 'climate controls', 'power switch', 'zone switch', 'streamer', 'holiday/advanced modes when represented by local endpoints'],
controls: true,
},
localApi: {
implemented: [
'BRP069-style key/value local HTTP reads and set_control_info writes',
'BRP072C register_terminal/API-key request modeling when HTTPS is usable by the runtime',
'AirBase skyfi-prefixed reads plus zone setting writes',
'SkyFi password API reads and set.cgi/setzone.cgi writes',
'BRP084 dsiot snapshot reads',
'snapshot/manual-data and injected client/executor operation',
],
explicitUnsupported: [
'BRP084 dsiot control writes without an injected commandExecutor',
'faking command success for snapshot/manual-only configurations',
'forcing TLS certificate bypass for BRP072C self-signed devices through Node fetch',
],
},
};
public async setup(configArg: IDaikinConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DaikinRuntime(new DaikinClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDaikinIntegration extends DaikinIntegration {}
class DaikinRuntime implements IIntegrationRuntime {
public domain = daikinDomain;
constructor(private readonly client: DaikinClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DaikinMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DaikinMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === daikinDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === daikinDomain && requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot || result.data };
}
const snapshot = await this.client.getSnapshot();
const command = DaikinMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Daikin service: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
return { success: true, data: data ?? command };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
+380
View File
@@ -0,0 +1,380 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type {
IDiscoveryCandidate,
IDiscoveryContext,
IDiscoveryMatch,
IDiscoveryMatcher,
IDiscoveryProbe,
IDiscoveryProbeResult,
IDiscoveryValidator,
} from '../../core/types.js';
import { DaikinMapper } from './daikin.mapper.js';
import type { IDaikinHttpCandidateRecord, IDaikinManualEntry, IDaikinMdnsRecord, IDaikinRawData, IDaikinSnapshot, IDaikinUdpDiscoveryRecord, IDaikinValueMap } from './daikin.types.js';
import { daikinDefaultHttpPort, daikinDisplayName, daikinDomain, daikinUdpDiscoveryMessage, daikinUdpDiscoveryPort, daikinUdpSourcePort, daikinZeroconfType } from './daikin.types.js';
export class DaikinUdpDiscoveryProbe implements IDiscoveryProbe {
public id = 'daikin-udp-discovery-probe';
public source = 'custom' as const;
public description = 'Discover Daikin units using the local DAIKIN_UDP/common/basic_info broadcast used by pydaikin.';
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
if (contextArg.abortSignal?.aborted) {
return { candidates: [] };
}
return { candidates: await this.discover(1200) };
}
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
const { createSocket } = await import('node:dgram');
const matcher = new DaikinUdpMatcher();
const candidates: IDiscoveryCandidate[] = [];
const message = Buffer.from(daikinUdpDiscoveryMessage, 'utf8');
return new Promise((resolve) => {
const socket = createSocket({ type: 'udp4', reuseAddr: true });
const timer = setTimeout(() => {
closeSocket();
resolve(candidates);
}, timeoutMsArg);
const closeSocket = () => {
clearTimeout(timer);
try {
socket.close();
} catch {
// Discovery sockets may already be closed after timeout or OS errors.
}
};
socket.on('message', async (dataArg, remoteArg) => {
const match = await matcher.matches({ host: remoteArg.address, port: remoteArg.port, data: dataArg.toString('utf8') });
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
candidates.push(match.candidate);
}
});
socket.on('error', () => {
closeSocket();
resolve(candidates);
});
socket.bind(daikinUdpSourcePort, () => {
socket.setBroadcast(true);
socket.send(message, daikinUdpDiscoveryPort, '255.255.255.255');
});
});
}
}
export class DaikinZeroconfMatcher implements IDiscoveryMatcher<IDaikinMdnsRecord> {
public id = 'daikin-zeroconf-match';
public source = 'mdns' as const;
public description = 'Recognize Daikin _dkapi._tcp.local zeroconf advertisements from the Home Assistant manifest.';
public async matches(recordArg: IDaikinMdnsRecord): Promise<IDiscoveryMatch> {
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
const name = stringValue(txt.name) || cleanServiceName(recordArg.name) || daikinDisplayName;
const mac = DaikinMapper.normalizeMac(txt.mac || txt.macaddress || txt.id);
const matched = type === normalizeMdnsType(daikinZeroconfType) || type.includes('_dkapi') || recordArg.metadata?.daikin === true;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Daikin _dkapi advertisement.' };
}
return {
matched: true,
confidence: host && mac ? 'certain' : host ? 'high' : 'medium',
reason: 'mDNS service matches _dkapi._tcp.local.',
normalizedDeviceId: mac || recordArg.name || host,
candidate: {
source: 'mdns',
integrationDomain: daikinDomain,
id: mac || recordArg.name || host,
host,
port: recordArg.port || daikinDefaultHttpPort,
name,
manufacturer: 'Daikin',
model: txt.model || txt.type || 'Daikin AC',
macAddress: mac,
metadata: {
...recordArg.metadata,
daikin: true,
discoveryProtocol: 'zeroconf',
mdnsType: type,
mdnsName: recordArg.name,
txt,
},
},
};
}
}
export class DaikinUdpMatcher implements IDiscoveryMatcher<IDaikinUdpDiscoveryRecord> {
public id = 'daikin-udp-match';
public source = 'custom' as const;
public description = 'Recognize pydaikin DAIKIN_UDP/basic_info discovery responses.';
public async matches(recordArg: IDaikinUdpDiscoveryRecord): Promise<IDiscoveryMatch> {
const values = this.values(recordArg);
const host = recordArg.host || recordArg.address || recordArg.ip;
const mac = DaikinMapper.normalizeMac(values.mac);
const name = safeDecode(values.name) || (mac ? `Daikin ${mac.slice(-5).replace(':', '')}` : daikinDisplayName);
const matched = Boolean(mac || values.type === 'aircon' || values.adp_kind || recordArg.metadata?.daikin === true);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'UDP payload is not a Daikin discovery response.' };
}
return {
matched: true,
confidence: host && mac ? 'certain' : host ? 'high' : 'medium',
reason: mac ? 'UDP response contains a Daikin MAC address.' : 'UDP response contains Daikin adapter metadata.',
normalizedDeviceId: mac || host,
candidate: {
source: 'custom',
integrationDomain: daikinDomain,
id: mac || host,
host,
port: daikinDefaultHttpPort,
name,
manufacturer: 'Daikin',
model: values.model || values.type || 'Daikin AC',
macAddress: mac,
metadata: {
...recordArg.metadata,
daikin: true,
discoveryProtocol: 'udp',
discoveryPort: recordArg.port,
values,
},
},
};
}
private values(recordArg: IDaikinUdpDiscoveryRecord): IDaikinValueMap {
const raw = recordArg.values || recordArg.response || recordArg.data || {};
if (typeof raw === 'string') {
try {
return DaikinMapper.parseKeyValueResponse(raw);
} catch {
return {};
}
}
return raw;
}
}
export class DaikinHttpMatcher implements IDiscoveryMatcher<IDaikinHttpCandidateRecord> {
public id = 'daikin-http-match';
public source = 'http' as const;
public description = 'Recognize local HTTP candidates that look like Daikin common/aircon, SkyFi, or dsiot endpoints.';
public async matches(recordArg: IDaikinHttpCandidateRecord): Promise<IDiscoveryMatch> {
const parsed = parseEndpoint(recordArg.url || recordArg.location);
const headers = normalizeKeys(recordArg.headers || {});
const path = recordArg.path || parsed?.path || '';
const body = recordArg.body || recordArg.responseText || '';
const text = [recordArg.url, recordArg.location, path, body, headers.server, recordArg.name, recordArg.manufacturer, recordArg.model].filter(Boolean).join(' ').toLowerCase();
const values = this.parseBody(body);
const mac = DaikinMapper.normalizeMac(values.mac);
const matched = Boolean(recordArg.metadata?.daikin)
|| path.includes('/common/basic_info')
|| path.includes('/aircon/')
|| path.includes('/skyfi/')
|| path.includes('/dsiot/')
|| text.includes('daikin')
|| Boolean(mac && values.type === 'aircon');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Daikin local API.' };
}
const host = recordArg.host || parsed?.host;
const port = recordArg.port || parsed?.port || daikinDefaultHttpPort;
return {
matched: true,
confidence: host && (mac || path.includes('/common/basic_info')) ? 'high' : host ? 'medium' : 'low',
reason: 'HTTP candidate has Daikin API path, body, or metadata hints.',
normalizedDeviceId: mac || (host ? `${host}:${port}` : undefined),
candidate: {
source: 'http',
integrationDomain: daikinDomain,
id: mac || (host ? `${host}:${port}` : undefined),
host,
port,
name: safeDecode(values.name) || recordArg.name || daikinDisplayName,
manufacturer: recordArg.manufacturer || 'Daikin',
model: recordArg.model || values.model || 'Daikin AC',
macAddress: mac,
metadata: {
...recordArg.metadata,
daikin: true,
url: recordArg.url || recordArg.location,
ssl: recordArg.ssl ?? parsed?.ssl ?? false,
values: Object.keys(values).length ? values : undefined,
headers,
},
},
};
}
private parseBody(valueArg: string): IDaikinValueMap {
if (!valueArg.trim()) {
return {};
}
try {
return DaikinMapper.parseKeyValueResponse(valueArg);
} catch {
return {};
}
}
}
export class DaikinManualMatcher implements IDiscoveryMatcher<IDaikinManualEntry> {
public id = 'daikin-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Daikin host, API key/password, snapshot, client, and raw-data setup entries.';
public async matches(inputArg: IDaikinManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const parsed = parseEndpoint(inputArg.url || inputArg.host);
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
const hasManualData = Boolean(snapshot || rawData || inputArg.values || metadata.client);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(inputArg.host || inputArg.url || inputArg.apiKey || inputArg.password || metadata.daikin || hasManualData || text.includes('daikin'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Daikin setup hints.' };
}
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.device.host;
const port = inputArg.port || parsed?.port || snapshot?.device.port || (inputArg.apiVariant === 'skyfi' || inputArg.password ? 2000 : daikinDefaultHttpPort);
const id = inputArg.id || inputArg.uniqueId || snapshot?.device.id || snapshot?.device.macAddress || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start Daikin setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: daikinDomain,
id,
host,
port,
name: inputArg.name || snapshot?.device.name || daikinDisplayName,
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || 'Daikin',
model: inputArg.model || snapshot?.device.model,
macAddress: inputArg.macAddress || snapshot?.device.macAddress,
metadata: {
...metadata,
daikin: true,
discoveryProtocol: 'manual',
ssl: inputArg.ssl ?? inputArg.tls ?? parsed?.ssl ?? metadata.ssl,
apiVariant: inputArg.apiVariant || metadata.apiVariant,
apiKey: inputArg.apiKey || metadata.apiKey,
uuid: inputArg.uuid || metadata.uuid,
password: inputArg.password || metadata.password,
snapshot,
rawData,
values: inputArg.values || metadata.values,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
},
},
};
}
}
export class DaikinCandidateValidator implements IDiscoveryValidator {
public id = 'daikin-candidate-validator';
public description = 'Validate Daikin candidates from zeroconf, UDP, HTTP, and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : '';
const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, mdnsType].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === daikinDomain
|| metadata.daikin === true
|| text.includes('daikin')
|| mdnsType.includes('_dkapi')
|| discoveryProtocol === 'udp';
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.values || metadata.client);
const normalizedDeviceId = DaikinMapper.normalizeMac(candidateArg.macAddress) || candidateArg.id || snapshot?.device.macAddress || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || daikinDefaultHttpPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Daikin candidate lacks a host, injected client, snapshot, or manual data.' : 'Candidate is not Daikin.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Daikin metadata and a usable local endpoint, client, snapshot, or manual data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: daikinDomain,
port: candidateArg.port || daikinDefaultHttpPort,
manufacturer: candidateArg.manufacturer || 'Daikin',
},
};
}
}
export const createDaikinDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: daikinDomain, displayName: daikinDisplayName })
.addProbe(new DaikinUdpDiscoveryProbe())
.addMatcher(new DaikinZeroconfMatcher())
.addMatcher(new DaikinUdpMatcher())
.addMatcher(new DaikinHttpMatcher())
.addMatcher(new DaikinManualMatcher())
.addValidator(new DaikinCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
const cleanServiceName = (valueArg: string | undefined): string | undefined => {
if (!valueArg) {
return undefined;
}
return valueArg.replace(/\._.*$/u, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; path: string } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: url.protocol === 'https:', path: url.pathname };
} catch {
return undefined;
}
};
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(recordArg)) {
normalized[key.toLowerCase()] = value;
}
return normalized;
};
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const safeDecode = (valueArg: string | undefined): string | undefined => {
if (valueArg === undefined) {
return undefined;
}
try {
return decodeURIComponent(valueArg.replace(/\+/g, '%20'));
} catch {
return valueArg;
}
};
const snapshotValue = (valueArg: unknown): IDaikinSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'climate' in valueArg ? valueArg as IDaikinSnapshot : undefined;
};
const rawDataValue = (valueArg: unknown): Partial<IDaikinRawData> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IDaikinRawData> : undefined;
};
File diff suppressed because it is too large Load Diff
+240 -3
View File
@@ -1,4 +1,241 @@
export interface IHomeAssistantDaikinConfig {
// TODO: replace with the TypeScript-native config for daikin.
[key: string]: unknown;
export const daikinDomain = 'daikin';
export const daikinDisplayName = 'Daikin AC';
export const daikinDefaultHttpPort = 80;
export const daikinDefaultHttpsPort = 443;
export const daikinSkyFiPort = 2000;
export const daikinDefaultTimeoutMs = 10000;
export const daikinUdpSourcePort = 30000;
export const daikinUdpDiscoveryPort = 30050;
export const daikinUdpDiscoveryMessage = 'DAIKIN_UDP/common/basic_info';
export const daikinZeroconfType = '_dkapi._tcp.local.';
export type TDaikinApiVariant = 'auto' | 'brp069' | 'brp072c' | 'airbase' | 'skyfi' | 'brp084';
export type TDaikinSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
export type TDaikinHvacMode = 'off' | 'fan_only' | 'dry' | 'cool' | 'heat' | 'heat_cool' | 'auto' | 'unknown' | string;
export type TDaikinHvacAction = 'off' | 'idle' | 'cooling' | 'heating' | 'drying' | 'fan' | 'unknown';
export type TDaikinPresetMode = 'none' | 'away' | 'boost' | 'eco' | string;
export type TDaikinCommandAction = 'refresh' | 'set_control' | 'set_holiday' | 'set_advanced_mode' | 'set_streamer' | 'set_zone';
export interface IDaikinValueMap {
[key: string]: string | undefined;
}
export interface IDaikinRawData {
variant?: TDaikinApiVariant;
values?: IDaikinValueMap;
resources?: Record<string, IDaikinValueMap>;
basicInfo?: IDaikinValueMap;
controlInfo?: IDaikinValueMap;
sensorInfo?: IDaikinValueMap;
modelInfo?: IDaikinValueMap;
holiday?: IDaikinValueMap;
notify?: IDaikinValueMap;
zoneSetting?: IDaikinValueMap;
dayPowerEx?: IDaikinValueMap;
weekPower?: IDaikinValueMap;
yearPower?: IDaikinValueMap;
skyfiAc?: IDaikinValueMap;
skyfiZones?: IDaikinValueMap;
brp084?: unknown;
fetchedAt?: string;
}
export interface IDaikinDeviceSnapshotInfo {
id: string;
name: string;
manufacturer: string;
model?: string;
firmware?: string;
adapterVersion?: string;
host?: string;
port?: number;
ssl?: boolean;
macAddress?: string;
apiVariant: TDaikinApiVariant;
}
export interface IDaikinClimateSnapshot {
power: boolean;
hvacMode: TDaikinHvacMode;
hvacAction: TDaikinHvacAction;
rawDaikinMode?: string;
rawPower?: string;
targetTemperature?: number;
insideTemperature?: number;
outsideTemperature?: number;
humidity?: number;
targetHumidity?: number;
compressorFrequency?: number;
fanMode?: string;
swingMode?: string;
presetMode: TDaikinPresetMode;
fanModes: string[];
swingModes: string[];
presetModes: TDaikinPresetMode[];
temperatureUnit: 'C';
targetTemperatureStep: number;
}
export interface IDaikinEnergySnapshot {
energyToday?: number;
coolEnergyToday?: number;
heatEnergyToday?: number;
totalEnergyToday?: number;
totalPower?: number;
lastHourCoolEnergy?: number;
lastHourHeatEnergy?: number;
}
export interface IDaikinZoneSnapshot {
id: number;
name: string;
enabled: boolean;
targetTemperature?: number;
heatTargetTemperature?: number;
coolTargetTemperature?: number;
}
export interface IDaikinCapabilities {
localControl: boolean;
fanRate: boolean;
swingMode: boolean;
awayMode: boolean;
advancedModes: boolean;
streamer: boolean;
outsideTemperature: boolean;
humidity: boolean;
compressorFrequency: boolean;
energyConsumption: boolean;
zones: boolean;
zoneTemperatureControl: boolean;
}
export interface IDaikinSnapshot {
device: IDaikinDeviceSnapshotInfo;
climate: IDaikinClimateSnapshot;
energy: IDaikinEnergySnapshot;
zones: IDaikinZoneSnapshot[];
capabilities: IDaikinCapabilities;
rawData?: IDaikinRawData;
online: boolean;
updatedAt: string;
source: TDaikinSnapshotSource;
error?: string;
}
export interface IDaikinControlSettings {
mode?: string;
pow?: string;
stemp?: string | number;
shum?: string | number;
f_rate?: string;
f_dir?: string;
}
export interface IDaikinCommandRequest {
action: TDaikinCommandAction;
method?: 'GET' | 'POST';
path?: string;
query?: Record<string, string | number | boolean | undefined>;
protocol?: TDaikinApiVariant | 'legacy';
settings?: Partial<IDaikinControlSettings>;
mode?: string;
enabled?: boolean;
zoneId?: number;
zoneKey?: string;
zoneValue?: string;
service?: string;
target?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface IDaikinCommandExecutor {
execute(requestArg: IDaikinCommandRequest): Promise<unknown>;
}
export interface IDaikinClientLike {
getSnapshot?: () => Promise<IDaikinSnapshot | Partial<IDaikinRawData>>;
getRawData?: () => Promise<Partial<IDaikinRawData>>;
getResource?: (pathArg: string, queryArg?: Record<string, string | number | boolean | undefined>) => Promise<IDaikinValueMap>;
execute?: (requestArg: IDaikinCommandRequest) => Promise<unknown>;
destroy?: () => Promise<void>;
}
export interface IDaikinConfig {
host?: string;
url?: string;
port?: number;
ssl?: boolean;
tls?: boolean;
verifySsl?: boolean;
apiKey?: string;
uuid?: string;
password?: string;
apiVariant?: TDaikinApiVariant;
timeoutMs?: number;
name?: string;
manufacturer?: string;
model?: string;
uniqueId?: string;
macAddress?: string;
snapshot?: IDaikinSnapshot;
rawData?: Partial<IDaikinRawData>;
values?: IDaikinValueMap;
client?: IDaikinClientLike;
commandExecutor?: IDaikinCommandExecutor;
online?: boolean;
}
export interface IHomeAssistantDaikinConfig extends IDaikinConfig {}
export interface IDaikinManualEntry extends IDaikinConfig {
id?: string;
metadata?: Record<string, unknown>;
}
export interface IDaikinMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export interface IDaikinUdpDiscoveryRecord {
host?: string;
address?: string;
ip?: string;
port?: number;
data?: string | IDaikinValueMap;
response?: string | IDaikinValueMap;
values?: IDaikinValueMap;
metadata?: Record<string, unknown>;
}
export interface IDaikinHttpCandidateRecord {
url?: string;
location?: string;
host?: string;
port?: number;
ssl?: boolean;
path?: string;
name?: string;
manufacturer?: string;
model?: string;
headers?: Record<string, string | undefined>;
body?: string;
responseText?: string;
metadata?: Record<string, unknown>;
}
export interface IDaikinRefreshResult {
success: boolean;
snapshot?: IDaikinSnapshot;
error?: string;
data?: unknown;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './daikin.classes.client.js';
export * from './daikin.classes.configflow.js';
export * from './daikin.classes.integration.js';
export * from './daikin.discovery.js';
export * from './daikin.mapper.js';
export * from './daikin.types.js';
@@ -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,549 @@
import type {
IDirectvClientLike,
IDirectvConfig,
IDirectvDeviceInfo,
IDirectvDeviceUpdate,
IDirectvGetLocationsResponse,
IDirectvGetVersionResponse,
IDirectvLocation,
IDirectvLocationState,
IDirectvModeResponse,
IDirectvProgram,
IDirectvProgramResponse,
IDirectvRawRequest,
IDirectvSnapshot,
TDirectvRemoteKey,
TDirectvSnapshotSource,
} from './directv.types.js';
import {
directvDefaultDevice,
directvDefaultName,
directvDefaultPort,
directvDefaultTimeoutMs,
directvRemoteKeys,
} from './directv.types.js';
const validRemoteKeys = new Set<string>(directvRemoteKeys);
export class DirectvError extends Error {
constructor(messageArg: string) {
super(messageArg);
this.name = 'DirectvError';
}
}
export class DirectvAccessRestrictedError extends DirectvError {
constructor(messageArg = 'DirecTV access is restricted. Enable external device access on the receiver.') {
super(messageArg);
this.name = 'DirectvAccessRestrictedError';
}
}
export class DirectvTransportError extends DirectvError {
constructor(messageArg: string) {
super(messageArg);
this.name = 'DirectvTransportError';
}
}
export class DirectvClient {
private currentSnapshot?: IDirectvSnapshot;
constructor(private readonly config: IDirectvConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<IDirectvSnapshot> {
if (this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.cloneValue(this.config.snapshot), 'snapshot');
return this.cloneValue(this.currentSnapshot);
}
if (this.config.client?.getSnapshot || this.config.client?.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client');
return this.cloneValue(this.currentSnapshot);
}
if (this.config.host || this.config.commandExecutor) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneValue(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('DirecTV refresh requires a host, snapshot, injected client, or command executor.');
return this.cloneValue(this.currentSnapshot);
}
public async refresh(): Promise<IDirectvSnapshot> {
return this.getSnapshot();
}
public async validateConnection(): Promise<IDirectvSnapshot> {
const snapshot = await this.fetchSnapshot();
if (!snapshot.online) {
throw new DirectvTransportError(snapshot.error || 'DirecTV receiver did not return live state.');
}
return snapshot;
}
public async update(): Promise<IDirectvDeviceUpdate> {
const info = await this.requestJson<IDirectvGetVersionResponse>('info/getVersion');
if (!this.isRecord(info)) {
throw new DirectvTransportError('DirecTV info/getVersion returned an invalid response.');
}
const locationsResponse = await this.requestJson<IDirectvGetLocationsResponse>('info/getLocations');
if (!this.isRecord(locationsResponse) || !Array.isArray(locationsResponse.locations)) {
throw new DirectvTransportError('DirecTV info/getLocations returned an invalid response.');
}
const deviceInfo = this.deviceInfoFromResponse(info);
const locations = locationsResponse.locations.map((locationArg) => this.locationFromResponse(locationArg));
return {
info: deviceInfo,
locations: locations.length ? locations : this.configLocations(deviceInfo),
};
}
public async state(clientArg = directvDefaultDevice): Promise<IDirectvLocationState> {
const updatedAt = new Date().toISOString();
let available = true;
let authorized = true;
let standby = true;
let error: string | undefined;
try {
const mode = await this.requestJson<IDirectvModeResponse>('info/mode', { clientAddr: clientArg });
const modeValue = this.numberValue(mode.mode, 1);
standby = modeValue === 1;
} catch (errorArg) {
if (errorArg instanceof DirectvAccessRestrictedError) {
return {
address: clientArg,
available: false,
authorized: false,
standby: true,
status: 'unauthorized',
updatedAt,
error: errorArg.message,
};
}
available = false;
standby = true;
error = this.errorMessage(errorArg);
}
let program: IDirectvProgram | undefined;
if (!standby && available) {
try {
program = await this.tuned(clientArg);
} catch (errorArg) {
if (errorArg instanceof DirectvAccessRestrictedError) {
authorized = false;
error = errorArg.message;
} else {
available = false;
error = this.errorMessage(errorArg);
}
}
}
const previousState = this.currentSnapshot?.states.find((stateArg) => stateArg.address === clientArg);
const paused = Boolean(program?.recorded && previousState?.program?.position === program.position);
const positionUpdatedAt = program && !paused ? updatedAt : previousState?.positionUpdatedAt;
return {
address: clientArg,
available,
authorized,
standby,
status: !authorized ? 'unauthorized' : !available ? 'unavailable' : standby ? 'standby' : 'active',
program,
paused,
positionUpdatedAt,
updatedAt,
error,
};
}
public async status(clientArg = directvDefaultDevice): Promise<string> {
try {
const mode = await this.requestJson<IDirectvModeResponse>('info/mode', { clientAddr: clientArg });
return this.numberValue(mode.mode, 1) === 1 ? 'standby' : 'active';
} catch (errorArg) {
return errorArg instanceof DirectvAccessRestrictedError ? 'unauthorized' : 'unavailable';
}
}
public async tuned(clientArg = directvDefaultDevice): Promise<IDirectvProgram> {
const response = await this.requestJson<IDirectvProgramResponse>('tv/getTuned', { clientAddr: clientArg });
if (!this.isRecord(response)) {
throw new DirectvTransportError('DirecTV tv/getTuned returned an invalid response.');
}
return this.programFromResponse(response);
}
public async sendRemoteKey(keyArg: TDirectvRemoteKey | string, clientArg = directvDefaultDevice): Promise<unknown> {
const key = keyArg.toLowerCase();
if (!validRemoteKeys.has(key)) {
throw new DirectvError(`Remote key is invalid: ${keyArg}`);
}
if (this.config.client?.sendRemoteKey) {
return this.config.client.sendRemoteKey(key, clientArg);
}
if (this.config.client?.remote) {
return this.config.client.remote(key, clientArg);
}
return this.requestJson('remote/processKey', {
key,
hold: 'keyPress',
clientAddr: clientArg,
});
}
public async remote(keyArg: TDirectvRemoteKey | string, clientArg = directvDefaultDevice): Promise<unknown> {
return this.sendRemoteKey(keyArg, clientArg);
}
public async tune(channelArg: string, clientArg = directvDefaultDevice): Promise<unknown> {
const [major, minor] = this.parseChannelNumber(channelArg);
if (this.config.client?.tune) {
return this.config.client.tune(channelArg, clientArg);
}
return this.requestJson('tv/tune', {
major,
minor,
clientAddr: clientArg,
});
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(): Promise<IDirectvSnapshot> {
const device = await this.update();
const updatedAt = new Date().toISOString();
const states = await Promise.all(device.locations.map((locationArg) => this.state(locationArg.address)));
const source = this.config.commandExecutor ? 'executor' : 'http';
return this.normalizeSnapshot({
device: device.info,
locations: device.locations,
states,
online: true,
updatedAt,
source,
}, source);
}
private async snapshotFromClient(clientArg: IDirectvClientLike): Promise<IDirectvSnapshot> {
const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.snapshot ? await clientArg.snapshot() : undefined;
if (!result) {
throw new DirectvError('DirecTV client must expose getSnapshot() or snapshot().');
}
return result;
}
private async requestJson<TValue>(endpointArg: string, paramsArg: Record<string, string | number | boolean | undefined> = {}): Promise<TValue> {
const request = this.rawRequest(endpointArg, paramsArg);
if (this.config.commandExecutor) {
return this.executorResult<TValue>(await this.config.commandExecutor.execute(request));
}
if (!this.config.host) {
throw new DirectvTransportError('DirecTV local HTTP request requires config.host or commandExecutor.');
}
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || directvDefaultTimeoutMs);
try {
const response = await globalThis.fetch(request.url, {
method: request.method,
signal: abortController.signal,
headers: {
Accept: 'application/json, text/plain, */*',
'User-Agent': 'smarthome.exchange-directv/0.1.0',
},
});
const text = await response.text();
if (response.status === 403) {
throw new DirectvAccessRestrictedError();
}
if (!response.ok) {
throw new DirectvTransportError(`DirecTV request ${endpointArg} failed with HTTP ${response.status}: ${text}`);
}
return this.parseResponseText<TValue>(text, response.headers.get('content-type') || '');
} catch (errorArg) {
if (errorArg instanceof DirectvError) {
throw errorArg;
}
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
throw new DirectvTransportError(`DirecTV request ${endpointArg} timed out after ${this.config.timeoutMs || directvDefaultTimeoutMs}ms.`);
}
throw new DirectvTransportError(`DirecTV request ${endpointArg} failed: ${this.errorMessage(errorArg)}`);
} finally {
clearTimeout(timeout);
}
}
private async executorResult<TValue>(valueArg: unknown): Promise<TValue> {
if (valueArg instanceof Response) {
const text = await valueArg.text();
if (valueArg.status === 403) {
throw new DirectvAccessRestrictedError();
}
if (!valueArg.ok) {
throw new DirectvTransportError(`DirecTV executor request failed with HTTP ${valueArg.status}: ${text}`);
}
return this.parseResponseText<TValue>(text, valueArg.headers.get('content-type') || '');
}
if (typeof valueArg === 'string') {
return this.parseResponseText<TValue>(valueArg, '');
}
return valueArg as TValue;
}
private parseResponseText<TValue>(textArg: string, contentTypeArg: string): TValue {
const text = textArg.trim();
if (!text) {
return {} as TValue;
}
if (contentTypeArg.includes('application/json') || text.startsWith('{') || text.startsWith('[')) {
return JSON.parse(text) as TValue;
}
return text as TValue;
}
private rawRequest(endpointArg: string, paramsArg: Record<string, string | number | boolean | undefined>): IDirectvRawRequest {
const port = this.config.port || directvDefaultPort;
const endpoint = endpointArg.replace(/^\/+/, '');
const base = this.config.host ? `http://${this.formatHost(this.config.host)}:${port}/` : 'http://directv.local/';
const url = new URL(endpoint, base);
for (const [key, value] of Object.entries(paramsArg)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
return {
endpoint,
params: { ...paramsArg },
url: url.toString(),
host: this.config.host,
port,
method: 'GET',
};
}
private normalizeSnapshot(snapshotArg: IDirectvSnapshot, sourceArg: TDirectvSnapshotSource): IDirectvSnapshot {
const device = {
...this.deviceInfoFromConfig(),
...snapshotArg.device,
};
const locations = snapshotArg.locations?.length ? snapshotArg.locations : this.configLocations(device);
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const online = snapshotArg.online ?? snapshotArg.states?.some((stateArg) => stateArg.available) ?? false;
const states = locations.map((locationArg) => this.normalizeState(locationArg, snapshotArg.states?.find((stateArg) => stateArg.address === locationArg.address), online, updatedAt));
return {
...snapshotArg,
device,
locations,
states,
online,
updatedAt,
source: snapshotArg.source || sourceArg,
error: snapshotArg.error || states.find((stateArg) => stateArg.error)?.error,
};
}
private normalizeState(locationArg: IDirectvLocation, stateArg: IDirectvLocationState | undefined, onlineArg: boolean, updatedAtArg: string): IDirectvLocationState {
if (stateArg) {
return {
...stateArg,
address: stateArg.address || locationArg.address,
available: stateArg.available ?? onlineArg,
authorized: stateArg.authorized ?? true,
standby: stateArg.standby ?? !onlineArg,
status: stateArg.status || (!onlineArg ? 'unavailable' : stateArg.standby ? 'standby' : 'active'),
updatedAt: stateArg.updatedAt || updatedAtArg,
};
}
return {
address: locationArg.address,
available: onlineArg,
authorized: true,
standby: !onlineArg,
status: onlineArg ? 'active' : 'unavailable',
updatedAt: updatedAtArg,
};
}
private offlineSnapshot(errorArg: string): IDirectvSnapshot {
const device = this.deviceInfoFromConfig();
const updatedAt = new Date().toISOString();
const locations = this.configLocations(device);
return this.normalizeSnapshot({
device,
locations,
states: locations.map((locationArg) => ({
address: locationArg.address,
available: false,
authorized: true,
standby: true,
status: 'unavailable',
updatedAt,
error: errorArg,
})),
online: false,
updatedAt,
source: 'runtime',
error: errorArg,
}, 'runtime');
}
private deviceInfoFromResponse(dataArg: IDirectvGetVersionResponse): IDirectvDeviceInfo {
return {
...this.deviceInfoFromConfig(),
brand: 'DirecTV',
receiverId: this.normalizeReceiverId(this.stringValue(dataArg.receiverId) || this.config.receiverId || this.config.uniqueId || this.config.host || 'manual-directv'),
version: this.stringValue(dataArg.stbSoftwareVersion) || 'Unknown',
};
}
private deviceInfoFromConfig(): IDirectvDeviceInfo {
return {
brand: 'DirecTV',
receiverId: this.normalizeReceiverId(this.config.receiverId || this.config.uniqueId || this.config.host || 'manual-directv'),
version: 'Unknown',
host: this.config.host,
port: this.config.port || directvDefaultPort,
name: this.config.name || directvDefaultName,
};
}
private locationFromResponse(dataArg: unknown): IDirectvLocation {
const data = this.isRecord(dataArg) ? dataArg : {};
const address = this.stringValue(data.clientAddr) || directvDefaultDevice;
return {
client: address !== directvDefaultDevice,
name: this.stringValue(data.locationName) || (address === directvDefaultDevice ? directvDefaultName : `DirecTV Client ${address}`),
address,
};
}
private configLocations(deviceArg: IDirectvDeviceInfo): IDirectvLocation[] {
if (this.config.locations?.length) {
return this.config.locations.map((locationArg) => ({
client: locationArg.address !== directvDefaultDevice,
name: locationArg.name || (locationArg.address === directvDefaultDevice ? directvDefaultName : `DirecTV Client ${locationArg.address}`),
address: locationArg.address || directvDefaultDevice,
}));
}
return [{ client: false, name: deviceArg.name || directvDefaultName, address: directvDefaultDevice }];
}
private programFromResponse(dataArg: IDirectvProgramResponse): IDirectvProgram {
const major = this.numberValue(dataArg.major, 0);
const minor = this.numberValue(dataArg.minor, 65535);
const uniqueId = this.optionalNumber(dataArg.uniqueId);
const music = this.isRecord(dataArg.music) ? dataArg.music : {};
const episodeTitle = this.stringValue(dataArg.episodeTitle);
const musicTitle = this.stringValue(music.title);
const startTime = this.optionalNumber(dataArg.startTime);
return {
channel: this.combineChannelNumber(major, minor),
channelName: this.stringValue(dataArg.callsign),
programId: this.optionalNumber(dataArg.programId),
programType: episodeTitle ? 'tvshow' : musicTitle ? 'music' : 'movie',
duration: this.numberValue(dataArg.duration, 0),
title: this.stringValue(dataArg.title),
episodeTitle,
musicTitle,
musicAlbum: this.stringValue(music.cd),
musicArtist: this.stringValue(music.by),
ondemand: this.booleanValue(dataArg.isVod, false),
partial: this.booleanValue(dataArg.isPartial, false),
payperview: this.booleanValue(dataArg.isPpv, false),
position: this.numberValue(dataArg.offset, 0),
purchased: this.booleanValue(dataArg.isPurchased, false),
rating: this.stringValue(dataArg.rating),
recorded: uniqueId !== undefined,
recording: this.booleanValue(dataArg.isRecording, false),
startTime: startTime !== undefined ? new Date(startTime * 1000).toISOString() : undefined,
uniqueId,
viewed: this.booleanValue(dataArg.isViewed, false),
};
}
private parseChannelNumber(channelArg: string): [string, string] {
const [major, minor] = channelArg.split('-', 2);
if (!major) {
throw new DirectvError('DirecTV channel number is required.');
}
return [major, minor || '65535'];
}
private combineChannelNumber(majorArg: number, minorArg: number): string {
return minorArg === 65535 ? String(majorArg) : `${majorArg}-${minorArg}`;
}
private normalizeReceiverId(valueArg: string): string {
return valueArg.replace(/^RID-/i, '').replace(/\s+/g, '') || 'manual-directv';
}
private formatHost(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown, fallbackArg: number): number {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : fallbackArg;
}
return fallbackArg;
}
private optionalNumber(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown, fallbackArg: boolean): boolean {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
if (typeof valueArg === 'string') {
if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) return true;
if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) return false;
}
return fallbackArg;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
@@ -0,0 +1,100 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IDirectvConfig, IDirectvSnapshot } from './directv.types.js';
import { directvDefaultPort } from './directv.types.js';
export class DirectvConfigFlow implements IConfigFlow<IDirectvConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDirectvConfig>> {
void contextArg;
if ((candidateArg.source === 'ssdp' || candidateArg.source === 'manual') && (candidateArg.host || this.snapshotMetadata(candidateArg))) {
return {
kind: 'done',
title: candidateArg.name || candidateArg.host || 'DirecTV configured',
config: this.configFromCandidate(candidateArg),
};
}
return {
kind: 'form',
title: 'Connect DirecTV receiver',
description: 'Configure the local DirecTV SHEF HTTP endpoint. Live state and commands require a receiver host or injected client/executor.',
fields: [
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
{ name: 'port', label: `Port (${candidateArg.port || directvDefaultPort})`, type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'receiverId', label: 'Receiver ID', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDirectvConfig>> {
const host = this.stringValue(valuesArg.host) || candidateArg.host;
const snapshot = this.snapshotMetadata(candidateArg);
const hasInjectedRuntime = Boolean(candidateArg.metadata?.client || candidateArg.metadata?.commandExecutor || snapshot);
if (!host && !hasInjectedRuntime) {
return { kind: 'error', title: 'DirecTV setup failed', error: 'DirecTV setup requires a host, snapshot, injected client, or command executor.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || directvDefaultPort;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { kind: 'error', title: 'DirecTV setup failed', error: 'DirecTV port must be between 1 and 65535.' };
}
return {
kind: 'done',
title: this.stringValue(valuesArg.name) || candidateArg.name || host || 'DirecTV configured',
config: {
...this.configFromCandidate(candidateArg),
host,
port,
name: this.stringValue(valuesArg.name) || candidateArg.name,
receiverId: this.receiverId(this.stringValue(valuesArg.receiverId) || this.stringMetadata(candidateArg, 'receiverId') || candidateArg.id || snapshot?.device.receiverId),
},
};
}
private configFromCandidate(candidateArg: IDiscoveryCandidate): IDirectvConfig {
const snapshot = this.snapshotMetadata(candidateArg);
const receiverId = this.receiverId(this.stringMetadata(candidateArg, 'receiverId') || candidateArg.id || candidateArg.serialNumber || snapshot?.device.receiverId);
return {
host: candidateArg.host || snapshot?.device.host,
port: candidateArg.port || snapshot?.device.port || directvDefaultPort,
name: candidateArg.name || snapshot?.device.name,
receiverId,
uniqueId: receiverId || candidateArg.id,
timeoutMs: 8000,
snapshot,
client: candidateArg.metadata?.client as IDirectvConfig['client'],
commandExecutor: candidateArg.metadata?.commandExecutor as IDirectvConfig['commandExecutor'],
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
const value = candidateArg.metadata?.[keyArg];
return this.stringValue(value);
}
private snapshotMetadata(candidateArg: IDiscoveryCandidate): IDirectvSnapshot | undefined {
const value = candidateArg.metadata?.snapshot;
return value && typeof value === 'object' && !Array.isArray(value) ? value as IDirectvSnapshot : undefined;
}
private receiverId(valueArg: string | undefined): string | undefined {
return valueArg?.replace(/^RID-/i, '').replace(/\s+/g, '') || undefined;
}
}
@@ -1,24 +1,223 @@
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 { DirectvClient } from './directv.classes.client.js';
import { DirectvConfigFlow } from './directv.classes.configflow.js';
import { createDirectvDiscoveryDescriptor } from './directv.discovery.js';
import { DirectvMapper } from './directv.mapper.js';
import type { IDirectvConfig } from './directv.types.js';
import { directvDefaultDevice, directvDomain } from './directv.types.js';
export class HomeAssistantDirectvIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "directv",
displayName: "DirecTV",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/directv",
"upstreamDomain": "directv",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"directv==0.4.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": []
},
});
export class DirectvIntegration extends BaseIntegration<IDirectvConfig> {
public readonly domain = directvDomain;
public readonly displayName = 'DirecTV';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDirectvDiscoveryDescriptor();
public readonly configFlow = new DirectvConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/directv',
upstreamDomain: directvDomain,
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['directv==0.4.0'],
dependencies: [],
afterDependencies: [],
codeowners: [],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/directv',
ssdp: [{ deviceType: 'urn:schemas-upnp-org:device:MediaServer:1', manufacturer: 'DIRECTV' }],
runtime: {
type: 'control-runtime',
polling: 'local DirecTV SHEF HTTP API',
services: ['snapshot', 'refresh', 'remote.send_command', 'media_player.play_media', 'media_player playback controls'],
controls: true,
},
localApi: {
implemented: [
'SSDP DirecTV MediaServer discovery',
'manual host setup',
'info/getVersion and info/getLocations snapshots',
'info/mode and tv/getTuned per-location state',
'remote/processKey commands',
'tv/tune channel selection',
],
explicitUnsupported: [
'cloud DirecTV account APIs',
'reporting live success without a local HTTP host, injected client, snapshot, or command executor',
],
},
};
public async setup(configArg: IDirectvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DirectvRuntime(new DirectvClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDirectvIntegration extends DirectvIntegration {}
class DirectvRuntime implements IIntegrationRuntime {
public domain = directvDomain;
constructor(private readonly client: DirectvClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DirectvMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DirectvMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'remote') {
return await this.callRemoteService(requestArg);
}
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === directvDomain) {
return await this.callDirectvService(requestArg);
}
return { success: false, error: `Unsupported DirecTV 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 callDirectvService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'refresh' || requestArg.service === 'reload') {
const snapshot = await this.client.refresh();
return snapshot.source === 'runtime' && !snapshot.online
? { success: false, error: snapshot.error || 'DirecTV refresh requires a host, snapshot, injected client, or command executor.', data: snapshot }
: { success: true, data: snapshot };
}
if (requestArg.service === 'send_command') {
return this.callRemoteService({ ...requestArg, domain: 'remote', service: 'send_command' });
}
if (requestArg.service === 'tune') {
const channel = this.stringData(requestArg, 'channel') || this.stringData(requestArg, 'media_content_id');
if (!channel) {
return { success: false, error: 'DirecTV tune requires data.channel.' };
}
const address = await this.targetAddress(requestArg);
return { success: true, data: await this.client.tune(channel, address) };
}
return { success: false, error: `Unsupported DirecTV service: ${requestArg.service}` };
}
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'turn_on') {
return { success: true, data: await this.client.sendRemoteKey('poweron', await this.targetAddress(requestArg)) };
}
if (requestArg.service === 'turn_off') {
return { success: true, data: await this.client.sendRemoteKey('poweroff', await this.targetAddress(requestArg)) };
}
if (requestArg.service !== 'send_command') {
return { success: false, error: `Unsupported DirecTV remote service: ${requestArg.service}` };
}
const commands = this.stringArrayData(requestArg, 'command');
if (!commands?.length) {
return { success: false, error: 'DirecTV remote.send_command requires data.command.' };
}
const repeats = this.repeats(requestArg);
const address = await this.targetAddress(requestArg);
let result: unknown;
for (let index = 0; index < repeats; index += 1) {
for (const command of commands) {
result = await this.client.sendRemoteKey(command, address);
}
}
return { success: true, data: result };
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const address = await this.targetAddress(requestArg);
if (requestArg.service === 'turn_on') {
if (address !== directvDefaultDevice) {
return { success: false, error: 'DirecTV media_player.turn_on is only supported for the main receiver.' };
}
return { success: true, data: await this.client.sendRemoteKey('poweron', address) };
}
if (requestArg.service === 'turn_off') {
if (address !== directvDefaultDevice) {
return { success: false, error: 'DirecTV media_player.turn_off is only supported for the main receiver.' };
}
return { success: true, data: await this.client.sendRemoteKey('poweroff', address) };
}
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
return { success: true, data: await this.client.sendRemoteKey('play', address) };
}
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
return { success: true, data: await this.client.sendRemoteKey('pause', address) };
}
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
return { success: true, data: await this.client.sendRemoteKey('stop', address) };
}
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
return { success: true, data: await this.client.sendRemoteKey('ffwd', address) };
}
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
return { success: true, data: await this.client.sendRemoteKey('rew', address) };
}
if (requestArg.service === 'play_media') {
const mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'media_type');
const mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'media_id') || this.stringData(requestArg, 'channel');
if (mediaType !== 'channel') {
return { success: false, error: 'DirecTV play_media only supports media_content_type channel.' };
}
if (!mediaId) {
return { success: false, error: 'DirecTV play_media requires data.media_content_id.' };
}
return { success: true, data: await this.client.tune(mediaId, address) };
}
return { success: false, error: `Unsupported DirecTV media_player service: ${requestArg.service}` };
}
private async targetAddress(requestArg: IServiceCallRequest): Promise<string> {
const explicit = this.stringData(requestArg, 'clientAddr') || this.stringData(requestArg, 'client_addr') || this.stringData(requestArg, 'address') || this.stringData(requestArg, 'client');
if (explicit) {
return explicit;
}
const targetId = requestArg.target.entityId || requestArg.target.deviceId;
if (!targetId) {
return directvDefaultDevice;
}
const snapshot = await this.client.getSnapshot();
for (const location of snapshot.locations) {
if (targetId === DirectvMapper.locationDeviceId(snapshot, location) || targetId === DirectvMapper.mediaPlayerEntityId(snapshot, location)) {
return location.address;
}
}
return directvDefaultDevice;
}
private repeats(requestArg: IServiceCallRequest): number {
const value = requestArg.data?.num_repeats ?? requestArg.data?.numRepeats ?? 1;
return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string' && value) {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string' && Boolean(itemArg)) ? value : undefined;
}
}
@@ -0,0 +1,151 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IDirectvManualEntry, IDirectvSsdpRecord } from './directv.types.js';
import { directvDefaultPort, directvDomain, directvSsdpDeviceType } from './directv.types.js';
export class DirectvSsdpMatcher implements IDiscoveryMatcher<IDirectvSsdpRecord> {
public id = 'directv-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize DirecTV receivers from UPnP MediaServer SSDP advertisements.';
public async matches(recordArg: IDirectvSsdpRecord): Promise<IDiscoveryMatch> {
const headers = recordArg.headers || {};
const st = recordArg.st || headerValue(headers, 'st');
const nt = recordArg.nt || headerValue(headers, 'nt');
const usn = recordArg.usn || headerValue(headers, 'usn');
const location = recordArg.location || headerValue(headers, 'location');
const deviceType = recordArg.deviceType || recordArg.upnp?.deviceType || st || nt;
const manufacturer = recordArg.manufacturer || recordArg.upnp?.manufacturer || headerValue(headers, 'manufacturer');
const serial = recordArg.serialNumber || recordArg.upnp?.serialNumber || recordArg.upnp?.serial;
const receiverId = stripReceiverPrefix(serial);
const matchedDeviceType = deviceType === directvSsdpDeviceType || Boolean(usn?.includes(directvSsdpDeviceType));
const matchedManufacturer = isDirectvText(manufacturer);
if (!matchedDeviceType || !matchedManufacturer) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not a DirecTV MediaServer advertisement.' };
}
const parsedLocation = parseLocation(location);
const id = receiverId || stripReceiverPrefix(usn?.split('::')[0]);
return {
matched: true,
confidence: id && parsedLocation?.hostname ? 'certain' : parsedLocation?.hostname ? 'high' : 'medium',
reason: 'SSDP record matches DirecTV MediaServer metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: directvDomain,
id,
host: parsedLocation?.hostname,
port: parsedLocation?.port || directvDefaultPort,
name: recordArg.upnp?.friendlyName || parsedLocation?.hostname,
manufacturer: 'DirecTV',
model: recordArg.upnp?.modelName,
serialNumber: receiverId || serial,
metadata: { st, nt, usn, location, deviceType, manufacturer, receiverId, serialNumber: serial },
},
};
}
}
export class DirectvManualMatcher implements IDiscoveryMatcher<IDirectvManualEntry> {
public id = 'directv-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual DirecTV receiver setup entries.';
public async matches(inputArg: IDirectvManualEntry): Promise<IDiscoveryMatch> {
const endpoint = inputArg.location || inputArg.url;
const parsedEndpoint = parseLocation(endpoint);
const host = inputArg.host || parsedEndpoint?.hostname;
const receiverId = stripReceiverPrefix(inputArg.receiverId || inputArg.id || stringMetadata(inputArg.metadata, 'receiverId'));
const hinted = Boolean(inputArg.metadata?.directv || inputArg.metadata?.direcTV || isDirectvText(inputArg.manufacturer) || isDirectvText(inputArg.model) || isDirectvText(inputArg.name));
if (!host && !hinted && !receiverId) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain DirecTV setup hints.' };
}
const id = receiverId || inputArg.id || host;
return {
matched: true,
confidence: host && receiverId ? 'certain' : host ? 'high' : 'medium',
reason: 'Manual entry can start DirecTV setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: directvDomain,
id,
host,
port: inputArg.port || parsedEndpoint?.port || directvDefaultPort,
name: inputArg.name || host,
manufacturer: inputArg.manufacturer || 'DirecTV',
model: inputArg.model,
serialNumber: receiverId,
metadata: { ...inputArg.metadata, receiverId, location: endpoint },
},
};
}
}
export class DirectvCandidateValidator implements IDiscoveryValidator {
public id = 'directv-candidate-validator';
public description = 'Validate DirecTV discovery candidate metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.deviceType].filter(Boolean).join(' ');
const receiverId = stripReceiverPrefix(candidateArg.serialNumber || stringMetadata(metadata, 'receiverId'));
const matched = candidateArg.integrationDomain === directvDomain
|| (metadata.deviceType === directvSsdpDeviceType && isDirectvText(text))
|| isDirectvText(text)
|| Boolean(receiverId || metadata.directv || metadata.direcTV);
return {
matched,
confidence: matched && candidateArg.host && (candidateArg.id || receiverId) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has DirecTV metadata.' : 'Candidate is not DirecTV.',
candidate: matched ? candidateArg : undefined,
normalizedDeviceId: candidateArg.id || receiverId,
};
}
}
export const createDirectvDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: directvDomain, displayName: 'DirecTV' })
.addMatcher(new DirectvSsdpMatcher())
.addMatcher(new DirectvManualMatcher())
.addValidator(new DirectvCandidateValidator());
};
const headerValue = (headersArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(headersArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const parseLocation = (valueArg?: string): { hostname: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
return { hostname: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const stripReceiverPrefix = (valueArg?: string): string | undefined => {
const value = valueArg?.replace(/^uuid:/i, '').replace(/^RID-/i, '').replace(/\s+/g, '').trim();
return value || undefined;
};
const isDirectvText = (valueArg: unknown): boolean => {
return typeof valueArg === 'string' && valueArg.toLowerCase().replace(/\s+/g, '').includes('directv');
};
const stringMetadata = (metadataArg: Record<string, unknown> | undefined, keyArg: string): string | undefined => {
const value = metadataArg?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
+161
View File
@@ -0,0 +1,161 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IDirectvLocation, IDirectvLocationState, IDirectvProgram, IDirectvSnapshot } from './directv.types.js';
import { directvDefaultDevice, directvDefaultName, directvDomain } from './directv.types.js';
export class DirectvMapper {
public static toDevices(snapshotArg: IDirectvSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
return snapshotArg.locations.map((locationArg) => {
const state = this.stateForLocation(snapshotArg, locationArg.address);
const program = state?.program;
return {
id: this.locationDeviceId(snapshotArg, locationArg),
integrationDomain: directvDomain,
name: this.locationName(locationArg),
protocol: 'http',
manufacturer: snapshotArg.device.brand || 'DirecTV',
model: locationArg.client ? 'DirecTV client' : 'DirecTV receiver',
online: Boolean(state?.available),
features: [
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'channel', capability: 'media', name: 'Channel', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
{ id: 'recording', capability: 'sensor', name: 'Recording', readable: true, writable: false },
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
],
state: [
{ featureId: 'power', value: this.powerState(state), updatedAt },
{ featureId: 'playback', value: this.mediaState(state), updatedAt },
{ featureId: 'channel', value: program?.channel || null, updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(program) || null, updatedAt },
{ featureId: 'recording', value: program?.recording ?? null, updatedAt },
],
metadata: this.cleanAttributes({
receiverId: snapshotArg.device.receiverId,
locationAddress: locationArg.address,
client: locationArg.client,
viaDevice: locationArg.client ? this.receiverIdentity(snapshotArg) : undefined,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
softwareVersion: snapshotArg.device.version,
source: snapshotArg.source,
status: state?.status,
authorized: state?.authorized,
error: state?.error || snapshotArg.error,
}),
};
});
}
public static toEntities(snapshotArg: IDirectvSnapshot): IIntegrationEntity[] {
return snapshotArg.locations.map((locationArg) => {
const state = this.stateForLocation(snapshotArg, locationArg.address);
const program = state?.program;
const name = this.locationName(locationArg);
return {
id: this.mediaPlayerEntityId(snapshotArg, locationArg),
uniqueId: `directv_${this.uniqueBase(snapshotArg, locationArg)}_media_player`,
integrationDomain: directvDomain,
deviceId: this.locationDeviceId(snapshotArg, locationArg),
platform: 'media_player',
name,
state: this.mediaState(state),
attributes: this.cleanAttributes({
receiverId: snapshotArg.device.receiverId,
locationAddress: locationArg.address,
isClient: locationArg.client,
source: snapshotArg.source,
mediaContentId: program?.programId,
mediaContentType: program?.programType,
mediaDuration: program?.duration,
mediaPosition: state?.standby ? undefined : program?.position,
mediaPositionUpdatedAt: state?.positionUpdatedAt,
mediaTitle: this.mediaTitle(program),
mediaArtist: program?.musicArtist,
mediaAlbumName: program?.musicAlbum,
mediaSeriesTitle: program?.episodeTitle,
mediaChannel: program?.channelName && program?.channel ? `${program.channelName} (${program.channel})` : program?.channel,
sourceChannel: program?.channel,
mediaCurrentlyRecording: program?.recording,
mediaRating: program?.rating,
mediaRecorded: state?.standby ? undefined : program?.recorded,
mediaStartTime: program?.startTime,
status: state?.status,
authorized: state?.authorized,
supportedFeatures: this.supportedFeatures(locationArg),
error: state?.error || snapshotArg.error,
}),
available: Boolean(state?.available),
};
});
}
public static locationDeviceId(snapshotArg: IDirectvSnapshot, locationArg: IDirectvLocation): string {
return `directv.device.${this.uniqueBase(snapshotArg, locationArg)}`;
}
public static mediaPlayerEntityId(snapshotArg: IDirectvSnapshot, locationArg: IDirectvLocation): string {
return `media_player.${this.slug(`${this.locationName(locationArg)} ${this.uniqueBase(snapshotArg, locationArg)}`)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || directvDomain;
}
public static mediaState(stateArg: IDirectvLocationState | undefined): string {
if (!stateArg?.available) {
return 'unavailable';
}
if (stateArg.standby) {
return 'off';
}
if (stateArg.paused) {
return 'paused';
}
return 'playing';
}
private static powerState(stateArg: IDirectvLocationState | undefined): string {
if (!stateArg?.available) {
return 'unavailable';
}
return stateArg.standby ? 'off' : 'on';
}
private static mediaTitle(programArg: IDirectvProgram | undefined): string | undefined {
if (!programArg) {
return undefined;
}
return programArg.programType === 'music' ? programArg.musicTitle : programArg.title;
}
private static supportedFeatures(locationArg: IDirectvLocation): string[] {
const common = ['pause', 'play_media', 'stop', 'next_track', 'previous_track', 'play', 'remote_send_command'];
return locationArg.client ? common : ['turn_on', 'turn_off', ...common];
}
private static stateForLocation(snapshotArg: IDirectvSnapshot, addressArg: string): IDirectvLocationState | undefined {
return snapshotArg.states.find((stateArg) => stateArg.address === addressArg);
}
private static uniqueBase(snapshotArg: IDirectvSnapshot, locationArg: IDirectvLocation): string {
const identity = locationArg.address !== directvDefaultDevice
? locationArg.address
: this.receiverIdentity(snapshotArg);
return this.slug(identity || this.locationName(locationArg));
}
private static receiverIdentity(snapshotArg: IDirectvSnapshot): string {
return snapshotArg.device.receiverId || snapshotArg.device.host || snapshotArg.device.name || directvDefaultName;
}
private static locationName(locationArg: IDirectvLocation): string {
return locationArg.name || (locationArg.client ? `DirecTV Client ${locationArg.address}` : directvDefaultName);
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
}
+242 -2
View File
@@ -1,4 +1,244 @@
export interface IHomeAssistantDirectvConfig {
// TODO: replace with the TypeScript-native config for directv.
export const directvDomain = 'directv';
export const directvDefaultPort = 8080;
export const directvDefaultDevice = '0';
export const directvDefaultName = 'DirecTV Receiver';
export const directvDefaultTimeoutMs = 8000;
export const directvSsdpDeviceType = 'urn:schemas-upnp-org:device:MediaServer:1';
export const directvSsdpManufacturer = 'DIRECTV';
export const directvRemoteKeys = [
'power',
'poweron',
'poweroff',
'format',
'pause',
'rew',
'replay',
'stop',
'advance',
'ffwd',
'record',
'play',
'guide',
'active',
'list',
'exit',
'back',
'menu',
'info',
'up',
'down',
'left',
'right',
'select',
'red',
'green',
'yellow',
'blue',
'chanup',
'chandown',
'prev',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'dash',
'enter',
] as const;
export type TDirectvRemoteKey = typeof directvRemoteKeys[number];
export type TDirectvSnapshotSource = 'snapshot' | 'client' | 'http' | 'executor' | 'runtime';
export type TDirectvProgramType = 'movie' | 'music' | 'tvshow';
export type TDirectvLocationStatus = 'active' | 'standby' | 'unavailable' | 'unauthorized';
export interface IDirectvConfig {
host?: string;
port?: number;
name?: string;
receiverId?: string;
uniqueId?: string;
timeoutMs?: number;
locations?: IDirectvLocation[];
snapshot?: IDirectvSnapshot;
client?: IDirectvClientLike;
commandExecutor?: IDirectvCommandExecutor;
}
export interface IHomeAssistantDirectvConfig extends IDirectvConfig {}
export interface IDirectvClientLike {
getSnapshot?: () => Promise<IDirectvSnapshot>;
snapshot?: () => Promise<IDirectvSnapshot>;
sendRemoteKey?: (keyArg: string, clientArg?: string) => Promise<unknown>;
remote?: (keyArg: string, clientArg?: string) => Promise<unknown>;
tune?: (channelArg: string, clientArg?: string) => Promise<unknown>;
}
export interface IDirectvCommandExecutor {
execute(requestArg: IDirectvRawRequest): Promise<unknown>;
}
export interface IDirectvRawRequest {
endpoint: string;
params: Record<string, string | number | boolean | undefined>;
url: string;
host?: string;
port: number;
method: 'GET';
}
export interface IDirectvDeviceInfo {
brand: string;
receiverId: string;
version: string;
host?: string;
port?: number;
name?: string;
}
export interface IDirectvLocation {
client: boolean;
name: string;
address: string;
}
export interface IDirectvProgram {
channel: string;
channelName?: string;
ondemand: boolean;
recorded: boolean;
recording: boolean;
viewed: boolean;
programId?: number;
programType: TDirectvProgramType;
duration: number;
title?: string;
episodeTitle?: string;
musicTitle?: string;
musicAlbum?: string;
musicArtist?: string;
partial: boolean;
payperview: boolean;
position: number;
purchased: boolean;
rating?: string;
startTime?: string;
uniqueId?: number;
}
export interface IDirectvLocationState {
address: string;
available: boolean;
authorized: boolean;
standby: boolean;
status: TDirectvLocationStatus;
program?: IDirectvProgram;
paused?: boolean;
positionUpdatedAt?: string;
updatedAt?: string;
error?: string;
}
export interface IDirectvSnapshot {
device: IDirectvDeviceInfo;
locations: IDirectvLocation[];
states: IDirectvLocationState[];
online: boolean;
updatedAt?: string;
source?: TDirectvSnapshotSource;
error?: string;
}
export interface IDirectvDeviceUpdate {
info: IDirectvDeviceInfo;
locations: IDirectvLocation[];
}
export interface IDirectvGetVersionResponse {
receiverId?: string;
stbSoftwareVersion?: string;
[key: string]: unknown;
}
export interface IDirectvGetLocationsResponse {
locations?: IDirectvLocationResponse[];
[key: string]: unknown;
}
export interface IDirectvLocationResponse {
clientAddr?: string;
locationName?: string;
[key: string]: unknown;
}
export interface IDirectvModeResponse {
mode?: number | string;
[key: string]: unknown;
}
export interface IDirectvProgramResponse {
major?: number | string;
minor?: number | string;
callsign?: string;
isVod?: boolean;
isPartial?: boolean;
isPpv?: boolean;
isPurchased?: boolean;
isRecording?: boolean;
isViewed?: boolean;
uniqueId?: number | string;
programId?: number | string;
duration?: number | string;
title?: string;
episodeTitle?: string;
music?: {
title?: string;
cd?: string;
by?: string;
[key: string]: unknown;
};
offset?: number | string;
rating?: string;
startTime?: number | string;
[key: string]: unknown;
}
export interface IDirectvSsdpRecord {
st?: string;
nt?: string;
usn?: string;
location?: string;
deviceType?: string;
manufacturer?: string;
serialNumber?: string;
headers?: Record<string, string | undefined>;
upnp?: {
deviceType?: string;
friendlyName?: string;
manufacturer?: string;
modelName?: string;
serialNumber?: string;
serial?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface IDirectvManualEntry {
host?: string;
port?: number;
id?: string;
receiverId?: string;
name?: string;
manufacturer?: string;
model?: string;
url?: string;
location?: string;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './directv.classes.client.js';
export * from './directv.classes.configflow.js';
export * from './directv.classes.integration.js';
export * from './directv.discovery.js';
export * from './directv.mapper.js';
export * from './directv.types.js';
@@ -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,534 @@
import { DoorbirdMapper } from './doorbird.mapper.js';
import type {
IDoorbirdClientLike,
IDoorbirdCommandRequest,
IDoorbirdConfig,
IDoorbirdFavoriteChange,
IDoorbirdFavorites,
IDoorbirdInfo,
IDoorbirdRawData,
IDoorbirdRefreshResult,
IDoorbirdScheduleEntry,
IDoorbirdSnapshot,
IDoorbirdSnapshotImage,
IDoorbirdStatus,
TDoorbirdProtocol,
TDoorbirdCameraKind,
} from './doorbird.types.js';
import { doorbirdDefaultPort, doorbirdDefaultSnapshotTimeoutMs, doorbirdDefaultTimeoutMs } from './doorbird.types.js';
export class DoorbirdApiError extends Error {}
export class DoorbirdApiConnectionError extends DoorbirdApiError {}
export class DoorbirdApiAuthorizationError extends DoorbirdApiError {}
export class DoorbirdApiNotFoundError extends DoorbirdApiError {}
export class DoorbirdClient {
private currentSnapshot?: IDoorbirdSnapshot;
constructor(private readonly config: IDoorbirdConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IDoorbirdSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot!);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = DoorbirdMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return this.cloneSnapshot(this.currentSnapshot!);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot!);
}
if (this.hasManualData()) {
this.currentSnapshot = DoorbirdMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
return this.cloneSnapshot(this.currentSnapshot!);
}
if (this.config.host) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot!);
}
this.currentSnapshot = this.offlineSnapshot('No DoorBird HTTP endpoint, client, or snapshot/manual data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IDoorbirdRefreshResult> {
if (this.config.snapshot || this.config.client || this.hasManualData()) {
const snapshot = await this.getSnapshot();
return { success: snapshot.online && !snapshot.error, snapshot, data: { source: snapshot.source || 'runtime' } };
}
if (!this.config.host) {
const snapshot = await this.getSnapshot(true);
return { success: false, snapshot, error: 'DoorBird refresh requires a configured HTTP endpoint, client, or snapshot/manual data.' };
}
try {
const snapshot = await this.fetchSnapshot();
this.currentSnapshot = snapshot;
return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'http' } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = this.offlineSnapshot(error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
}
}
public async validateConnection(): Promise<IDoorbirdInfo> {
return this.getInfo();
}
public async fetchSnapshot(): Promise<IDoorbirdSnapshot> {
const info = await this.getInfo();
const [doorbell, motion, favorites, schedule] = await Promise.all([
this.getDoorbellState().catch(() => undefined),
this.getMotionSensorState().catch(() => undefined),
this.getFavorites().catch(() => undefined),
this.getSchedule().catch((errorArg) => errorArg instanceof DoorbirdApiNotFoundError ? undefined : undefined),
]);
return DoorbirdMapper.toSnapshot({
config: this.config,
rawData: {
info,
status: {
doorbell,
motion,
updatedAt: new Date().toISOString(),
},
favorites,
schedule,
},
online: true,
source: 'http',
});
}
public async getInfo(): Promise<IDoorbirdInfo> {
const data = await this.requestJson<Record<string, unknown>>('/bha-api/info.cgi');
const bha = recordValue(data.BHA);
const version = Array.isArray(bha?.VERSION) ? bha.VERSION[0] : undefined;
if (!version || typeof version !== 'object') {
throw new DoorbirdApiConnectionError('DoorBird info response did not contain BHA.VERSION[0].');
}
return version as IDoorbirdInfo;
}
public async getDoorbellState(): Promise<boolean> {
return this.monitorState('doorbell');
}
public async getMotionSensorState(): Promise<boolean> {
return this.monitorState('motionsensor');
}
public async getFavorites(): Promise<IDoorbirdFavorites> {
return this.requestJson<IDoorbirdFavorites>('/bha-api/favorites.cgi');
}
public async changeFavorite(favoriteArg: IDoorbirdFavoriteChange): Promise<boolean> {
const query: Record<string, string | number | undefined> = {
action: 'save',
type: favoriteArg.type,
title: favoriteArg.title,
value: favoriteArg.value,
id: favoriteArg.id,
};
await this.request('/bha-api/favorites.cgi', { query });
return true;
}
public async deleteFavorite(favoriteTypeArg: string, favoriteIdArg: string): Promise<boolean> {
await this.request('/bha-api/favorites.cgi', {
query: {
action: 'remove',
type: favoriteTypeArg,
id: favoriteIdArg,
},
});
return true;
}
public async getSchedule(): Promise<IDoorbirdScheduleEntry[]> {
return this.requestJson<IDoorbirdScheduleEntry[]>('/bha-api/schedule.cgi');
}
public async changeSchedule(entryArg: IDoorbirdScheduleEntry): Promise<boolean> {
await this.request('/bha-api/schedule.cgi', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(entryArg),
});
return true;
}
public async deleteSchedule(inputArg: string, paramArg = ''): Promise<boolean> {
await this.request('/bha-api/schedule.cgi', {
query: {
action: 'remove',
input: inputArg,
param: paramArg,
},
});
return true;
}
public async getSnapshotImage(cameraIdArg: string = 'live'): Promise<IDoorbirdSnapshotImage> {
const cameraId = cameraIdArg as TDoorbirdCameraKind;
const path = cameraId === 'last_ring'
? '/bha-api/history.cgi?index=1&event=doorbell'
: cameraId === 'last_motion'
? '/bha-api/history.cgi?index=1&event=motionsensor'
: '/bha-api/image.cgi';
const response = await this.request(path, {}, this.config.snapshotTimeoutMs || doorbirdDefaultSnapshotTimeoutMs);
return {
contentType: response.headers.get('content-type') || 'image/jpeg',
data: new Uint8Array(await response.arrayBuffer()),
};
}
public async energizeRelay(relayArg = '1'): Promise<boolean> {
const data = await this.requestJson<Record<string, unknown>>('/bha-api/open-door.cgi', { query: { r: relayArg } });
return this.successFromReturnCode(data);
}
public async turnLightOn(): Promise<boolean> {
const data = await this.requestJson<Record<string, unknown>>('/bha-api/light-on.cgi');
return this.successFromReturnCode(data);
}
public async resetFavorites(): Promise<Array<{ type: string; id: string; success: boolean }>> {
const favorites = await this.getFavorites();
const results: Array<{ type: string; id: string; success: boolean }> = [];
for (const [type, entries] of Object.entries(favorites)) {
for (const id of Object.keys(entries || {})) {
results.push({ type, id, success: await this.deleteFavorite(type, id) });
}
}
return results;
}
public async execute(commandArg: IDoorbirdCommandRequest): Promise<unknown> {
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client && !this.config.host) {
return this.executeWithClient(this.config.client, commandArg);
}
if (commandArg.action === 'refresh') {
return this.refresh();
}
if (commandArg.action === 'stream_source') {
const snapshot = await this.getSnapshot();
const camera = snapshot.cameras.find((cameraArg) => cameraArg.id === commandArg.cameraId) || snapshot.cameras[0];
return {
cameraId: camera?.id,
stillImageUrl: camera?.stillImageUrl,
liveVideoUrl: camera?.liveVideoUrl,
rtspUrl: camera?.rtspUrl,
requiresAuth: true,
};
}
if (!this.config.host) {
throw new DoorbirdApiConnectionError('DoorBird commands require config.host, config.client, or commandExecutor.');
}
if (commandArg.action === 'snapshot_image') {
const image = await this.getSnapshotImage(commandArg.cameraId);
return { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
if (commandArg.action === 'energize_relay') {
return { ok: await this.energizeRelay(commandArg.relay || '1'), relay: commandArg.relay || '1' };
}
if (commandArg.action === 'turn_light_on') {
return { ok: await this.turnLightOn() };
}
if (commandArg.action === 'doorbell_state') {
return { doorbell: await this.getDoorbellState() };
}
if (commandArg.action === 'motion_sensor_state') {
return { motion: await this.getMotionSensorState() };
}
if (commandArg.action === 'favorites') {
return this.getFavorites();
}
if (commandArg.action === 'change_favorite') {
if (!commandArg.favorite) {
throw new DoorbirdApiError('DoorBird change_favorite requires a favorite payload.');
}
return { ok: await this.changeFavorite(commandArg.favorite), favorite: commandArg.favorite };
}
if (commandArg.action === 'delete_favorite') {
if (!commandArg.favoriteType || !commandArg.favoriteId) {
throw new DoorbirdApiError('DoorBird delete_favorite requires favoriteType and favoriteId.');
}
return { ok: await this.deleteFavorite(commandArg.favoriteType, commandArg.favoriteId), type: commandArg.favoriteType, id: commandArg.favoriteId };
}
if (commandArg.action === 'schedule') {
return this.getSchedule();
}
if (commandArg.action === 'change_schedule') {
if (!commandArg.scheduleEntry) {
throw new DoorbirdApiError('DoorBird change_schedule requires scheduleEntry.');
}
return { ok: await this.changeSchedule(commandArg.scheduleEntry), scheduleEntry: commandArg.scheduleEntry };
}
if (commandArg.action === 'delete_schedule') {
if (!commandArg.scheduleInput) {
throw new DoorbirdApiError('DoorBird delete_schedule requires scheduleInput.');
}
return { ok: await this.deleteSchedule(commandArg.scheduleInput, commandArg.scheduleParam), input: commandArg.scheduleInput, param: commandArg.scheduleParam };
}
if (commandArg.action === 'reset_favorites') {
return this.resetFavorites();
}
throw new DoorbirdApiError(`Unsupported DoorBird command: ${commandArg.action}`);
}
public async destroy(): Promise<void> {}
public static async verifySupportedDevice(hostArg: string, portArg = doorbirdDefaultPort, timeoutMsArg = 3000): Promise<boolean> {
const baseUrl = endpointBaseUrl({ host: hostArg, port: portArg });
const url = `${baseUrl}/bha-api/monitor.cgi?check=doorbell`;
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
try {
const response = await globalThis.fetch(url, { signal: abortController.signal });
await response.arrayBuffer().catch(() => undefined);
return response.status === 401;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
private async executeWithClient(clientArg: IDoorbirdClientLike, commandArg: IDoorbirdCommandRequest): Promise<unknown> {
if (commandArg.action === 'refresh') {
return this.refresh();
}
if (commandArg.action === 'snapshot_image' && clientArg.getSnapshotImage) {
const image = await clientArg.getSnapshotImage(commandArg.cameraId);
return { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
if (commandArg.action === 'energize_relay' && clientArg.energizeRelay) {
return clientArg.energizeRelay(commandArg.relay || '1');
}
if (commandArg.action === 'turn_light_on' && clientArg.turnLightOn) {
return clientArg.turnLightOn();
}
if (commandArg.action === 'doorbell_state' && clientArg.getDoorbellState) {
return { doorbell: await clientArg.getDoorbellState() };
}
if (commandArg.action === 'motion_sensor_state' && clientArg.getMotionSensorState) {
return { motion: await clientArg.getMotionSensorState() };
}
if (commandArg.action === 'favorites' && clientArg.favorites) {
return clientArg.favorites();
}
if (commandArg.action === 'change_favorite' && commandArg.favorite && clientArg.changeFavorite) {
return clientArg.changeFavorite(commandArg.favorite);
}
if (commandArg.action === 'delete_favorite' && commandArg.favoriteType && commandArg.favoriteId && clientArg.deleteFavorite) {
return clientArg.deleteFavorite(commandArg.favoriteType, commandArg.favoriteId);
}
if (commandArg.action === 'schedule' && clientArg.schedule) {
return clientArg.schedule();
}
if (commandArg.action === 'change_schedule' && commandArg.scheduleEntry && clientArg.changeSchedule) {
return clientArg.changeSchedule(commandArg.scheduleEntry);
}
if (commandArg.action === 'delete_schedule' && commandArg.scheduleInput && clientArg.deleteSchedule) {
return clientArg.deleteSchedule(commandArg.scheduleInput, commandArg.scheduleParam);
}
if (commandArg.action === 'reset_favorites' && clientArg.resetFavorites) {
return clientArg.resetFavorites();
}
throw new DoorbirdApiConnectionError('DoorBird command is not available on the injected client and no HTTP endpoint or commandExecutor is configured.');
}
private async snapshotFromClient(clientArg: IDoorbirdClientLike): Promise<IDoorbirdSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (isDoorbirdSnapshot(result)) {
return DoorbirdMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return DoorbirdMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
}
const [info, doorbell, motion, favorites, schedule] = await Promise.all([
clientArg.getInfo ? clientArg.getInfo() : undefined,
clientArg.getDoorbellState ? clientArg.getDoorbellState().catch(() => undefined) : undefined,
clientArg.getMotionSensorState ? clientArg.getMotionSensorState().catch(() => undefined) : undefined,
clientArg.favorites ? clientArg.favorites().catch(() => undefined) : undefined,
clientArg.schedule ? clientArg.schedule().catch(() => undefined) : undefined,
]);
if (!info && doorbell === undefined && motion === undefined && !favorites && !schedule) {
throw new DoorbirdApiConnectionError('DoorBird client must expose getSnapshot() or at least one raw API getter.');
}
return DoorbirdMapper.toSnapshot({
config: this.config,
rawData: {
info,
status: { doorbell, motion, updatedAt: new Date().toISOString() },
favorites,
schedule,
},
online: true,
source: 'client',
});
}
private async monitorState(kindArg: 'doorbell' | 'motionsensor'): Promise<boolean> {
const text = await (await this.request('/bha-api/monitor.cgi', { query: { check: kindArg } })).text();
const match = text.match(/=\s*([01])/);
return match ? match[1] === '1' : false;
}
private async requestJson<TValue>(pathArg: string, optionsArg: IDoorbirdRequestOptions = {}): Promise<TValue> {
const response = await this.request(pathArg, { ...optionsArg, headers: { accept: 'application/json', ...optionsArg.headers } });
const text = await response.text();
if (!text.trim()) {
return {} as TValue;
}
try {
return JSON.parse(text) as TValue;
} catch (errorArg) {
throw new DoorbirdApiConnectionError(`Unable to parse DoorBird JSON from ${pathArg}: ${this.errorMessage(errorArg)}`);
}
}
private async request(pathArg: string, optionsArg: IDoorbirdRequestOptions = {}, timeoutMsArg = this.config.timeoutMs || doorbirdDefaultTimeoutMs): Promise<Response> {
if (!this.config.host) {
throw new DoorbirdApiConnectionError('DoorBird HTTP requests require config.host.');
}
const url = this.url(pathArg, optionsArg.query);
const headers = new Headers(optionsArg.headers);
if (optionsArg.auth !== false && (this.config.username || this.config.password !== undefined)) {
headers.set('authorization', this.basicAuthorization());
}
const response = await this.fetchWithTimeout(url, {
method: optionsArg.method || 'GET',
headers,
body: optionsArg.body,
}, timeoutMsArg);
return this.checkedResponse(response, pathArg);
}
private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
if (!responseArg.ok) {
const text = await responseArg.text().catch(() => '');
if (responseArg.status === 401) {
throw new DoorbirdApiAuthorizationError('Please check your DoorBird credentials.');
}
if (responseArg.status === 404) {
throw new DoorbirdApiNotFoundError(`DoorBird endpoint ${pathArg} was not found.`);
}
throw new DoorbirdApiConnectionError(`DoorBird endpoint ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
}
return responseArg;
}
private async fetchWithTimeout(urlArg: string, initArg: RequestInit, timeoutMsArg: number): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
try {
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
} catch (errorArg) {
throw new DoorbirdApiConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
} finally {
clearTimeout(timeout);
}
}
private successFromReturnCode(dataArg: Record<string, unknown>): boolean {
const bha = recordValue(dataArg.BHA);
const code = bha?.RETURNCODE;
return Number(code) === 1 || String(code) === '1';
}
private basicAuthorization(): string {
return `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`, 'utf8').toString('base64')}`;
}
private url(pathArg: string, queryArg?: Record<string, string | number | boolean | undefined>): string {
if (/^https?:\/\//i.test(pathArg)) {
return pathArg;
}
const [path, existingQuery] = pathArg.split('?');
const params = new URLSearchParams(existingQuery || '');
for (const [key, value] of Object.entries(queryArg || {})) {
if (value !== undefined) {
params.set(key, String(value));
}
}
const query = params.toString();
return `${this.baseUrl()}${path.startsWith('/') ? path : `/${path}`}${query ? `?${query}` : ''}`;
}
private baseUrl(): string {
return endpointBaseUrl(this.config);
}
private hasManualData(): boolean {
return Boolean(this.config.rawInfo || this.config.status || this.config.favorites || this.config.schedule || this.config.relays?.length);
}
private offlineSnapshot(errorArg: string): IDoorbirdSnapshot {
return DoorbirdMapper.toSnapshot({
config: this.config,
online: false,
source: 'runtime',
error: errorArg,
});
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IDoorbirdSnapshot): IDoorbirdSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IDoorbirdSnapshot;
}
}
interface IDoorbirdRequestOptions {
method?: 'GET' | 'POST';
query?: Record<string, string | number | boolean | undefined>;
headers?: HeadersInit;
body?: BodyInit;
auth?: boolean;
}
const endpointBaseUrl = (configArg: Pick<IDoorbirdConfig, 'host' | 'port' | 'protocol' | 'ssl' | 'tls'>): string => {
const host = configArg.host || 'localhost';
if (/^https?:\/\//i.test(host)) {
const url = new URL(host);
return `${url.protocol}//${url.host}`;
}
const protocol: TDoorbirdProtocol = configArg.protocol || (configArg.ssl || configArg.tls ? 'https' : 'http');
const defaultPort = protocol === 'https' ? 443 : doorbirdDefaultPort;
const port = configArg.port && configArg.port !== defaultPort ? `:${configArg.port}` : '';
return `${protocol}://${hostForUrl(host)}${port}`;
};
const hostForUrl = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
};
const isDoorbirdSnapshot = (valueArg: unknown): valueArg is IDoorbirdSnapshot => {
return Boolean(valueArg && typeof valueArg === 'object' && 'deviceInfo' in valueArg && 'cameras' in valueArg && 'buttons' in valueArg);
};
@@ -0,0 +1,153 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IDoorbirdConfig, IDoorbirdInfo, IDoorbirdSnapshot, IDoorbirdStatus, TDoorbirdProtocol } from './doorbird.types.js';
import { doorbirdDefaultHttpsPort, doorbirdDefaultPort, doorbirdDefaultTimeoutMs, doorbirdManufacturer } from './doorbird.types.js';
export class DoorbirdConfigFlow implements IConfigFlow<IDoorbirdConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDoorbirdConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect DoorBird',
description: 'Configure a local DoorBird HTTP endpoint discovered by zeroconf or entered manually.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'username', label: 'Username', type: 'text', required: true },
{ name: 'password', label: 'Password', type: 'password', required: true },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'events', label: 'Events (comma separated)', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDoorbirdConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringValue(metadata.url) || candidateArg.host);
const snapshot = snapshotValue(metadata.snapshot);
const rawInfo = infoValue(metadata.rawInfo);
const status = statusValue(metadata.status);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.deviceInfo.host;
const protocol = parsed?.protocol || (this.booleanValue(valuesArg.ssl) ?? booleanMetadata(metadata, 'ssl') ? 'https' : 'http');
const port = this.numberValue(valuesArg.port) || candidateArg.port || parsed?.port || snapshot?.deviceInfo.port || (protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort);
const hasManualData = Boolean(snapshot || rawInfo || status || metadata.client);
const username = this.stringValue(valuesArg.username) || stringMetadata(metadata, 'username');
const password = this.stringValue(valuesArg.password) || stringMetadata(metadata, 'password');
if (!host && !hasManualData) {
return { kind: 'error', title: 'DoorBird setup failed', error: 'DoorBird host, injected client, snapshot, or raw info is required.' };
}
if (!validPort(port)) {
return { kind: 'error', title: 'DoorBird setup failed', error: 'DoorBird port must be between 1 and 65535.' };
}
if (host && !hasManualData && (!username || password === undefined)) {
return { kind: 'error', title: 'DoorBird setup failed', error: 'DoorBird username and password are required for live local HTTP setup.' };
}
const config: IDoorbirdConfig = {
host,
port,
protocol,
ssl: protocol === 'https',
username,
password,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.deviceInfo.name || stringMetadata(metadata, 'name'),
manufacturer: candidateArg.manufacturer || snapshot?.deviceInfo.manufacturer || doorbirdManufacturer,
model: candidateArg.model || snapshot?.deviceInfo.model,
macAddress: candidateArg.macAddress || snapshot?.deviceInfo.macAddress || stringMetadata(metadata, 'macaddress') || stringMetadata(metadata, 'macAddress'),
uniqueId: candidateArg.id || snapshot?.deviceInfo.id || candidateArg.macAddress || (host ? `${host}:${port}` : undefined),
timeoutMs: doorbirdDefaultTimeoutMs,
events: this.eventsValue(valuesArg.events) || this.eventsValue(metadata.events) || ['doorbell', 'motion'],
snapshot,
rawInfo,
status,
client: metadata.client as IDoorbirdConfig['client'],
commandExecutor: metadata.commandExecutor as IDoorbirdConfig['commandExecutor'],
};
return { kind: 'done', title: 'DoorBird configured', config };
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
}
private eventsValue(valueArg: unknown): string[] | undefined {
if (Array.isArray(valueArg)) {
return valueArg.map((eventArg) => this.stringValue(eventArg)).filter(Boolean) as string[];
}
const value = this.stringValue(valueArg);
if (!value) {
return undefined;
}
return value.split(',').map((eventArg) => eventArg.trim()).filter(Boolean);
}
}
const parseEndpoint = (valueArg: string | undefined): { protocol: TDoorbirdProtocol; host: string; port?: number } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
};
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IDoorbirdSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'deviceInfo' in valueArg && 'cameras' in valueArg ? valueArg as IDoorbirdSnapshot : undefined;
};
const infoValue = (valueArg: unknown): IDoorbirdInfo | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IDoorbirdInfo : undefined;
};
const statusValue = (valueArg: unknown): IDoorbirdStatus | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IDoorbirdStatus : undefined;
};
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const booleanMetadata = (metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'boolean' ? value : undefined;
};
const validPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
@@ -1,31 +1,108 @@
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, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { DoorbirdClient } from './doorbird.classes.client.js';
import { DoorbirdConfigFlow } from './doorbird.classes.configflow.js';
import { createDoorbirdDiscoveryDescriptor } from './doorbird.discovery.js';
import { DoorbirdMapper } from './doorbird.mapper.js';
import type { IDoorbirdConfig } from './doorbird.types.js';
import { doorbirdDomain } from './doorbird.types.js';
export class HomeAssistantDoorbirdIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "doorbird",
displayName: "DoorBird",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/doorbird",
"upstreamDomain": "doorbird",
"integrationType": "device",
"iotClass": "local_push",
"requirements": [
"DoorBirdPy==3.0.11"
],
"dependencies": [
"http",
"repairs"
],
"afterDependencies": [],
"codeowners": [
"@oblogic7",
"@bdraco",
"@flacjacket"
]
},
});
export class DoorbirdIntegration extends BaseIntegration<IDoorbirdConfig> {
public readonly domain = doorbirdDomain;
public readonly displayName = 'DoorBird';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDoorbirdDiscoveryDescriptor();
public readonly configFlow = new DoorbirdConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/doorbird',
upstreamDomain: doorbirdDomain,
integrationType: 'device',
iotClass: 'local_push',
requirements: ['DoorBirdPy==3.0.11'],
dependencies: ['http', 'repairs'],
afterDependencies: [] as string[],
codeowners: ['@oblogic7', '@bdraco', '@flacjacket'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/doorbird',
zeroconf: [{ type: '_axis-video._tcp.local.', properties: { macaddress: '1ccae3*' } }],
discovery: {
manual: true,
zeroconf: '_axis-video._tcp.local. advertisements with macaddress OUI 1CCAE3 are recognized.',
},
runtime: {
type: 'control-runtime',
polling: 'local HTTP DoorBird LAN API /bha-api/info.cgi, /bha-api/monitor.cgi?check=doorbell|motionsensor, and snapshot endpoints',
services: ['snapshot', 'status', 'refresh', 'energize_relay', 'turn_light_on', 'favorites', 'schedule'],
controls: true,
},
localApi: {
implemented: [
'DoorBird local HTTP info/status/snapshot API compatible with DoorBirdPy 3.0.11 endpoints used by Home Assistant',
'zeroconf/manual discovery and config flow output for local HTTP setup',
'relay, IR light, favorites, and schedule HTTP command shapes where represented by the upstream integration',
],
explicitUnsupported: [
'Home Assistant HTTP callback view hosting for DoorBird favorite events',
'fake live event or command success without a configured HTTP endpoint, injected client, or command executor',
],
},
};
public async setup(configArg: IDoorbirdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DoorbirdRuntime(new DoorbirdClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDoorbirdIntegration extends DoorbirdIntegration {}
class DoorbirdRuntime implements IIntegrationRuntime {
public domain = doorbirdDomain;
constructor(private readonly client: DoorbirdClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DoorbirdMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DoorbirdMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
void handlerArg;
throw new Error('DoorBird live event subscription is not implemented in this TypeScript port; use status polling or configure DoorBird HTTP favorites against a real callback endpoint.');
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === doorbirdDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
const snapshot = await this.client.getSnapshot();
return { success: true, data: requestArg.service === 'status' ? snapshot.status : snapshot };
}
if (requestArg.domain === doorbirdDomain && requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot || result.data };
}
const snapshot = await this.client.getSnapshot();
const command = DoorbirdMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported DoorBird service: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
const ok = data && typeof data === 'object' && 'ok' in data ? Boolean((data as { ok?: unknown }).ok) : true;
return { success: ok, data };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,266 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { DoorbirdMapper } from './doorbird.mapper.js';
import type { IDoorbirdManualEntry, IDoorbirdMdnsRecord, TDoorbirdProtocol } from './doorbird.types.js';
import { doorbirdDefaultHttpsPort, doorbirdDefaultPort, doorbirdDomain, doorbirdManufacturer, doorbirdMdnsType, doorbirdOui } from './doorbird.types.js';
export class DoorbirdManualMatcher implements IDiscoveryMatcher<IDoorbirdManualEntry> {
public id = 'doorbird-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual DoorBird local HTTP setup entries.';
public async matches(inputArg: IDoorbirdManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromManual(inputArg);
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || snapshotValue(metadata.snapshot);
const mac = DoorbirdMapper.normalizeMac(inputArg.macAddress || stringMetadata(metadata, 'macAddress') || stringMetadata(metadata, 'macaddress'));
const hasManualData = Boolean(snapshot || inputArg.rawInfo || inputArg.status || inputArg.client || metadata.client);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(endpoint.host || metadata.doorbird || hasManualData || mac.startsWith(doorbirdOui) || text.includes('doorbird'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain DoorBird setup hints.' };
}
const port = endpoint.port || doorbirdDefaultPort;
const id = mac || inputArg.id || snapshot?.deviceInfo.id || snapshot?.deviceInfo.macAddress || (endpoint.host ? `${endpoint.host}:${port}` : undefined);
return {
matched: true,
confidence: endpoint.host || hasManualData || mac.startsWith(doorbirdOui) ? 'high' : 'medium',
reason: endpoint.host ? 'Manual entry contains a local DoorBird HTTP endpoint.' : 'Manual entry contains DoorBird metadata.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: doorbirdDomain,
id,
host: endpoint.host,
port,
name: inputArg.name || snapshot?.deviceInfo.name || endpoint.host,
manufacturer: inputArg.manufacturer || snapshot?.deviceInfo.manufacturer || doorbirdManufacturer,
model: inputArg.model || snapshot?.deviceInfo.model,
macAddress: mac || snapshot?.deviceInfo.macAddress,
metadata: {
...metadata,
doorbird: true,
protocol: endpoint.protocol,
ssl: endpoint.protocol === 'https',
url: endpoint.url,
username: inputArg.username || stringMetadata(metadata, 'username'),
password: inputArg.password || stringMetadata(metadata, 'password'),
snapshot,
rawInfo: inputArg.rawInfo || metadata.rawInfo,
status: inputArg.status || metadata.status,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
manualSupported: true,
},
};
}
}
export class DoorbirdMdnsMatcher implements IDiscoveryMatcher<IDoorbirdMdnsRecord> {
public id = 'doorbird-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize DoorBird zeroconf records on _axis-video._tcp.local. with DoorBird OUI macaddress.';
public async matches(recordArg: IDoorbirdMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const properties = { ...recordArg.txt, ...recordArg.properties };
const mac = DoorbirdMapper.normalizeMac(valueForKey(properties, 'macaddress') || valueForKey(properties, 'macAddress') || valueForKey(properties, 'mac'));
const serviceMatch = type === doorbirdMdnsType;
const ouiMatch = mac.startsWith(doorbirdOui);
if (!serviceMatch || !ouiMatch) {
return { matched: false, confidence: serviceMatch ? 'medium' : 'low', reason: 'mDNS record does not match Home Assistant DoorBird zeroconf criteria.' };
}
const ipAddress = recordArg.ipAddress || firstAddress(recordArg.addresses);
if (ipAddress && (!isIpv4(ipAddress) || isIpv4LinkLocal(ipAddress))) {
return { matched: false, confidence: 'medium', reason: 'DoorBird zeroconf candidate is not a usable non-link-local IPv4 address.' };
}
const host = recordArg.host || ipAddress || recordArg.hostname;
const port = recordArg.port || numberString(valueForKey(properties, 'port')) || doorbirdDefaultPort;
const name = cleanMdnsName(recordArg.name || recordArg.hostname || valueForKey(properties, 'name')) || 'DoorBird';
return {
matched: true,
confidence: host ? 'certain' : 'high',
reason: 'mDNS record matches DoorBird _axis-video zeroconf type and OUI macaddress.',
normalizedDeviceId: mac,
candidate: {
source: 'mdns',
integrationDomain: doorbirdDomain,
id: mac,
host,
port,
name,
manufacturer: doorbirdManufacturer,
macAddress: mac,
metadata: {
doorbird: true,
mdnsName: recordArg.name,
mdnsType: type,
txt: properties,
macaddress: mac,
protocol: 'http' satisfies TDoorbirdProtocol,
ssl: false,
discoveryProtocol: 'zeroconf',
},
},
metadata: { mdnsType: type, macaddress: mac, zeroconf: true },
};
}
}
export class DoorbirdCandidateValidator implements IDiscoveryValidator {
public id = 'doorbird-candidate-validator';
public description = 'Validate DoorBird candidates have zeroconf/manual metadata and a local setup source.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== doorbirdDomain) {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not DoorBird.` };
}
const metadata = candidateArg.metadata || {};
const mdnsType = normalizeMdnsType(stringMetadata(metadata, 'mdnsType') || '');
const mac = DoorbirdMapper.normalizeMac(candidateArg.macAddress || candidateArg.id || stringMetadata(metadata, 'macaddress') || stringMetadata(metadata, 'macAddress'));
const snapshot = snapshotValue(metadata.snapshot);
const endpoint = endpointFromCandidate(candidateArg);
const text = [candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === doorbirdDomain
|| Boolean(metadata.doorbird)
|| mdnsType === doorbirdMdnsType && mac.startsWith(doorbirdOui)
|| mac.startsWith(doorbirdOui)
|| text.includes('doorbird')
|| Boolean(snapshot || metadata.client || metadata.rawInfo);
const hasUsableSource = Boolean(endpoint.host || snapshot || metadata.client || metadata.rawInfo);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'DoorBird candidate lacks a usable host, injected client, snapshot, or raw info.' : 'Candidate is not DoorBird.',
normalizedDeviceId: candidateArg.id || mac || (endpoint.host ? `${endpoint.host}:${endpoint.port || doorbirdDefaultPort}` : undefined),
};
}
if (endpoint.port !== undefined && !isValidPort(endpoint.port)) {
return { matched: false, confidence: 'low', reason: 'DoorBird candidate has an invalid port.' };
}
const id = candidateArg.id || mac || snapshot?.deviceInfo.id || (endpoint.host ? `${endpoint.host}:${endpoint.port || doorbirdDefaultPort}` : undefined);
return {
matched: true,
confidence: id && endpoint.host ? 'certain' : endpoint.host ? 'high' : 'medium',
reason: 'Candidate has DoorBird metadata and a local endpoint, client, snapshot, or raw info.',
normalizedDeviceId: id,
candidate: {
...candidateArg,
integrationDomain: doorbirdDomain,
id,
host: endpoint.host,
port: endpoint.port || doorbirdDefaultPort,
manufacturer: candidateArg.manufacturer || doorbirdManufacturer,
macAddress: mac || candidateArg.macAddress,
metadata: {
...metadata,
doorbird: true,
protocol: endpoint.protocol,
ssl: endpoint.protocol === 'https',
url: endpoint.url,
},
},
};
}
}
export const createDoorbirdDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: doorbirdDomain, displayName: 'DoorBird' })
.addMatcher(new DoorbirdManualMatcher())
.addMatcher(new DoorbirdMdnsMatcher())
.addValidator(new DoorbirdCandidateValidator());
};
const endpointFromManual = (inputArg: IDoorbirdManualEntry): { protocol: TDoorbirdProtocol; host?: string; port?: number; url?: string } => {
const parsed = parseEndpoint(inputArg.url || inputArg.host);
if (parsed) {
return parsed;
}
const protocol: TDoorbirdProtocol = inputArg.protocol || (inputArg.ssl || inputArg.tls ? 'https' : 'http');
const port = inputArg.port || (protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort);
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TDoorbirdProtocol; host?: string; port?: number; url?: string } => {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(stringMetadata(metadata, 'url') || candidateArg.host);
if (parsed) {
return parsed;
}
const protocol: TDoorbirdProtocol = stringMetadata(metadata, 'protocol') === 'https' || metadata.ssl === true ? 'https' : 'http';
const port = candidateArg.port || (protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort);
return { protocol, host: candidateArg.host, port, url: candidateArg.host ? `${protocol}://${candidateArg.host}:${port}` : undefined };
};
const parseEndpoint = (valueArg: string | undefined): { protocol: TDoorbirdProtocol; host: string; port: number; url: string } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
const protocol: TDoorbirdProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort;
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown) => {
return valueArg && typeof valueArg === 'object' && 'deviceInfo' in valueArg && 'cameras' in valueArg ? valueArg as import('./doorbird.types.js').IDoorbirdSnapshot : undefined;
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanMdnsName = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/\._axis-video\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const firstAddress = (addressesArg: string[] | undefined): string | undefined => addressesArg?.find((addressArg) => isIpv4(addressArg) && !isIpv4LinkLocal(addressArg)) || addressesArg?.[0];
const isIpv4 = (valueArg: string): boolean => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(valueArg) && valueArg.split('.').every((partArg) => Number(partArg) >= 0 && Number(partArg) <= 255);
const isIpv4LinkLocal = (valueArg: string): boolean => valueArg.startsWith('169.254.');
const numberString = (valueArg: string | undefined): number | undefined => {
if (!valueArg) {
return undefined;
}
const value = Number(valueArg);
return Number.isFinite(value) ? Math.round(value) : undefined;
};
const isValidPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
+611
View File
@@ -0,0 +1,611 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IDoorbirdButtonSnapshot,
IDoorbirdCameraSnapshot,
IDoorbirdCommandRequest,
IDoorbirdConfig,
IDoorbirdDeviceInfo,
IDoorbirdEventSnapshot,
IDoorbirdFavoriteChange,
IDoorbirdInfo,
IDoorbirdRawData,
IDoorbirdSnapshot,
IDoorbirdStatus,
TDoorbirdCameraKind,
TDoorbirdProtocol,
TDoorbirdSnapshotSource,
} from './doorbird.types.js';
import {
doorbirdDefaultHttpsPort,
doorbirdDefaultPort,
doorbirdDefaultRtspPort,
doorbirdDomain,
doorbirdManufacturer,
} from './doorbird.types.js';
interface IDoorbirdSnapshotOptions {
config: IDoorbirdConfig;
rawData?: Partial<IDoorbirdRawData>;
online?: boolean;
source?: TDoorbirdSnapshotSource;
error?: string;
}
interface IDoorbirdEntityDefinition {
key: string;
platform: TEntityPlatform;
name: string;
state: unknown;
available: boolean;
attributes?: Record<string, unknown>;
}
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
export class DoorbirdMapper {
public static toSnapshot(optionsArg: IDoorbirdSnapshotOptions): IDoorbirdSnapshot {
if (optionsArg.config.snapshot && !optionsArg.rawData) {
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot');
}
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
const online = optionsArg.online ?? optionsArg.config.online ?? this.hasRawData(rawData);
const updatedAt = new Date().toISOString();
const deviceInfo = this.deviceInfo(optionsArg.config, rawData.info, online);
const snapshot: IDoorbirdSnapshot = {
deviceInfo,
cameras: this.cameras(optionsArg.config, deviceInfo, online),
buttons: this.buttons(optionsArg.config, deviceInfo, online),
events: this.events(optionsArg.config, deviceInfo, online),
status: this.status(rawData.status),
rawInfo: rawData.info,
favorites: rawData.favorites,
schedule: rawData.schedule,
online,
updatedAt,
source: optionsArg.source || (this.hasRawData(rawData) ? 'manual' : 'runtime'),
error: optionsArg.error,
};
return this.normalizeSnapshot(snapshot, optionsArg.config, snapshot.source || 'runtime');
}
public static toDevices(snapshotArg: IDoorbirdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
{ id: 'doorbell', capability: 'sensor', name: 'Doorbell', readable: true, writable: false },
{ id: 'motion', capability: 'sensor', name: 'Motion', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
{ featureId: 'doorbell', value: snapshotArg.status.doorbell ?? null, updatedAt },
{ featureId: 'motion', value: snapshotArg.status.motion ?? null, updatedAt },
];
for (const camera of snapshotArg.cameras) {
features.push({ id: `camera_${this.slug(camera.id)}`, capability: 'camera', name: camera.name, readable: true, writable: false });
state.push({
featureId: `camera_${this.slug(camera.id)}`,
value: {
stillImageUrl: camera.stillImageUrl || null,
liveVideoUrl: camera.liveVideoUrl || null,
rtspUrl: camera.rtspUrl || null,
},
updatedAt,
});
}
for (const button of snapshotArg.buttons) {
features.push({ id: `button_${this.slug(button.key)}`, capability: 'switch', name: button.name, readable: false, writable: true });
}
return [{
id: this.deviceId(snapshotArg),
integrationDomain: doorbirdDomain,
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.deviceInfo.manufacturer || doorbirdManufacturer,
model: snapshotArg.deviceInfo.model || 'DoorBird door station',
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
macAddress: snapshotArg.deviceInfo.macAddress,
primaryMacAddress: snapshotArg.deviceInfo.primaryMacAddress,
wifiMacAddress: snapshotArg.deviceInfo.wifiMacAddress,
firmware: snapshotArg.deviceInfo.firmware,
buildNumber: snapshotArg.deviceInfo.buildNumber,
swVersion: snapshotArg.deviceInfo.swVersion,
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
baseUrl: snapshotArg.deviceInfo.baseUrl,
html5ViewerUrl: snapshotArg.deviceInfo.html5ViewerUrl,
rtspUrl: snapshotArg.deviceInfo.rtspUrl,
relays: snapshotArg.deviceInfo.relays,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IDoorbirdSnapshot): IIntegrationEntity[] {
const deviceId = this.deviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const baseName = this.deviceName(snapshotArg);
const usedIds = new Map<string, number>();
const entities: IIntegrationEntity[] = [];
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'online',
platform: 'binary_sensor',
name: `${baseName} Online`,
state: snapshotArg.online ? 'on' : 'off',
available: true,
attributes: { deviceClass: 'connectivity', source: snapshotArg.source, error: snapshotArg.error },
}));
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'doorbell',
platform: 'binary_sensor',
name: `${baseName} Doorbell`,
state: snapshotArg.status.doorbell ? 'on' : 'off',
available: snapshotArg.online && snapshotArg.status.doorbell !== undefined,
attributes: { deviceClass: 'occupancy', raw: snapshotArg.status.rawDoorbell },
}));
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'motion',
platform: 'binary_sensor',
name: `${baseName} Motion`,
state: snapshotArg.status.motion ? 'on' : 'off',
available: snapshotArg.online && snapshotArg.status.motion !== undefined,
attributes: { deviceClass: 'motion', raw: snapshotArg.status.rawMotion },
}));
for (const camera of snapshotArg.cameras) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: `camera_${camera.id}`,
platform: 'camera' as TEntityPlatform,
name: `${baseName} ${camera.name}`,
state: camera.available === false || !snapshotArg.online ? 'unavailable' : 'idle',
available: snapshotArg.online && camera.available !== false,
attributes: this.cleanAttributes({
cameraId: camera.id,
kind: camera.kind,
stillImageUrl: camera.stillImageUrl,
liveVideoUrl: camera.liveVideoUrl,
streamSourceUrl: camera.liveVideoUrl || camera.rtspUrl || camera.stillImageUrl,
rtspUrl: camera.rtspUrl,
historyEvent: camera.historyEvent,
historyIndex: camera.historyIndex,
supportedFeatures: camera.id === 'live' ? ['snapshot', 'stream'] : ['snapshot'],
requiresAuth: true,
}),
}));
}
for (const button of snapshotArg.buttons) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: button.key,
platform: 'button',
name: `${baseName} ${button.name}`,
state: 'idle',
available: snapshotArg.online && button.available !== false,
attributes: this.cleanAttributes({
action: button.action,
relay: button.relay,
entityCategory: button.entityCategory,
}),
}));
}
for (const event of snapshotArg.events) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: `event_${event.key}`,
platform: 'event' as TEntityPlatform,
name: `${baseName} ${event.name}`,
state: 'idle',
available: snapshotArg.online && event.available !== false,
attributes: {
eventType: event.eventType,
doorbirdEvent: event.doorbirdEvent,
eventTypes: [event.eventType === 'motion' ? 'motion' : 'ring'],
},
}));
}
return entities;
}
public static commandForService(snapshotArg: IDoorbirdSnapshot, requestArg: IServiceCallRequest): IDoorbirdCommandRequest | undefined {
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
return { action: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: this.cameraIdFromRequest(snapshotArg, requestArg) };
}
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
return { action: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: this.cameraIdFromRequest(snapshotArg, requestArg) };
}
if (requestArg.domain === 'button' && requestArg.service === 'press') {
const button = this.buttonFromRequest(snapshotArg, requestArg);
if (!button) {
return undefined;
}
return this.commandFromButton(button, requestArg);
}
if (requestArg.domain !== doorbirdDomain) {
return undefined;
}
if (requestArg.service === 'refresh') {
return { action: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'snapshot' || cameraSnapshotServices.has(requestArg.service)) {
return { action: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: this.cameraIdFromRequest(snapshotArg, requestArg) };
}
if (requestArg.service === 'stream_source' || cameraStreamServices.has(requestArg.service)) {
return { action: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: this.cameraIdFromRequest(snapshotArg, requestArg) };
}
if (['energize_relay', 'open_door', 'relay', 'press_relay'].includes(requestArg.service)) {
return { action: 'energize_relay', service: requestArg.service, target: requestArg.target, data: requestArg.data, relay: this.relayFromRequest(snapshotArg, requestArg) || '1' };
}
if (requestArg.service === 'turn_light_on' || requestArg.service === 'light_on') {
return { action: 'turn_light_on', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'doorbell_state') {
return { action: 'doorbell_state', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'motion_sensor_state') {
return { action: 'motion_sensor_state', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'favorites') {
return { action: 'favorites', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'change_favorite') {
const favorite = this.favoriteChangeFromData(requestArg.data);
return favorite ? { action: 'change_favorite', service: requestArg.service, target: requestArg.target, data: requestArg.data, favorite } : undefined;
}
if (requestArg.service === 'delete_favorite') {
const favoriteType = this.stringValue(requestArg.data?.type ?? requestArg.data?.favoriteType);
const favoriteId = this.stringValue(requestArg.data?.id ?? requestArg.data?.favoriteId);
return favoriteType && favoriteId ? { action: 'delete_favorite', service: requestArg.service, target: requestArg.target, data: requestArg.data, favoriteType, favoriteId } : undefined;
}
if (requestArg.service === 'schedule') {
return { action: 'schedule', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'change_schedule') {
const scheduleEntry = requestArg.data?.scheduleEntry || requestArg.data?.entry;
return scheduleEntry && typeof scheduleEntry === 'object' && !Array.isArray(scheduleEntry)
? { action: 'change_schedule', service: requestArg.service, target: requestArg.target, data: requestArg.data, scheduleEntry: scheduleEntry as IDoorbirdCommandRequest['scheduleEntry'] }
: undefined;
}
if (requestArg.service === 'delete_schedule') {
const scheduleInput = this.stringValue(requestArg.data?.input ?? requestArg.data?.event);
return scheduleInput ? { action: 'delete_schedule', service: requestArg.service, target: requestArg.target, data: requestArg.data, scheduleInput, scheduleParam: this.stringValue(requestArg.data?.param) } : undefined;
}
if (requestArg.service === 'reset_favorites') {
return { action: 'reset_favorites', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
return undefined;
}
public static deviceId(snapshotArg: IDoorbirdSnapshot): string {
return `${doorbirdDomain}.device.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || doorbirdDomain;
}
public static normalizeMac(valueArg: string | undefined): string {
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
return cleaned.length === 12 ? cleaned : '';
}
private static normalizeSnapshot(snapshotArg: IDoorbirdSnapshot, configArg: IDoorbirdConfig, sourceArg: TDoorbirdSnapshotSource): IDoorbirdSnapshot {
const rawData = this.rawData(configArg, { info: snapshotArg.rawInfo, status: snapshotArg.status, favorites: snapshotArg.favorites, schedule: snapshotArg.schedule });
const derivedDeviceInfo = this.deviceInfo(configArg, rawData.info, snapshotArg.online);
const deviceInfo: IDoorbirdDeviceInfo = {
...derivedDeviceInfo,
...snapshotArg.deviceInfo,
relays: snapshotArg.deviceInfo.relays?.length ? snapshotArg.deviceInfo.relays : derivedDeviceInfo.relays,
online: snapshotArg.online,
};
return {
...snapshotArg,
deviceInfo,
cameras: snapshotArg.cameras?.length ? snapshotArg.cameras : this.cameras(configArg, deviceInfo, snapshotArg.online),
buttons: snapshotArg.buttons?.length ? snapshotArg.buttons : this.buttons(configArg, deviceInfo, snapshotArg.online),
events: snapshotArg.events?.length ? snapshotArg.events : this.events(configArg, deviceInfo, snapshotArg.online),
status: this.status(snapshotArg.status),
rawInfo: rawData.info,
favorites: snapshotArg.favorites || rawData.favorites,
schedule: snapshotArg.schedule || rawData.schedule,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
};
}
private static rawData(configArg: IDoorbirdConfig, rawDataArg?: Partial<IDoorbirdRawData>): Partial<IDoorbirdRawData> {
return this.cleanAttributes({
...configArg.rawInfo ? { info: configArg.rawInfo } : {},
...configArg.status ? { status: configArg.status } : {},
...configArg.favorites ? { favorites: configArg.favorites } : {},
...configArg.schedule ? { schedule: configArg.schedule } : {},
...rawDataArg,
info: rawDataArg?.info || configArg.rawInfo,
status: rawDataArg?.status || configArg.status,
favorites: rawDataArg?.favorites || configArg.favorites,
schedule: rawDataArg?.schedule || configArg.schedule,
});
}
private static deviceInfo(configArg: IDoorbirdConfig, infoArg: IDoorbirdInfo | undefined, onlineArg: boolean): IDoorbirdDeviceInfo {
const endpoint = this.endpoint(configArg);
const primaryMacAddress = this.normalizeMac(this.stringValue(infoArg?.PRIMARY_MAC_ADDR));
const wifiMacAddress = this.normalizeMac(this.stringValue(infoArg?.WIFI_MAC_ADDR));
const macAddress = this.normalizeMac(configArg.macAddress) || primaryMacAddress || wifiMacAddress;
const firmware = this.stringValue(infoArg?.FIRMWARE);
const buildNumber = this.stringValue(infoArg?.BUILD_NUMBER);
const model = configArg.model || this.stringValue(infoArg?.['DEVICE-TYPE']) || this.stringValue(infoArg?.DEVICE_TYPE);
const relays = this.relays(configArg, infoArg);
const name = configArg.name || model || endpoint.host || 'DoorBird';
return this.cleanAttributes({
id: configArg.uniqueId || macAddress || (endpoint.host ? `${endpoint.host}:${endpoint.port || doorbirdDefaultPort}` : undefined) || name,
name,
manufacturer: configArg.manufacturer || doorbirdManufacturer,
model,
firmware,
buildNumber,
swVersion: firmware || buildNumber ? [firmware, buildNumber].filter(Boolean).join(' ') : undefined,
macAddress: macAddress || undefined,
primaryMacAddress: primaryMacAddress || undefined,
wifiMacAddress: wifiMacAddress || undefined,
host: endpoint.host,
port: endpoint.port,
protocol: endpoint.protocol,
baseUrl: endpoint.baseUrl,
rtspUrl: endpoint.host ? this.authenticatedUrl(configArg, '/mpeg/media.amp', configArg.rtspPort || doorbirdDefaultRtspPort, 'rtsp') : undefined,
html5ViewerUrl: endpoint.host ? this.authenticatedUrl(configArg, '/bha-api/view.html') : undefined,
relays,
online: onlineArg,
});
}
private static status(statusArg: IDoorbirdStatus | undefined): IDoorbirdStatus {
return this.cleanAttributes({
doorbell: statusArg?.doorbell,
motion: statusArg?.motion,
rawDoorbell: statusArg?.rawDoorbell,
rawMotion: statusArg?.rawMotion,
updatedAt: statusArg?.updatedAt || new Date().toISOString(),
});
}
private static cameras(configArg: IDoorbirdConfig, deviceInfoArg: IDoorbirdDeviceInfo, onlineArg: boolean): IDoorbirdCameraSnapshot[] {
const canFetchImage = Boolean(configArg.host || configArg.client?.getSnapshotImage || configArg.commandExecutor);
return [
{
id: 'live',
name: 'Live',
kind: 'live',
stillImageUrl: deviceInfoArg.host ? this.authenticatedUrl(configArg, '/bha-api/image.cgi') : undefined,
liveVideoUrl: deviceInfoArg.host ? this.authenticatedUrl(configArg, '/bha-api/video.cgi') : undefined,
rtspUrl: deviceInfoArg.host ? this.authenticatedUrl(configArg, '/mpeg/media.amp', configArg.rtspPort || doorbirdDefaultRtspPort, 'rtsp') : undefined,
available: onlineArg && canFetchImage,
},
{
id: 'last_ring',
name: 'Last Ring',
kind: 'last_ring',
stillImageUrl: deviceInfoArg.host ? this.authenticatedUrl(configArg, '/bha-api/history.cgi?index=1&event=doorbell') : undefined,
historyEvent: 'doorbell',
historyIndex: 1,
available: onlineArg && canFetchImage,
},
{
id: 'last_motion',
name: 'Last Motion',
kind: 'last_motion',
stillImageUrl: deviceInfoArg.host ? this.authenticatedUrl(configArg, '/bha-api/history.cgi?index=1&event=motionsensor') : undefined,
historyEvent: 'motionsensor',
historyIndex: 1,
available: onlineArg && canFetchImage,
},
];
}
private static buttons(configArg: IDoorbirdConfig, deviceInfoArg: IDoorbirdDeviceInfo, onlineArg: boolean): IDoorbirdButtonSnapshot[] {
const relayAvailable = onlineArg && this.hasCommandTransport(configArg, 'energize_relay');
const lightAvailable = onlineArg && this.hasCommandTransport(configArg, 'turn_light_on');
const favoritesAvailable = onlineArg && this.hasCommandTransport(configArg, 'reset_favorites');
const buttons: IDoorbirdButtonSnapshot[] = deviceInfoArg.relays.map((relayArg) => ({
key: `relay_${this.slug(relayArg)}`,
name: `Relay ${relayArg}`,
action: 'energize_relay',
relay: relayArg,
available: relayAvailable,
}));
buttons.push({ key: 'ir_light', name: 'IR Light', action: 'turn_light_on', available: lightAvailable });
buttons.push({ key: 'reset_favorites', name: 'Reset Favorites', action: 'reset_favorites', entityCategory: 'config', available: favoritesAvailable });
return buttons;
}
private static events(configArg: IDoorbirdConfig, deviceInfoArg: IDoorbirdDeviceInfo, onlineArg: boolean): IDoorbirdEventSnapshot[] {
const configuredEvents = configArg.events?.length ? configArg.events : ['doorbell', 'motion'];
const base = this.slug(deviceInfoArg.name || doorbirdDomain);
return configuredEvents.map((eventArg) => {
const eventType = eventArg === 'motionsensor' ? 'motion' : eventArg;
return {
key: this.slug(eventArg),
name: eventType === 'motion' ? 'Motion' : this.title(eventType),
eventType,
doorbirdEvent: `${base}_${this.slug(eventArg)}`,
available: onlineArg,
};
});
}
private static entity(snapshotArg: IDoorbirdSnapshot, usedIdsArg: Map<string, number>, deviceIdArg: string, uniqueBaseArg: string, definitionArg: IDoorbirdEntityDefinition): IIntegrationEntity {
const baseId = `${String(definitionArg.platform)}.${this.slug(definitionArg.name) || this.slug(definitionArg.key)}`;
const seen = usedIdsArg.get(baseId) || 0;
usedIdsArg.set(baseId, seen + 1);
return {
id: seen ? `${baseId}_${seen + 1}` : baseId,
uniqueId: `${doorbirdDomain}_${uniqueBaseArg}_${this.slug(definitionArg.key)}`,
integrationDomain: doorbirdDomain,
deviceId: deviceIdArg,
platform: definitionArg.platform,
name: definitionArg.name,
state: definitionArg.state,
attributes: this.cleanAttributes({ key: definitionArg.key, ...definitionArg.attributes }),
available: definitionArg.available,
};
}
private static commandFromButton(buttonArg: IDoorbirdButtonSnapshot, requestArg: IServiceCallRequest): IDoorbirdCommandRequest {
if (buttonArg.action === 'energize_relay') {
return { action: 'energize_relay', relay: buttonArg.relay || '1', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (buttonArg.action === 'turn_light_on') {
return { action: 'turn_light_on', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
return { action: 'reset_favorites', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
private static buttonFromRequest(snapshotArg: IDoorbirdSnapshot, requestArg: IServiceCallRequest): IDoorbirdButtonSnapshot | undefined {
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key ?? requestArg.data?.relay);
if (!target) {
return snapshotArg.buttons[0];
}
const buttonEntities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'button');
const entity = buttonEntities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target || entityArg.attributes?.relay === target);
const key = this.stringValue(entity?.attributes?.key) || target;
return snapshotArg.buttons.find((buttonArg) => buttonArg.key === key || buttonArg.relay === target || buttonArg.name === target);
}
private static cameraIdFromRequest(snapshotArg: IDoorbirdSnapshot, requestArg: IServiceCallRequest): TDoorbirdCameraKind | undefined {
const target = requestArg.target.entityId || this.stringValue(requestArg.data?.cameraId ?? requestArg.data?.camera_id ?? requestArg.data?.camera ?? requestArg.data?.kind);
if (!target) {
return 'live';
}
if (target === 'live' || target === 'last_ring' || target === 'last_motion') {
return target;
}
const cameraEntities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === ('camera' as TEntityPlatform));
const entity = cameraEntities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.cameraId === target);
const cameraId = this.stringValue(entity?.attributes?.cameraId);
return cameraId === 'live' || cameraId === 'last_ring' || cameraId === 'last_motion' ? cameraId : 'live';
}
private static relayFromRequest(snapshotArg: IDoorbirdSnapshot, requestArg: IServiceCallRequest): string | undefined {
const relay = this.stringValue(requestArg.data?.relay ?? requestArg.data?.r);
if (relay) {
return relay;
}
return this.buttonFromRequest(snapshotArg, requestArg)?.relay;
}
private static favoriteChangeFromData(dataArg: Record<string, unknown> | undefined): IDoorbirdFavoriteChange | undefined {
const type = this.stringValue(dataArg?.type ?? dataArg?.favoriteType);
const title = this.stringValue(dataArg?.title);
const value = this.stringValue(dataArg?.value ?? dataArg?.url);
if (!type || !title || !value) {
return undefined;
}
return { type, title, value, id: this.stringValue(dataArg?.id ?? dataArg?.favoriteId) };
}
private static relays(configArg: IDoorbirdConfig, infoArg: IDoorbirdInfo | undefined): string[] {
const rawRelays = configArg.relays || (Array.isArray(infoArg?.RELAYS) ? infoArg.RELAYS : []);
return rawRelays.map((relayArg) => String(relayArg)).filter(Boolean);
}
private static hasCommandTransport(configArg: IDoorbirdConfig, actionArg: IDoorbirdCommandRequest['action']): boolean {
if (configArg.commandExecutor || configArg.host) {
return true;
}
if (actionArg === 'energize_relay') {
return Boolean(configArg.client?.energizeRelay);
}
if (actionArg === 'turn_light_on') {
return Boolean(configArg.client?.turnLightOn);
}
if (actionArg === 'reset_favorites') {
return Boolean(configArg.client?.resetFavorites || configArg.client?.favorites && configArg.client?.deleteFavorite);
}
return Boolean(configArg.client);
}
private static deviceName(snapshotArg: IDoorbirdSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'DoorBird';
}
private static uniqueBase(snapshotArg: IDoorbirdSnapshot): string {
return this.slug(snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg));
}
private static hasRawData(rawDataArg: Partial<IDoorbirdRawData>): boolean {
return Boolean(rawDataArg.info || rawDataArg.status || rawDataArg.favorites || rawDataArg.schedule);
}
private static endpoint(configArg: IDoorbirdConfig): { protocol?: TDoorbirdProtocol; host?: string; port?: number; baseUrl?: string } {
const parsed = this.parseEndpoint(configArg.host);
if (parsed) {
return parsed;
}
const protocol: TDoorbirdProtocol = configArg.protocol || (configArg.ssl || configArg.tls ? 'https' : 'http');
const port = configArg.port || (protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort);
return {
protocol,
host: configArg.host,
port: configArg.host ? port : configArg.port,
baseUrl: configArg.host ? `${protocol}://${this.hostForUrl(configArg.host)}${port && port !== (protocol === 'https' ? 443 : 80) ? `:${port}` : ''}` : undefined,
};
}
private static parseEndpoint(valueArg: string | undefined): { protocol: TDoorbirdProtocol; host: string; port: number; baseUrl: string } | undefined {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
const protocol: TDoorbirdProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort;
return { protocol, host: url.hostname, port, baseUrl: `${protocol}://${url.hostname}${port !== (protocol === 'https' ? 443 : 80) ? `:${port}` : ''}` };
} catch {
return undefined;
}
}
private static authenticatedUrl(configArg: IDoorbirdConfig, pathArg: string, portArg?: number, protocolArg?: string): string | undefined {
const endpoint = this.endpoint(configArg);
if (!endpoint.host) {
return undefined;
}
const protocol = protocolArg || endpoint.protocol || 'http';
const defaultPort = protocol === 'rtsp' ? doorbirdDefaultRtspPort : protocol === 'https' ? doorbirdDefaultHttpsPort : doorbirdDefaultPort;
const port = portArg || endpoint.port || defaultPort;
const credentials = configArg.username || configArg.password !== undefined
? `${encodeURIComponent(configArg.username || '')}:${encodeURIComponent(configArg.password || '')}@`
: '';
return `${protocol}://${credentials}${this.hostForUrl(endpoint.host)}${port !== defaultPort ? `:${port}` : ''}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
}
private static hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
}
private static title(valueArg: string): string {
return `${valueArg.charAt(0).toUpperCase()}${valueArg.slice(1).replace(/_/g, ' ')}`;
}
private static cleanAttributes<TRecord extends Record<string, unknown>>(attributesArg: TRecord): TRecord {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as TRecord;
}
private static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
+271 -2
View File
@@ -1,4 +1,273 @@
export interface IHomeAssistantDoorbirdConfig {
// TODO: replace with the TypeScript-native config for doorbird.
export const doorbirdDomain = 'doorbird';
export const doorbirdDefaultPort = 80;
export const doorbirdDefaultHttpsPort = 443;
export const doorbirdDefaultRtspPort = 554;
export const doorbirdRtspOverHttpPort = 8557;
export const doorbirdDefaultTimeoutMs = 10000;
export const doorbirdDefaultSnapshotTimeoutMs = 15000;
export const doorbirdManufacturer = 'Bird Home Automation Group';
export const doorbirdOui = '1ccae3';
export const doorbirdMdnsType = '_axis-video._tcp';
export type TDoorbirdProtocol = 'http' | 'https';
export type TDoorbirdSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime';
export type TDoorbirdCameraKind = 'live' | 'last_ring' | 'last_motion';
export type TDoorbirdCommandAction =
| 'refresh'
| 'snapshot_image'
| 'stream_source'
| 'energize_relay'
| 'turn_light_on'
| 'doorbell_state'
| 'motion_sensor_state'
| 'favorites'
| 'change_favorite'
| 'delete_favorite'
| 'schedule'
| 'change_schedule'
| 'delete_schedule'
| 'reset_favorites';
export interface IDoorbirdConfig {
host?: string;
port?: number;
protocol?: TDoorbirdProtocol;
ssl?: boolean;
tls?: boolean;
username?: string;
password?: string;
timeoutMs?: number;
snapshotTimeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
macAddress?: string;
relays?: Array<string | number>;
events?: string[];
token?: string;
customUrl?: string;
eventBaseUrl?: string;
rtspPort?: number;
snapshot?: IDoorbirdSnapshot;
rawInfo?: IDoorbirdInfo;
status?: IDoorbirdStatus;
favorites?: IDoorbirdFavorites;
schedule?: IDoorbirdScheduleEntry[];
client?: IDoorbirdClientLike;
commandExecutor?: IDoorbirdCommandExecutor;
online?: boolean;
}
export interface IHomeAssistantDoorbirdConfig extends IDoorbirdConfig {}
export interface IDoorbirdClientLike {
getSnapshot?: () => Promise<IDoorbirdSnapshot | Partial<IDoorbirdRawData>>;
getInfo?: () => Promise<IDoorbirdInfo>;
getDoorbellState?: () => Promise<boolean>;
getMotionSensorState?: () => Promise<boolean>;
getSnapshotImage?: (cameraIdArg?: string) => Promise<IDoorbirdSnapshotImage>;
energizeRelay?: (relayArg: string) => Promise<boolean | unknown>;
turnLightOn?: () => Promise<boolean | unknown>;
favorites?: () => Promise<IDoorbirdFavorites>;
changeFavorite?: (favoriteArg: IDoorbirdFavoriteChange) => Promise<boolean | unknown>;
deleteFavorite?: (favoriteTypeArg: string, favoriteIdArg: string) => Promise<boolean | unknown>;
schedule?: () => Promise<IDoorbirdScheduleEntry[]>;
changeSchedule?: (entryArg: IDoorbirdScheduleEntry) => Promise<boolean | unknown>;
deleteSchedule?: (inputArg: string, paramArg?: string) => Promise<boolean | unknown>;
resetFavorites?: () => Promise<boolean | unknown>;
}
export interface IDoorbirdCommandExecutor {
execute(requestArg: IDoorbirdCommandRequest): Promise<unknown>;
}
export interface IDoorbirdCommandRequest {
action: TDoorbirdCommandAction;
service?: string;
target?: {
entityId?: string;
deviceId?: string;
};
data?: Record<string, unknown>;
relay?: string;
cameraId?: string;
favorite?: IDoorbirdFavoriteChange;
favoriteType?: string;
favoriteId?: string;
scheduleEntry?: IDoorbirdScheduleEntry;
scheduleInput?: string;
scheduleParam?: string;
path?: string;
method?: 'GET' | 'POST';
}
export interface IDoorbirdRefreshResult {
success: boolean;
snapshot?: IDoorbirdSnapshot;
error?: string;
data?: Record<string, unknown>;
}
export interface IDoorbirdRawData {
info?: IDoorbirdInfo;
status?: IDoorbirdStatus;
favorites?: IDoorbirdFavorites;
schedule?: IDoorbirdScheduleEntry[];
}
export interface IDoorbirdInfo {
FIRMWARE?: string;
BUILD_NUMBER?: string;
'DEVICE-TYPE'?: string;
DEVICE_TYPE?: string;
RELAYS?: Array<string | number>;
PRIMARY_MAC_ADDR?: string;
WIFI_MAC_ADDR?: string;
[key: string]: unknown;
}
export interface IDoorbirdStatus {
doorbell?: boolean;
motion?: boolean;
rawDoorbell?: string;
rawMotion?: string;
updatedAt?: string;
}
export interface IDoorbirdDeviceInfo {
id?: string;
name?: string;
manufacturer?: string;
model?: string;
firmware?: string;
buildNumber?: string;
swVersion?: string;
macAddress?: string;
primaryMacAddress?: string;
wifiMacAddress?: string;
host?: string;
port?: number;
protocol?: TDoorbirdProtocol;
baseUrl?: string;
rtspUrl?: string;
html5ViewerUrl?: string;
relays: string[];
online?: boolean;
}
export interface IDoorbirdCameraSnapshot {
id: TDoorbirdCameraKind;
name: string;
kind: TDoorbirdCameraKind;
stillImageUrl?: string;
liveVideoUrl?: string;
rtspUrl?: string;
historyEvent?: 'doorbell' | 'motionsensor';
historyIndex?: number;
available?: boolean;
}
export interface IDoorbirdButtonSnapshot {
key: string;
name: string;
action: 'energize_relay' | 'turn_light_on' | 'reset_favorites';
relay?: string;
entityCategory?: string;
available?: boolean;
}
export interface IDoorbirdEventSnapshot {
key: string;
name: string;
eventType: 'doorbell' | 'motion' | string;
doorbirdEvent: string;
available?: boolean;
}
export interface IDoorbirdSnapshot {
deviceInfo: IDoorbirdDeviceInfo;
cameras: IDoorbirdCameraSnapshot[];
buttons: IDoorbirdButtonSnapshot[];
events: IDoorbirdEventSnapshot[];
status: IDoorbirdStatus;
rawInfo?: IDoorbirdInfo;
favorites?: IDoorbirdFavorites;
schedule?: IDoorbirdScheduleEntry[];
online: boolean;
updatedAt?: string;
source?: TDoorbirdSnapshotSource;
error?: string;
}
export interface IDoorbirdSnapshotImage {
contentType: string;
data: Uint8Array;
}
export interface IDoorbirdFavorite {
title?: string;
value?: string;
[key: string]: unknown;
}
export interface IDoorbirdFavorites {
[favoriteType: string]: Record<string, IDoorbirdFavorite> | undefined;
}
export interface IDoorbirdFavoriteChange {
type: string;
title: string;
value: string;
id?: string;
}
export interface IDoorbirdScheduleEntry {
input: string;
param?: string;
output: IDoorbirdScheduleOutput[];
[key: string]: unknown;
}
export interface IDoorbirdScheduleOutput {
enabled?: boolean | string;
event?: string;
param?: string;
schedule?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IDoorbirdManualEntry {
host?: string;
url?: string;
port?: number;
protocol?: TDoorbirdProtocol;
ssl?: boolean;
tls?: boolean;
username?: string;
password?: string;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
macAddress?: string;
rawInfo?: IDoorbirdInfo;
status?: IDoorbirdStatus;
snapshot?: IDoorbirdSnapshot;
client?: IDoorbirdClientLike;
commandExecutor?: IDoorbirdCommandExecutor;
metadata?: Record<string, unknown>;
}
export interface IDoorbirdMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
ipAddress?: string;
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './doorbird.classes.client.js';
export * from './doorbird.classes.configflow.js';
export * from './doorbird.classes.integration.js';
export * from './doorbird.discovery.js';
export * from './doorbird.mapper.js';
export * from './doorbird.types.js';
@@ -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,587 @@
import type {
IForkedDaapdCommandExecutorResult,
IForkedDaapdCommandRequest,
IForkedDaapdConfig,
IForkedDaapdLibraryItem,
IForkedDaapdOutput,
IForkedDaapdPlayerStatus,
IForkedDaapdQueueStatus,
IForkedDaapdRawCommandRequest,
IForkedDaapdServerConfig,
IForkedDaapdServerInfo,
IForkedDaapdSnapshot,
TForkedDaapdProtocol,
} from './forked_daapd.types.js';
import {
forkedDaapdDefaultMaxPlaylists,
forkedDaapdDefaultPort,
forkedDaapdDefaultTimeoutMs,
forkedDaapdDefaultUnmuteVolume,
forkedDaapdSourceNameClear,
forkedDaapdSourceNameDefault,
} from './forked_daapd.types.js';
const notifyEventTypes = ['player', 'outputs', 'volume', 'options', 'queue', 'database'];
const startupPlayer: IForkedDaapdPlayerStatus = {
state: 'stop',
repeat: 'off',
consume: false,
shuffle: false,
volume: 0,
item_id: 0,
item_length_ms: 0,
item_progress_ms: 0,
};
const startupQueue: IForkedDaapdQueueStatus = {
version: 0,
count: 0,
items: [],
};
export class ForkedDaapdHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'ForkedDaapdHttpError';
}
}
export class ForkedDaapdCommandError extends Error {
constructor(public readonly endpoint: string, messageArg: string) {
super(`OwnTone request ${endpoint} failed: ${messageArg}`);
this.name = 'ForkedDaapdCommandError';
}
}
export class ForkedDaapdClient {
private currentSnapshot?: IForkedDaapdSnapshot;
constructor(private readonly config: IForkedDaapdConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<IForkedDaapdSnapshot> {
if (this.currentSnapshot && this.config.snapshot) {
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
}
if (!this.config.host && !this.config.commandExecutor) {
if (this.currentSnapshot) {
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
}
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'OwnTone refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
}
try {
this.currentSnapshot = await this.fetchSnapshot();
return this.cloneValue(this.currentSnapshot);
} catch (errorArg) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
}
}
public async validateConnection(): Promise<IForkedDaapdSnapshot> {
const serverConfig = await this.getRequest<IForkedDaapdServerConfig>('config');
if (serverConfig.websocket_port === 0) {
throw new Error('OwnTone server websocket not enabled.');
}
if (!serverConfig.library_name && typeof serverConfig.websocket_port !== 'number') {
throw new Error('The OwnTone integration requires an OwnTone server with version >= 27.0.');
}
return this.fetchSnapshot(serverConfig);
}
public async execute(requestArg: IForkedDaapdCommandRequest): Promise<unknown> {
if (requestArg.command === 'play') {
return this.putRequest('player/play');
}
if (requestArg.command === 'pause') {
return this.putRequest('player/pause');
}
if (requestArg.command === 'stop') {
return this.putRequest('player/stop');
}
if (requestArg.command === 'next_track') {
return this.putRequest('player/next');
}
if (requestArg.command === 'previous_track') {
return this.putRequest('player/previous');
}
if (requestArg.command === 'seek') {
const positionMs = typeof requestArg.positionMs === 'number'
? Math.round(requestArg.positionMs)
: typeof requestArg.position === 'number'
? Math.round(requestArg.position * 1000)
: undefined;
if (positionMs === undefined) {
throw new Error('OwnTone seek requires position or positionMs.');
}
return this.putRequest('player/seek', { position_ms: positionMs });
}
if (requestArg.command === 'set_volume') {
const params: Record<string, string | number | boolean | undefined> = { volume: this.volumePercent(requestArg) };
if (requestArg.outputId) {
params.output_id = requestArg.outputId;
}
return this.putRequest('player/volume', params);
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('OwnTone mute requires muted.');
}
const volume = requestArg.muted ? 0 : this.volumePercent({ ...requestArg, volumeLevel: requestArg.volumeLevel ?? forkedDaapdDefaultUnmuteVolume });
const params: Record<string, string | number | boolean | undefined> = { volume };
if (requestArg.outputId) {
params.output_id = requestArg.outputId;
}
return this.putRequest('player/volume', params);
}
if (requestArg.command === 'shuffle') {
if (typeof requestArg.shuffle !== 'boolean') {
throw new Error('OwnTone shuffle requires shuffle.');
}
return this.putRequest('player/shuffle', { state: requestArg.shuffle });
}
if (requestArg.command === 'repeat') {
if (!requestArg.repeat) {
throw new Error('OwnTone repeat requires repeat.');
}
return this.putRequest('player/repeat', { state: requestArg.repeat });
}
if (requestArg.command === 'clear_queue') {
return this.putRequest('queue/clear');
}
if (requestArg.command === 'play_media') {
const uri = requestArg.uri || requestArg.mediaId;
if (!uri) {
throw new Error('OwnTone play_media requires mediaId or uri.');
}
return this.postRequest('queue/items/add', {
uris: uri,
playback: requestArg.playback || 'start',
clear: requestArg.clear ?? true,
});
}
if (requestArg.command === 'select_source') {
return this.selectSource(requestArg);
}
if (requestArg.command === 'turn_on') {
const snapshot = await this.getSnapshot();
const outputIds = requestArg.outputIds?.length ? requestArg.outputIds : snapshot.outputs.map((outputArg) => outputArg.id);
if (requestArg.volume !== undefined || requestArg.volumeLevel !== undefined) {
await this.execute({ command: 'set_volume', volume: requestArg.volume, volumeLevel: requestArg.volumeLevel });
}
return this.execute({ command: 'set_enabled_outputs', outputIds });
}
if (requestArg.command === 'turn_off') {
await this.execute({ command: 'pause' });
return this.execute({ command: 'set_enabled_outputs', outputIds: [] });
}
if (requestArg.command === 'set_output') {
if (!requestArg.outputId) {
throw new Error('OwnTone set_output requires outputId.');
}
const body: Record<string, number | boolean> = {};
if (typeof requestArg.selected === 'boolean') {
body.selected = requestArg.selected;
}
if (requestArg.volume !== undefined || requestArg.volumeLevel !== undefined) {
body.volume = this.volumePercent(requestArg);
}
return this.putRequest(`outputs/${encodeURIComponent(requestArg.outputId)}`, undefined, body);
}
if (requestArg.command === 'set_enabled_outputs') {
return this.putRequest('outputs/set', undefined, { outputs: requestArg.outputIds || [] });
}
if (requestArg.command === 'raw_request') {
if (!requestArg.endpoint) {
throw new Error('OwnTone raw_request requires endpoint.');
}
return this.request(requestArg.method || 'GET', requestArg.endpoint, requestArg.params, requestArg.body, requestArg.expectJson ?? true);
}
throw new Error(`Unsupported OwnTone command: ${requestArg.command}`);
}
public async getRequest<TValue = Record<string, unknown>>(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): Promise<TValue> {
return (await this.request('GET', endpointArg, paramsArg, undefined, true)).data as TValue;
}
public async putRequest(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>, bodyArg?: unknown): Promise<unknown> {
return this.request('PUT', endpointArg, paramsArg, bodyArg, false);
}
public async postRequest(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>, bodyArg?: unknown): Promise<unknown> {
return this.request('POST', endpointArg, paramsArg, bodyArg, false);
}
public fullUrl(pathArg: string): string {
if (/^https?:\/\//i.test(pathArg)) {
return pathArg;
}
const host = this.config.host;
const port = this.config.port || forkedDaapdDefaultPort;
if (!host) {
return pathArg;
}
const encodedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
const credentials = this.config.password ? `admin:${encodeURIComponent(this.config.password)}@` : '';
return `http://${credentials}${encodedHost}:${port}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
}
public async subscribeToNotifications(handlerArg: (snapshotArg: IForkedDaapdSnapshot, eventTypesArg: string[]) => void | Promise<void>): Promise<() => Promise<void>> {
const snapshot = await this.getSnapshot();
const host = snapshot.server.host || this.config.host;
const websocketPort = snapshot.server.websocketPort;
if (!host || !websocketPort) {
throw new Error('OwnTone WebSocket notifications require a discovered server websocket_port and host.');
}
const WebSocketCtor = (globalThis as unknown as { WebSocket?: new (urlArg: string, protocolsArg?: string | string[]) => IForkedDaapdWebSocket }).WebSocket;
if (!WebSocketCtor) {
throw new Error('OwnTone WebSocket notifications require a global WebSocket implementation.');
}
const websocket = new WebSocketCtor(this.websocketUrl(host, websocketPort), 'notify');
const onOpen = async () => {
websocket.send(JSON.stringify({ notify: notifyEventTypes }));
await handlerArg(await this.getSnapshot(), notifyEventTypes);
};
const onMessage = async (eventArg: { data?: unknown }) => {
const payload = typeof eventArg.data === 'string' ? JSON.parse(eventArg.data) as { notify?: string[] } : undefined;
const events = Array.isArray(payload?.notify) ? payload.notify : notifyEventTypes;
await handlerArg(await this.getSnapshot(), events);
};
attachWebSocketHandler(websocket, 'open', onOpen);
attachWebSocketHandler(websocket, 'message', (eventArg) => { void onMessage(eventArg as { data?: unknown }); });
return async () => websocket.close();
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(serverConfigArg?: IForkedDaapdServerConfig): Promise<IForkedDaapdSnapshot> {
const serverConfig = serverConfigArg || await this.getRequest<IForkedDaapdServerConfig>('config');
const [player, queue, outputsResponse, playlists, pipes] = await Promise.all([
this.getRequest<IForkedDaapdPlayerStatus>('player'),
this.getRequest<IForkedDaapdQueueStatus>('queue'),
this.getRequest<{ outputs?: IForkedDaapdOutput[] }>('outputs'),
this.getListItems('library/playlists').catch(() => []),
this.getPipes().catch(() => []),
]);
const snapshot: IForkedDaapdSnapshot = {
server: this.serverFromConfig(serverConfig),
player,
queue,
outputs: outputsResponse.outputs || [],
library: { playlists, pipes },
online: true,
updatedAt: new Date().toISOString(),
source: this.config.commandExecutor && !this.config.host ? 'executor' : 'http',
maxPlaylists: this.config.maxPlaylists,
raw: { config: serverConfig },
};
return this.normalizeSnapshot(snapshot, snapshot.source || 'http');
}
private async getListItems(endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): Promise<IForkedDaapdLibraryItem[]> {
const response = await this.getRequest<{ items?: IForkedDaapdLibraryItem[] }>(endpointArg, paramsArg);
return Array.isArray(response.items) ? response.items : [];
}
private async getPipes(): Promise<IForkedDaapdLibraryItem[]> {
const response = await this.getRequest<Record<string, { items?: IForkedDaapdLibraryItem[] }>>('search', { type: 'tracks', expression: 'data_kind is pipe' });
return Array.isArray(response.tracks?.items) ? response.tracks.items : [];
}
private async selectSource(requestArg: IForkedDaapdCommandRequest): Promise<unknown> {
const source = requestArg.source;
if (!source) {
throw new Error('OwnTone select_source requires source.');
}
if (source === forkedDaapdSourceNameClear) {
return this.execute({ command: 'clear_queue' });
}
if (source === forkedDaapdSourceNameDefault) {
return { source };
}
const snapshot = await this.getSnapshot();
const uri = this.sourceUris(snapshot)[source];
if (!uri) {
throw new Error(`Unknown OwnTone source: ${source}`);
}
return this.execute({ command: 'play_media', uri, clear: true });
}
private sourceUris(snapshotArg: IForkedDaapdSnapshot): Record<string, string | undefined> {
const sources: Record<string, string | undefined> = {
[forkedDaapdSourceNameClear]: undefined,
[forkedDaapdSourceNameDefault]: undefined,
};
for (const pipe of snapshotArg.library.pipes) {
const title = stringValue(pipe.title) || stringValue(pipe.name);
if (title && pipe.uri) {
sources[`${title} (pipe)`] = pipe.uri;
}
}
for (const playlist of snapshotArg.library.playlists.slice(0, snapshotArg.maxPlaylists || forkedDaapdDefaultMaxPlaylists)) {
const name = stringValue(playlist.name) || stringValue(playlist.title);
if (name && playlist.uri) {
sources[`${name} (playlist)`] = playlist.uri;
}
}
return sources;
}
private async request(
methodArg: IForkedDaapdRawCommandRequest['method'],
endpointArg: string,
paramsArg?: Record<string, string | number | boolean | undefined>,
bodyArg?: unknown,
expectJsonArg = false
): Promise<{ status: number; data?: unknown; response?: string }> {
const rawRequest = this.rawRequest(methodArg, endpointArg, paramsArg, bodyArg, expectJsonArg);
if (this.config.commandExecutor) {
return this.executorResult(rawRequest, await this.config.commandExecutor.execute(rawRequest));
}
if (!rawRequest.host || !rawRequest.url) {
throw new Error('OwnTone HTTP command requires config.host or commandExecutor. Static snapshots are read-only.');
}
const abortController = new AbortController();
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || forkedDaapdDefaultTimeoutMs);
try {
const headers: Record<string, string> = {};
if (this.config.password) {
headers.Authorization = `Basic ${Buffer.from(`admin:${this.config.password}`).toString('base64')}`;
}
if (bodyArg !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await globalThis.fetch(rawRequest.url, {
method: methodArg,
headers,
body: bodyArg === undefined ? undefined : JSON.stringify(bodyArg),
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new ForkedDaapdHttpError(response.status, `OwnTone ${methodArg} ${endpointArg} failed with HTTP ${response.status}: ${text}`);
}
return { status: response.status, response: text, data: expectJsonArg && text ? JSON.parse(text) : undefined };
} finally {
globalThis.clearTimeout(timeout);
}
}
private rawRequest(
methodArg: IForkedDaapdRawCommandRequest['method'],
endpointArg: string,
paramsArg: Record<string, string | number | boolean | undefined> | undefined,
bodyArg: unknown,
expectJsonArg: boolean
): IForkedDaapdRawCommandRequest {
const protocol: TForkedDaapdProtocol = 'http';
const host = this.config.host;
const port = this.config.port || forkedDaapdDefaultPort;
return {
method: methodArg,
endpoint: cleanEndpoint(endpointArg),
host,
port,
protocol,
params: paramsArg,
body: bodyArg,
password: this.config.password,
expectJson: expectJsonArg,
url: host ? this.apiUrl(protocol, host, port, endpointArg, paramsArg) : undefined,
};
}
private apiUrl(protocolArg: TForkedDaapdProtocol, hostArg: string, portArg: number, endpointArg: string, paramsArg?: Record<string, string | number | boolean | undefined>): string {
const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
const url = new URL(`${protocolArg}://${encodedHost}:${portArg}/api/${cleanEndpoint(endpointArg)}`);
for (const [key, value] of Object.entries(paramsArg || {})) {
if (value !== undefined) {
url.searchParams.set(key, typeof value === 'boolean' ? String(value).toLowerCase() : String(value));
}
}
return url.toString();
}
private websocketUrl(hostArg: string, portArg: number): string {
const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
return `ws://${encodedHost}:${portArg}/`;
}
private executorResult(requestArg: IForkedDaapdRawCommandRequest, resultArg: unknown): { status: number; data?: unknown; response?: string } {
if (resultArg === undefined || resultArg === true) {
return { status: 204, data: resultArg };
}
if (typeof resultArg === 'string') {
if (!requestArg.expectJson) {
return { status: 200, response: resultArg };
}
try {
return { status: 200, response: resultArg, data: JSON.parse(resultArg) as unknown };
} catch {
throw new ForkedDaapdCommandError(requestArg.endpoint, `executor returned invalid JSON: ${resultArg}`);
}
}
if (this.isRecord(resultArg)) {
const result = resultArg as IForkedDaapdCommandExecutorResult;
if (result.ok === false || result.success === false || result.status && result.status >= 400) {
throw new ForkedDaapdCommandError(requestArg.endpoint, JSON.stringify(resultArg));
}
if ('data' in result) {
return { status: result.status || 200, data: result.data, response: result.response };
}
return { status: result.status || 200, data: requestArg.expectJson ? resultArg : undefined, response: result.response };
}
return { status: 200, data: resultArg };
}
private serverFromConfig(configArg: IForkedDaapdServerConfig): IForkedDaapdServerInfo {
const port = this.config.port || forkedDaapdDefaultPort;
const libraryName = stringValue(configArg.library_name) || this.config.name;
const id = this.config.uniqueId || libraryName || (this.config.host ? `${this.config.host}:${port}` : 'owntone');
return {
id,
name: libraryName || this.config.host || 'OwnTone',
host: this.config.host,
port,
protocol: 'http',
libraryName,
websocketPort: numberValue(configArg.websocket_port),
version: stringValue(configArg.version),
config: configArg,
};
}
private normalizeSnapshot(snapshotArg: IForkedDaapdSnapshot, sourceArg: IForkedDaapdSnapshot['source']): IForkedDaapdSnapshot {
const server = this.normalizeServer(snapshotArg.server);
const player = { ...startupPlayer, ...snapshotArg.player };
const queue = {
...startupQueue,
...snapshotArg.queue,
items: Array.isArray(snapshotArg.queue?.items) ? snapshotArg.queue.items : [],
};
const outputs = (snapshotArg.outputs || []).map((outputArg) => ({
...outputArg,
id: String(outputArg.id),
name: outputArg.name || String(outputArg.id),
selected: Boolean(outputArg.selected),
volume: numberValue(outputArg.volume) ?? 0,
}));
return {
...snapshotArg,
server,
player,
queue,
outputs,
library: {
playlists: snapshotArg.library?.playlists || [],
pipes: snapshotArg.library?.pipes || [],
},
online: Boolean(snapshotArg.online),
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
maxPlaylists: snapshotArg.maxPlaylists ?? this.config.maxPlaylists ?? forkedDaapdDefaultMaxPlaylists,
};
}
private normalizeServer(serverArg: IForkedDaapdServerInfo): IForkedDaapdServerInfo {
const port = serverArg.port || this.config.port || forkedDaapdDefaultPort;
const name = serverArg.name || serverArg.libraryName || this.config.name || this.config.host || 'OwnTone';
return {
...serverArg,
id: serverArg.id || this.config.uniqueId || name || (this.config.host ? `${this.config.host}:${port}` : 'owntone'),
name,
host: serverArg.host || this.config.host,
port,
protocol: 'http',
libraryName: serverArg.libraryName || name,
};
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IForkedDaapdSnapshot {
const port = this.config.port || forkedDaapdDefaultPort;
const name = this.config.name || this.config.host || 'OwnTone';
return {
server: {
id: this.config.uniqueId || (this.config.host ? `${this.config.host}:${port}` : 'owntone'),
name,
host: this.config.host,
port,
protocol: 'http',
libraryName: name,
},
player: { ...startupPlayer },
queue: { ...startupQueue, items: [] },
outputs: [],
library: { playlists: [], pipes: [] },
online: onlineArg,
updatedAt: new Date().toISOString(),
source: 'runtime',
maxPlaylists: this.config.maxPlaylists,
error: errorArg,
};
}
private volumePercent(requestArg: Pick<IForkedDaapdCommandRequest, 'volume' | 'volumeLevel'>): number {
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('OwnTone volume command requires volumeLevel or volume.');
}
return Math.max(0, Math.min(100, Math.round(value)));
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
interface IForkedDaapdWebSocket {
addEventListener?: (typeArg: string, listenerArg: (eventArg: unknown) => void) => void;
onopen?: (eventArg: unknown) => void;
onmessage?: (eventArg: unknown) => void;
send(dataArg: string): void;
close(): void;
}
const attachWebSocketHandler = (websocketArg: IForkedDaapdWebSocket, typeArg: 'open' | 'message', listenerArg: (eventArg: unknown) => void): void => {
if (websocketArg.addEventListener) {
websocketArg.addEventListener(typeArg, listenerArg);
return;
}
if (typeArg === 'open') {
websocketArg.onopen = listenerArg;
} else {
websocketArg.onmessage = listenerArg;
}
};
const cleanEndpoint = (endpointArg: string): string => endpointArg.replace(/^\/?api\//, '').replace(/^\//, '');
const stringValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string' && valueArg.trim()) {
return valueArg.trim();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return String(valueArg);
}
return undefined;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
@@ -0,0 +1,60 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IForkedDaapdConfig } from './forked_daapd.types.js';
import { forkedDaapdDefaultMaxPlaylists, forkedDaapdDefaultPort, forkedDaapdDefaultTimeoutMs } from './forked_daapd.types.js';
export class ForkedDaapdConfigFlow implements IConfigFlow<IForkedDaapdConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IForkedDaapdConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect OwnTone',
description: candidateArg.source === 'manual'
? 'Configure a local OwnTone JSON API endpoint.'
: 'Confirm or adjust the discovered local OwnTone endpoint.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'API port', type: 'number' },
{ name: 'password', label: 'API password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
if (!host) {
return { kind: 'error', title: 'OwnTone setup failed', error: 'OwnTone setup requires a host.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || forkedDaapdDefaultPort;
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { kind: 'error', title: 'OwnTone setup failed', error: 'OwnTone API port must be an integer between 1 and 65535.' };
}
return {
kind: 'done',
title: 'OwnTone configured',
config: {
host,
port,
password: this.stringValue(valuesArg.password) || this.stringValue(candidateArg.metadata?.password) || '',
name: this.stringValue(valuesArg.name) || candidateArg.name,
uniqueId: candidateArg.id,
timeoutMs: forkedDaapdDefaultTimeoutMs,
maxPlaylists: forkedDaapdDefaultMaxPlaylists,
},
};
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
}
}
@@ -1,28 +1,295 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { ForkedDaapdClient } from './forked_daapd.classes.client.js';
import { ForkedDaapdConfigFlow } from './forked_daapd.classes.configflow.js';
import { createForkedDaapdDiscoveryDescriptor } from './forked_daapd.discovery.js';
import { ForkedDaapdMapper } from './forked_daapd.mapper.js';
import type { IForkedDaapdCommandRequest, IForkedDaapdConfig } from './forked_daapd.types.js';
export class HomeAssistantForkedDaapdIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "forked_daapd",
displayName: "OwnTone",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/forked_daapd",
"upstreamDomain": "forked_daapd",
"iotClass": "local_push",
"requirements": [
"pyforked-daapd==0.1.14",
"pylibrespot-java==0.1.1"
],
"dependencies": [],
"afterDependencies": [
"spotify"
],
"codeowners": [
"@uvjustin"
]
},
export class ForkedDaapdIntegration extends BaseIntegration<IForkedDaapdConfig> {
public readonly domain = 'forked_daapd';
public readonly displayName = 'OwnTone';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createForkedDaapdDiscoveryDescriptor();
public readonly configFlow = new ForkedDaapdConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/forked_daapd',
upstreamDomain: 'forked_daapd',
iotClass: 'local_push',
requirements: ['pyforked-daapd==0.1.14', 'pylibrespot-java==0.1.1'],
dependencies: [],
afterDependencies: ['spotify'],
codeowners: ['@uvjustin'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/forked_daapd',
zeroconf: ['_daap._tcp.local.'],
};
public async setup(configArg: IForkedDaapdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
return new ForkedDaapdRuntime(new ForkedDaapdClient(configArg), contextArg);
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantForkedDaapdIntegration extends ForkedDaapdIntegration {}
class ForkedDaapdRuntime implements IIntegrationRuntime {
public domain = 'forked_daapd';
private unsubscribeNotifications?: () => Promise<void>;
constructor(private readonly client: ForkedDaapdClient, private readonly context: IIntegrationSetupContext) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ForkedDaapdMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ForkedDaapdMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
this.unsubscribeNotifications = await this.client.subscribeToNotifications(async (snapshotArg, eventTypesArg) => {
handlerArg({
type: 'state_changed',
integrationDomain: this.domain,
deviceId: ForkedDaapdMapper.serverDeviceId(snapshotArg),
entityId: ForkedDaapdMapper.serverEntityId(snapshotArg),
data: { eventTypes: eventTypesArg, snapshot: snapshotArg },
timestamp: Date.now(),
});
});
return async () => {
await this.unsubscribeNotifications?.();
this.unsubscribeNotifications = undefined;
};
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'forked_daapd') {
return await this.callOwnToneService(requestArg);
}
return { success: false, error: `Unsupported OwnTone service domain: ${requestArg.domain}` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.unsubscribeNotifications?.();
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const snapshot = await this.client.getSnapshot();
const target = this.targetFromRequest(snapshot, requestArg);
if (target.kind === 'output') {
return { success: true, data: await this.client.execute(await this.outputCommandRequest(target.outputId, requestArg, snapshot)) };
}
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported OwnTone media_player service: ${requestArg.service}` };
}
return { success: true, data: await this.client.execute(await this.serverCommandRequest(command, requestArg, snapshot)) };
}
private async callOwnToneService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'raw_request' || requestArg.service === 'request') {
return {
success: true,
data: await this.client.execute({
command: 'raw_request',
method: this.methodData(requestArg) || 'GET',
endpoint: this.stringData(requestArg, 'endpoint') || this.stringData(requestArg, 'path'),
params: this.recordData(requestArg, 'params') as Record<string, string | number | boolean | undefined> | undefined,
body: requestArg.data?.body,
expectJson: this.booleanData(requestArg, 'expect_json') ?? this.booleanData(requestArg, 'expectJson') ?? true,
}),
};
}
if (requestArg.service === 'update' || requestArg.service === 'rescan') {
return { success: true, data: await this.client.execute({ command: 'raw_request', method: 'PUT', endpoint: requestArg.service }) };
}
if (requestArg.service === 'clear_queue') {
return { success: true, data: await this.client.execute({ command: 'clear_queue' }) };
}
const command = this.commandFromMediaService(requestArg.service);
if (command) {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported OwnTone service: ${requestArg.service}` };
}
private async outputCommandRequest(outputIdArg: string, requestArg: IServiceCallRequest, snapshotArg: Awaited<ReturnType<ForkedDaapdClient['getSnapshot']>>): Promise<IForkedDaapdCommandRequest> {
if (requestArg.service === 'turn_on') {
return { command: 'set_output', outputId: outputIdArg, selected: true };
}
if (requestArg.service === 'turn_off') {
return { command: 'set_output', outputId: outputIdArg, selected: false };
}
if (requestArg.service === 'toggle') {
const output = snapshotArg.outputs.find((outputArg) => outputArg.id === outputIdArg);
return { command: 'set_output', outputId: outputIdArg, selected: !output?.selected };
}
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
return { command: 'set_volume', outputId: outputIdArg, volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') };
}
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
return { command: 'mute', outputId: outputIdArg, muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') };
}
throw new Error(`OwnTone output ${outputIdArg} does not support media_player service ${requestArg.service}.`);
}
private async serverCommandRequest(commandArg: IForkedDaapdCommandRequest['command'], requestArg: IServiceCallRequest, snapshotArg: Awaited<ReturnType<ForkedDaapdClient['getSnapshot']>>): Promise<IForkedDaapdCommandRequest> {
const base: IForkedDaapdCommandRequest = { command: commandArg };
if (commandArg === 'set_volume') {
base.volumeLevel = this.numberData(requestArg, 'volume_level');
base.volume = this.numberData(requestArg, 'volume');
}
if (commandArg === 'mute') {
base.muted = this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute');
}
if (commandArg === 'seek') {
base.position = this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position');
}
if (commandArg === 'shuffle') {
base.shuffle = this.booleanData(requestArg, 'shuffle') ?? this.booleanData(requestArg, 'shuffle_set');
}
if (commandArg === 'repeat') {
const repeat = this.stringData(requestArg, 'repeat') || this.stringData(requestArg, 'repeat_mode');
if (repeat) {
base.repeat = repeat;
}
}
if (commandArg === 'play_media') {
base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri');
base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
base.clear = this.booleanData(requestArg, 'clear');
}
if (commandArg === 'select_source') {
base.source = this.stringData(requestArg, 'source') || this.stringData(requestArg, 'option');
}
if (commandArg === 'turn_on') {
base.outputIds = snapshotArg.outputs.map((outputArg) => outputArg.id);
base.volumeLevel = this.numberData(requestArg, 'volume_level');
base.volume = this.numberData(requestArg, 'volume');
}
return base;
}
private targetFromRequest(snapshotArg: Awaited<ReturnType<ForkedDaapdClient['getSnapshot']>>, requestArg: IServiceCallRequest): { kind: 'server' } | { kind: 'output'; outputId: string } {
const outputId = this.stringData(requestArg, 'output_id') || this.stringData(requestArg, 'outputId');
if (outputId) {
return { kind: 'output', outputId };
}
if (requestArg.target.entityId) {
const target = ForkedDaapdMapper.entityTarget(snapshotArg, requestArg.target.entityId);
if (target) {
return target;
}
}
if (requestArg.target.deviceId) {
const target = ForkedDaapdMapper.deviceTarget(snapshotArg, requestArg.target.deviceId);
if (target) {
return target;
}
}
return { kind: 'server' };
}
private commandFromMediaService(serviceArg: string): IForkedDaapdCommandRequest['command'] | undefined {
if (serviceArg === 'media_play' || serviceArg === 'play') {
return 'play';
}
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
return 'pause';
}
if (serviceArg === 'media_stop' || serviceArg === 'stop') {
return 'stop';
}
if (serviceArg === 'media_next_track' || serviceArg === 'next_track' || serviceArg === 'next') {
return 'next_track';
}
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track' || serviceArg === 'previous') {
return 'previous_track';
}
if (serviceArg === 'media_seek' || serviceArg === 'seek') {
return 'seek';
}
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
return 'set_volume';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'clear_playlist' || serviceArg === 'clear_queue') {
return 'clear_queue';
}
if (serviceArg === 'shuffle_set' || serviceArg === 'set_shuffle') {
return 'shuffle';
}
if (serviceArg === 'repeat_set' || serviceArg === 'set_repeat') {
return 'repeat';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
if (serviceArg === 'select_source' || serviceArg === 'source') {
return 'select_source';
}
if (serviceArg === 'turn_on') {
return 'turn_on';
}
if (serviceArg === 'turn_off') {
return 'turn_off';
}
return undefined;
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'boolean') {
return value;
}
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
return undefined;
}
private recordData(requestArg: IServiceCallRequest, keyArg: string): Record<string, unknown> | undefined {
const value = requestArg.data?.[keyArg];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
private methodData(requestArg: IServiceCallRequest): 'GET' | 'PUT' | 'POST' | 'DELETE' | undefined {
const method = this.stringData(requestArg, 'method')?.toUpperCase();
return method === 'GET' || method === 'PUT' || method === 'POST' || method === 'DELETE' ? method : undefined;
}
}
@@ -0,0 +1,192 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IForkedDaapdManualEntry, IForkedDaapdMdnsRecord } from './forked_daapd.types.js';
import { forkedDaapdDefaultPort } from './forked_daapd.types.js';
const forkedDaapdDomain = 'forked_daapd';
const forkedDaapdMdnsType = '_daap._tcp';
export class ForkedDaapdMdnsMatcher implements IDiscoveryMatcher<IForkedDaapdMdnsRecord> {
public id = 'forked-daapd-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize OwnTone zeroconf DAAP advertisements.';
public async matches(recordArg: IForkedDaapdMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const properties = { ...recordArg.txt, ...recordArg.properties };
const machineName = stringValue(valueForKey(properties, 'Machine Name')) || cleanName(recordArg.name || recordArg.hostname);
const mtdVersion = stringValue(valueForKey(properties, 'mtd-version'));
const versionMajor = majorVersion(mtdVersion);
const serviceMatch = type === forkedDaapdMdnsType;
if (!serviceMatch) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a DAAP service advertisement.' };
}
if (!machineName) {
return { matched: false, confidence: 'medium', reason: 'DAAP service lacks OwnTone Machine Name metadata.' };
}
if (versionMajor === undefined || versionMajor < 27) {
return { matched: false, confidence: 'medium', reason: 'DAAP service is not an OwnTone/forked-daapd server with version >= 27.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
const port = recordArg.port || forkedDaapdDefaultPort;
return {
matched: true,
confidence: host ? 'certain' : 'high',
reason: 'mDNS service is an OwnTone DAAP advertisement with supported mtd-version.',
normalizedDeviceId: machineName,
candidate: {
source: 'mdns',
integrationDomain: forkedDaapdDomain,
id: machineName,
host,
port,
name: machineName,
manufacturer: 'OwnTone',
model: 'OwnTone Server',
metadata: {
mdnsName: recordArg.name,
mdnsType: type,
txt: properties,
mtdVersion,
versionMajor,
},
},
metadata: { mdnsType: type, mtdVersion, versionMajor },
};
}
}
export class ForkedDaapdManualMatcher implements IDiscoveryMatcher<IForkedDaapdManualEntry> {
public id = 'forked-daapd-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual OwnTone endpoint setup entries.';
public async matches(inputArg: IForkedDaapdManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const haystack = `${inputArg.name || ''} ${inputArg.id || ''} ${inputArg.uniqueId || ''}`.toLowerCase();
const hinted = Boolean(metadata.owntone || metadata.forked_daapd || metadata.forkedDaapd) || haystack.includes('owntone') || haystack.includes('forked_daapd');
if (!inputArg.host && !hinted) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain an OwnTone host or setup hint.' };
}
const port = inputArg.port || forkedDaapdDefaultPort;
const id = inputArg.uniqueId || inputArg.id || inputArg.name || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start OwnTone setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: forkedDaapdDomain,
id,
host: inputArg.host,
port,
name: inputArg.name,
manufacturer: 'OwnTone',
model: 'OwnTone Server',
metadata: {
...metadata,
password: inputArg.password,
},
},
};
}
}
export class ForkedDaapdCandidateValidator implements IDiscoveryValidator {
public id = 'forked-daapd-candidate-validator';
public description = 'Validate OwnTone candidates have a local API endpoint.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const mdnsType = stringMetadata(metadata.mdnsType);
const versionMajor = numberMetadata(metadata.versionMajor) ?? majorVersion(stringMetadata(metadata.mtdVersion));
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === forkedDaapdDomain
|| normalizeMdnsType(mdnsType || '') === forkedDaapdMdnsType
|| Boolean(metadata.owntone || metadata.forked_daapd || metadata.forkedDaapd)
|| haystack.includes('owntone')
|| haystack.includes('forked-daapd')
|| haystack.includes('forked_daapd');
if (!matched || !candidateArg.host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'OwnTone candidate lacks host information.' : 'Candidate is not OwnTone.',
};
}
if (versionMajor !== undefined && versionMajor < 27) {
return { matched: false, confidence: 'medium', reason: 'OwnTone candidate version is below 27.' };
}
const port = candidateArg.port || forkedDaapdDefaultPort;
return {
matched: true,
confidence: candidateArg.id ? 'certain' : 'high',
reason: 'Candidate has OwnTone metadata and host information.',
normalizedDeviceId: candidateArg.id || `${candidateArg.host}:${port}`,
candidate: {
...candidateArg,
integrationDomain: forkedDaapdDomain,
port,
manufacturer: candidateArg.manufacturer || 'OwnTone',
model: candidateArg.model || 'OwnTone Server',
},
};
}
}
export const createForkedDaapdDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: forkedDaapdDomain, displayName: 'OwnTone' })
.addMatcher(new ForkedDaapdMdnsMatcher())
.addMatcher(new ForkedDaapdManualMatcher())
.addValidator(new ForkedDaapdCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/\._daap\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
const valueForKey = (recordArg: Record<string, unknown> | undefined, keyArg: string): unknown => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const stringValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string' && valueArg.trim()) {
return valueArg.trim();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return String(valueArg);
}
if (valueArg instanceof Uint8Array) {
return Buffer.from(valueArg).toString('utf8');
}
return undefined;
};
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
const numberMetadata = (valueArg: unknown): number | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
const majorVersion = (valueArg: string | undefined): number | undefined => {
if (!valueArg) {
return undefined;
}
const parsed = Number.parseInt(valueArg.split('.')[0], 10);
return Number.isFinite(parsed) ? parsed : undefined;
};
@@ -0,0 +1,286 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IForkedDaapdOutput, IForkedDaapdQueueItem, IForkedDaapdSnapshot } from './forked_daapd.types.js';
import { forkedDaapdDefaultMaxPlaylists, forkedDaapdSourceNameClear, forkedDaapdSourceNameDefault } from './forked_daapd.types.js';
export class ForkedDaapdMapper {
public static toDevices(snapshotArg: IForkedDaapdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const activeOutputs = snapshotArg.outputs.filter((outputArg) => outputArg.selected).length;
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.serverDeviceId(snapshotArg),
integrationDomain: 'forked_daapd',
name: this.serverName(snapshotArg),
protocol: 'http',
manufacturer: 'OwnTone',
model: 'OwnTone Server',
online: snapshotArg.online,
features: [
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
{ id: 'active_outputs', capability: 'media', name: 'Active outputs', readable: true, writable: true },
{ id: 'queue_count', capability: 'media', name: 'Queue count', 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: snapshotArg.player.volume, updatedAt },
{ featureId: 'active_outputs', value: activeOutputs, updatedAt },
{ featureId: 'queue_count', value: snapshotArg.queue.count, updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(snapshotArg) || null, updatedAt },
],
metadata: {
uniqueId: snapshotArg.server.id,
host: snapshotArg.server.host,
port: snapshotArg.server.port,
websocketPort: snapshotArg.server.websocketPort,
source: snapshotArg.source,
},
}];
for (const output of snapshotArg.outputs) {
devices.push({
id: this.outputDeviceId(snapshotArg, output),
integrationDomain: 'forked_daapd',
name: output.name,
protocol: 'http',
manufacturer: 'OwnTone',
model: output.type || 'Audio Output',
online: snapshotArg.online,
features: [
{ id: 'selected', capability: 'media', name: 'Selected', readable: true, writable: true },
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
],
state: [
{ featureId: 'selected', value: output.selected, updatedAt },
{ featureId: 'volume', value: output.volume, updatedAt },
],
metadata: {
serverId: snapshotArg.server.id,
outputId: output.id,
outputType: output.type,
requiresAuth: output.requires_auth,
needsAuthKey: output.needs_auth_key,
},
});
}
return devices;
}
public static toEntities(snapshotArg: IForkedDaapdSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const track = this.trackInfo(snapshotArg);
entities.push({
id: this.serverEntityId(snapshotArg),
uniqueId: `forked_daapd_${this.slug(snapshotArg.server.id)}_server`,
integrationDomain: 'forked_daapd',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'media_player',
name: `${this.serverName(snapshotArg)} server`,
state: this.mediaState(snapshotArg),
attributes: {
deviceClass: 'speaker',
volumeLevel: this.volumeLevel(snapshotArg.player.volume),
volume: snapshotArg.player.volume,
isVolumeMuted: snapshotArg.player.volume === 0,
repeat: snapshotArg.player.repeat,
shuffle: snapshotArg.player.shuffle,
source: this.currentSource(snapshotArg),
sourceList: this.sourceList(snapshotArg),
mediaContentId: snapshotArg.player.item_id,
mediaContentType: track?.media_kind,
mediaDuration: this.seconds(snapshotArg.player.item_length_ms),
mediaPosition: this.seconds(snapshotArg.player.item_progress_ms),
mediaTitle: this.mediaTitle(snapshotArg),
mediaArtist: track?.artist,
mediaAlbumName: this.mediaAlbumName(track),
mediaAlbumArtist: track?.album_artist || track?.albumartist,
mediaTrack: track?.track_number,
mediaImageUrl: this.mediaImageUrl(snapshotArg, track),
queueCount: snapshotArg.queue.count,
outputs: snapshotArg.outputs.map((outputArg) => ({ id: outputArg.id, name: outputArg.name, selected: outputArg.selected, volume: outputArg.volume, type: outputArg.type })),
},
available: snapshotArg.online,
});
for (const output of snapshotArg.outputs) {
entities.push({
id: this.outputEntityId(snapshotArg, output),
uniqueId: `forked_daapd_${this.slug(snapshotArg.server.id)}_output_${this.slug(output.id)}`,
integrationDomain: 'forked_daapd',
deviceId: this.outputDeviceId(snapshotArg, output),
platform: 'media_player',
name: `${this.serverName(snapshotArg)} output (${output.name})`,
state: output.selected ? 'on' : 'off',
attributes: {
outputId: output.id,
outputType: output.type,
volumeLevel: this.volumeLevel(output.volume),
volume: output.volume,
isVolumeMuted: output.volume === 0,
hasPassword: output.has_password,
requiresAuth: output.requires_auth,
needsAuthKey: output.needs_auth_key,
format: output.format,
supportedFormats: output.supported_formats,
},
available: snapshotArg.online,
});
}
entities.push({
id: `sensor.${this.serverEntityBase(snapshotArg)}_queue`,
uniqueId: `forked_daapd_${this.slug(snapshotArg.server.id)}_queue`,
integrationDomain: 'forked_daapd',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.serverName(snapshotArg)} queue`,
state: snapshotArg.queue.count,
attributes: {
version: snapshotArg.queue.version,
items: snapshotArg.queue.items,
},
available: snapshotArg.online,
});
return entities;
}
public static entityTarget(snapshotArg: IForkedDaapdSnapshot, entityIdArg: string): { kind: 'server' } | { kind: 'output'; outputId: string } | undefined {
if (entityIdArg === this.serverEntityId(snapshotArg)) {
return { kind: 'server' };
}
const output = snapshotArg.outputs.find((outputArg) => this.outputEntityId(snapshotArg, outputArg) === entityIdArg);
return output ? { kind: 'output', outputId: output.id } : undefined;
}
public static deviceTarget(snapshotArg: IForkedDaapdSnapshot, deviceIdArg: string): { kind: 'server' } | { kind: 'output'; outputId: string } | undefined {
if (deviceIdArg === this.serverDeviceId(snapshotArg)) {
return { kind: 'server' };
}
const output = snapshotArg.outputs.find((outputArg) => this.outputDeviceId(snapshotArg, outputArg) === deviceIdArg);
return output ? { kind: 'output', outputId: output.id } : undefined;
}
public static serverEntityId(snapshotArg: IForkedDaapdSnapshot): string {
return `media_player.${this.serverEntityBase(snapshotArg)}`;
}
public static outputEntityId(snapshotArg: IForkedDaapdSnapshot, outputArg: IForkedDaapdOutput): string {
return `media_player.${this.serverEntityBase(snapshotArg)}_${this.slug(outputArg.name || outputArg.id)}`;
}
public static serverDeviceId(snapshotArg: IForkedDaapdSnapshot): string {
return `forked_daapd.server.${this.slug(snapshotArg.server.id || snapshotArg.server.name)}`;
}
public static outputDeviceId(snapshotArg: IForkedDaapdSnapshot, outputArg: IForkedDaapdOutput): string {
return `forked_daapd.output.${this.slug(snapshotArg.server.id)}.${this.slug(outputArg.id)}`;
}
public static slug(valueArg: string | number | undefined): string {
return String(valueArg || 'owntone').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'owntone';
}
public static sourceList(snapshotArg: IForkedDaapdSnapshot): string[] {
return Object.keys(this.sourceUris(snapshotArg));
}
public static sourceUris(snapshotArg: IForkedDaapdSnapshot): Record<string, string | undefined> {
const sources: Record<string, string | undefined> = {
[forkedDaapdSourceNameClear]: undefined,
[forkedDaapdSourceNameDefault]: undefined,
};
for (const pipe of snapshotArg.library.pipes) {
const title = this.stringValue(pipe.title) || this.stringValue(pipe.name);
if (title && pipe.uri) {
sources[`${title} (pipe)`] = pipe.uri;
}
}
for (const playlist of snapshotArg.library.playlists.slice(0, snapshotArg.maxPlaylists || forkedDaapdDefaultMaxPlaylists)) {
const name = this.stringValue(playlist.name) || this.stringValue(playlist.title);
if (name && playlist.uri) {
sources[`${name} (playlist)`] = playlist.uri;
}
}
return sources;
}
private static serverEntityBase(snapshotArg: IForkedDaapdSnapshot): string {
return `${this.slug(this.serverName(snapshotArg))}_owntone`;
}
private static serverName(snapshotArg: IForkedDaapdSnapshot): string {
return snapshotArg.server.name || snapshotArg.server.libraryName || snapshotArg.server.host || 'OwnTone';
}
private static mediaState(snapshotArg: IForkedDaapdSnapshot): string {
if (!snapshotArg.online) {
return 'off';
}
if (snapshotArg.player.state === 'play') {
return 'playing';
}
if (snapshotArg.player.state === 'pause') {
return 'paused';
}
if (!snapshotArg.outputs.some((outputArg) => outputArg.selected)) {
return 'off';
}
if (snapshotArg.player.state === 'stop') {
return 'idle';
}
return snapshotArg.player.state || 'unknown';
}
private static currentSource(snapshotArg: IForkedDaapdSnapshot): string {
const first = snapshotArg.queue.items[0];
if (first?.data_kind === 'pipe' && first.title) {
return `${first.title} (pipe)`;
}
return forkedDaapdSourceNameDefault;
}
private static trackInfo(snapshotArg: IForkedDaapdSnapshot): IForkedDaapdQueueItem | undefined {
return snapshotArg.queue.items.find((itemArg) => String(itemArg.id) === String(snapshotArg.player.item_id));
}
private static mediaTitle(snapshotArg: IForkedDaapdSnapshot): string | undefined {
const track = this.trackInfo(snapshotArg);
if (track?.data_kind === 'url') {
return track.album;
}
return track?.title;
}
private static mediaAlbumName(trackArg: IForkedDaapdQueueItem | undefined): string | undefined {
if (trackArg?.data_kind === 'url') {
return trackArg.title;
}
return trackArg?.album;
}
private static mediaImageUrl(snapshotArg: IForkedDaapdSnapshot, trackArg: IForkedDaapdQueueItem | undefined): string | undefined {
if (!trackArg?.artwork_url) {
return undefined;
}
if (/^https?:\/\//i.test(trackArg.artwork_url)) {
return trackArg.artwork_url;
}
if (!snapshotArg.server.host) {
return trackArg.artwork_url;
}
return `http://${snapshotArg.server.host}:${snapshotArg.server.port || 3689}${trackArg.artwork_url.startsWith('/') ? trackArg.artwork_url : `/${trackArg.artwork_url}`}`;
}
private static volumeLevel(volumeArg: number | undefined): number | undefined {
return typeof volumeArg === 'number' ? Math.max(0, Math.min(1, volumeArg / 100)) : undefined;
}
private static seconds(valueArg: number | undefined): number | undefined {
return typeof valueArg === 'number' ? Math.floor(valueArg / 1000) : undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
}
@@ -1,4 +1,218 @@
export interface IHomeAssistantForkedDaapdConfig {
// TODO: replace with the TypeScript-native config for forked_daapd.
export const forkedDaapdDefaultPort = 3689;
export const forkedDaapdDefaultTimeoutMs = 5000;
export const forkedDaapdDefaultUnmuteVolume = 0.6;
export const forkedDaapdDefaultMaxPlaylists = 10;
export const forkedDaapdSourceNameClear = 'Clear queue';
export const forkedDaapdSourceNameDefault = 'Default (no pipe)';
export type TForkedDaapdProtocol = 'http';
export type TForkedDaapdSnapshotSource = 'snapshot' | 'http' | 'executor' | 'websocket' | 'runtime';
export type TForkedDaapdPlayerState = 'play' | 'pause' | 'stop' | (string & {});
export type TForkedDaapdRepeatMode = 'off' | 'all' | 'single' | (string & {});
export type TForkedDaapdCommand =
| 'play'
| 'pause'
| 'stop'
| 'next_track'
| 'previous_track'
| 'seek'
| 'set_volume'
| 'mute'
| 'shuffle'
| 'repeat'
| 'clear_queue'
| 'play_media'
| 'select_source'
| 'turn_on'
| 'turn_off'
| 'set_output'
| 'set_enabled_outputs'
| 'raw_request';
export interface IForkedDaapdConfig {
host?: string;
port?: number;
password?: string;
name?: string;
uniqueId?: string;
timeoutMs?: number;
maxPlaylists?: number;
snapshot?: IForkedDaapdSnapshot;
commandExecutor?: IForkedDaapdCommandExecutor;
}
export interface IHomeAssistantForkedDaapdConfig extends IForkedDaapdConfig {}
export interface IForkedDaapdCommandExecutor {
execute(requestArg: IForkedDaapdRawCommandRequest): Promise<IForkedDaapdCommandExecutorResult | unknown>;
}
export interface IForkedDaapdRawCommandRequest {
method: 'GET' | 'PUT' | 'POST' | 'DELETE';
endpoint: string;
url?: string;
host?: string;
port: number;
protocol: TForkedDaapdProtocol;
params?: Record<string, string | number | boolean | undefined>;
body?: unknown;
password?: string;
expectJson?: boolean;
}
export interface IForkedDaapdCommandExecutorResult {
ok?: boolean;
success?: boolean;
status?: number;
response?: string;
data?: unknown;
}
export interface IForkedDaapdCommandRequest {
command: TForkedDaapdCommand;
outputId?: string;
outputIds?: string[];
selected?: boolean;
volumeLevel?: number;
volume?: number;
muted?: boolean;
shuffle?: boolean;
repeat?: TForkedDaapdRepeatMode;
position?: number;
positionMs?: number;
mediaId?: string;
mediaType?: string;
uri?: string;
source?: string;
clear?: boolean;
playback?: 'start' | 'stop' | string;
method?: IForkedDaapdRawCommandRequest['method'];
endpoint?: string;
params?: Record<string, string | number | boolean | undefined>;
body?: unknown;
expectJson?: boolean;
}
export interface IForkedDaapdServerConfig {
library_name?: string;
websocket_port?: number;
version?: string;
[key: string]: unknown;
}
export interface IForkedDaapdServerInfo {
id: string;
name: string;
host?: string;
port?: number;
protocol: TForkedDaapdProtocol;
libraryName?: string;
websocketPort?: number;
version?: string;
config?: IForkedDaapdServerConfig;
}
export interface IForkedDaapdPlayerStatus {
state: TForkedDaapdPlayerState;
repeat: TForkedDaapdRepeatMode;
consume: boolean;
shuffle: boolean;
volume: number;
item_id: number;
item_length_ms: number;
item_progress_ms: number;
[key: string]: unknown;
}
export interface IForkedDaapdQueueItem {
id: number | string;
position?: number;
track_id?: number | string;
title?: string;
artist?: string;
album?: string;
album_artist?: string;
albumartist?: string;
genre?: string;
track_number?: number;
length_ms?: number;
media_kind?: string;
data_kind?: string;
path?: string;
uri?: string;
artwork_url?: string;
[key: string]: unknown;
}
export interface IForkedDaapdQueueStatus {
version: number;
count: number;
items: IForkedDaapdQueueItem[];
[key: string]: unknown;
}
export interface IForkedDaapdOutput {
id: string;
name: string;
type?: string;
selected: boolean;
volume: number;
has_password?: boolean;
requires_auth?: boolean;
needs_auth_key?: boolean;
offset_ms?: number;
format?: string;
supported_formats?: string[];
[key: string]: unknown;
}
export interface IForkedDaapdLibraryItem {
id?: number | string;
name?: string;
title?: string;
uri?: string;
data_kind?: string;
artwork_url?: string;
[key: string]: unknown;
}
export interface IForkedDaapdLibrarySnapshot {
playlists: IForkedDaapdLibraryItem[];
pipes: IForkedDaapdLibraryItem[];
}
export interface IForkedDaapdSnapshot {
server: IForkedDaapdServerInfo;
player: IForkedDaapdPlayerStatus;
queue: IForkedDaapdQueueStatus;
outputs: IForkedDaapdOutput[];
library: IForkedDaapdLibrarySnapshot;
online: boolean;
updatedAt?: string;
source?: TForkedDaapdSnapshotSource;
maxPlaylists?: number;
error?: string;
raw?: Record<string, unknown>;
}
export interface IForkedDaapdMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
port?: number;
addresses?: string[];
txt?: Record<string, unknown>;
properties?: Record<string, unknown>;
}
export interface IForkedDaapdManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
password?: string;
uniqueId?: string;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './forked_daapd.classes.client.js';
export * from './forked_daapd.classes.configflow.js';
export * from './forked_daapd.classes.integration.js';
export * from './forked_daapd.discovery.js';
export * from './forked_daapd.mapper.js';
export * from './forked_daapd.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IFrontierSiliconConfig, TFrontierSiliconProtocol } from './frontier_silicon.types.js';
import { frontierSiliconDefaultPin, frontierSiliconDefaultPort, frontierSiliconDefaultTimeoutMs } from './frontier_silicon.types.js';
export class FrontierSiliconConfigFlow implements IConfigFlow<IFrontierSiliconConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IFrontierSiliconConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Frontier Silicon Radio',
description: 'Configure a local UNDOK/FSAPI endpoint. The default PIN is 1234 unless changed on the device.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'pin', label: 'PIN', type: 'password' },
{ name: 'webfsapiUrl', label: 'Web FSAPI URL', type: 'text' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'timeoutMs', label: 'Timeout in milliseconds', type: 'number' },
],
submit: async (valuesArg) => {
const metadata = candidateArg.metadata || {};
const candidateWebfsapiUrl = this.stringValue(metadata.webfsapiUrl);
const candidateDeviceUrl = this.stringValue(metadata.deviceUrl) || this.stringValue(metadata.location);
const webfsapiUrl = this.stringValue(valuesArg.webfsapiUrl) || candidateWebfsapiUrl;
const endpointUrl = this.parseUrl(webfsapiUrl) || this.parseUrl(candidateDeviceUrl);
const host = this.stringValue(valuesArg.host) || candidateArg.host || endpointUrl?.hostname;
if (!host && !webfsapiUrl) {
return { kind: 'error', title: 'Frontier Silicon setup failed', error: 'A host or Web FSAPI URL is required.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || this.urlPort(endpointUrl) || frontierSiliconDefaultPort;
const protocol = this.protocolValue(metadata.protocol) || this.protocolFromUrl(endpointUrl) || 'http';
return {
kind: 'done',
title: 'Frontier Silicon configured',
config: {
host,
port,
protocol,
pin: this.stringValue(valuesArg.pin) || frontierSiliconDefaultPin,
deviceUrl: candidateDeviceUrl || (host ? this.deviceUrl(protocol, host, port) : undefined),
webfsapiUrl,
name: this.stringValue(valuesArg.name) || candidateArg.name,
uniqueId: candidateArg.id,
manufacturer: candidateArg.manufacturer,
model: candidateArg.model,
timeoutMs: this.numberValue(valuesArg.timeoutMs) || frontierSiliconDefaultTimeoutMs,
},
};
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
}
return undefined;
}
private parseUrl(valueArg: string | undefined): URL | undefined {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
}
private protocolValue(valueArg: unknown): TFrontierSiliconProtocol | undefined {
return valueArg === 'https' ? 'https' : valueArg === 'http' ? 'http' : undefined;
}
private protocolFromUrl(urlArg: URL | undefined): TFrontierSiliconProtocol | undefined {
return urlArg?.protocol === 'https:' ? 'https' : urlArg?.protocol === 'http:' ? 'http' : undefined;
}
private urlPort(urlArg: URL | undefined): number | undefined {
return urlArg?.port ? Number(urlArg.port) : undefined;
}
private deviceUrl(protocolArg: TFrontierSiliconProtocol, hostArg: string, portArg: number): string {
const includePort = !(protocolArg === 'http' && portArg === 80) && !(protocolArg === 'https' && portArg === 443);
const host = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
return `${protocolArg}://${host}${includePort ? `:${portArg}` : ''}/device`;
}
}
@@ -1,26 +1,222 @@
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 { FrontierSiliconClient } from './frontier_silicon.classes.client.js';
import { FrontierSiliconConfigFlow } from './frontier_silicon.classes.configflow.js';
import { createFrontierSiliconDiscoveryDescriptor } from './frontier_silicon.discovery.js';
import { FrontierSiliconMapper } from './frontier_silicon.mapper.js';
import type { IFrontierSiliconCommandRequest, IFrontierSiliconConfig, IFrontierSiliconSnapshot, TFrontierSiliconCommand, TFrontierSiliconRepeatMode } from './frontier_silicon.types.js';
export class HomeAssistantFrontierSiliconIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "frontier_silicon",
displayName: "Frontier Silicon",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/frontier_silicon",
"upstreamDomain": "frontier_silicon",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"afsapi==1.0.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@wlcrs"
]
},
});
export class FrontierSiliconIntegration extends BaseIntegration<IFrontierSiliconConfig> {
public readonly domain = 'frontier_silicon';
public readonly displayName = 'Frontier Silicon';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createFrontierSiliconDiscoveryDescriptor();
public readonly configFlow = new FrontierSiliconConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/frontier_silicon',
upstreamDomain: 'frontier_silicon',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['afsapi==1.0.0'],
dependencies: [],
afterDependencies: [],
codeowners: ['@wlcrs'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/frontier_silicon',
ssdp: [{ st: 'urn:schemas-frontier-silicon-com:undok:fsapi:1' }],
};
public async setup(configArg: IFrontierSiliconConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new FrontierSiliconRuntime(new FrontierSiliconClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantFrontierSiliconIntegration extends FrontierSiliconIntegration {}
class FrontierSiliconRuntime implements IIntegrationRuntime {
public domain = 'frontier_silicon';
constructor(private readonly client: FrontierSiliconClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return FrontierSiliconMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return FrontierSiliconMapper.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 === 'frontier_silicon') {
return await this.callFrontierSiliconService(requestArg);
}
return { success: false, error: `Unsupported Frontier Silicon 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 === 'turn_on') {
return { success: true, data: await this.client.execute({ command: 'set_power', powered: true }) };
}
if (requestArg.service === 'turn_off') {
return { success: true, data: await this.client.execute({ command: 'set_power', powered: false }) };
}
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported Frontier Silicon media_player service: ${requestArg.service}` };
}
return { success: true, data: await this.client.execute(this.commandRequest(command, requestArg)) };
}
private async callFrontierSiliconService(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 IFrontierSiliconSnapshot | undefined);
return { success: true };
}
if (requestArg.service === 'play_preset') {
return { success: true, data: await this.client.execute({ command: 'play_preset', preset: this.numberData(requestArg, 'preset') ?? this.numberData(requestArg, 'preset_number'), presetIndex: this.numberData(requestArg, 'preset_index') }) };
}
if (requestArg.service === 'command' || requestArg.service === 'raw_command') {
return { success: true, data: await this.client.execute({ command: 'raw_command', path: this.stringData(requestArg, 'path') || this.stringData(requestArg, 'command'), node: this.stringData(requestArg, 'node'), value: this.primitiveData(requestArg, 'value'), params: this.paramsData(requestArg) }) };
}
if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off' || this.commandFromMediaService(requestArg.service)) {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported Frontier Silicon service: ${requestArg.service}` };
}
private commandFromMediaService(serviceArg: string): TFrontierSiliconCommand | undefined {
if (serviceArg === 'media_play' || serviceArg === 'play') {
return 'play';
}
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
return 'pause';
}
if (serviceArg === 'media_stop' || serviceArg === 'stop') {
return 'stop';
}
if (serviceArg === 'media_next_track' || serviceArg === 'next_track' || serviceArg === 'next') {
return 'next_track';
}
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track' || serviceArg === 'previous') {
return 'previous_track';
}
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
return 'set_volume';
}
if (serviceArg === 'volume_up') {
return 'volume_up';
}
if (serviceArg === 'volume_down') {
return 'volume_down';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'select_source' || serviceArg === 'source') {
return 'select_source';
}
if (serviceArg === 'select_sound_mode' || serviceArg === 'set_sound_mode') {
return 'set_sound_mode';
}
if (serviceArg === 'repeat_set' || serviceArg === 'set_repeat') {
return 'set_repeat';
}
if (serviceArg === 'shuffle_set' || serviceArg === 'set_shuffle') {
return 'set_shuffle';
}
if (serviceArg === 'media_seek' || serviceArg === 'seek') {
return 'seek';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
return undefined;
}
private commandRequest(commandArg: TFrontierSiliconCommand, requestArg: IServiceCallRequest): IFrontierSiliconCommandRequest {
const base: IFrontierSiliconCommandRequest = { command: commandArg };
if (commandArg === 'set_volume') {
base.volumeLevel = this.numberData(requestArg, 'volume_level');
base.volume = this.numberData(requestArg, 'volume');
}
if (commandArg === 'mute') {
base.muted = this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute');
}
if (commandArg === 'select_source') {
base.source = this.stringData(requestArg, 'source');
}
if (commandArg === 'set_sound_mode') {
base.soundMode = this.stringData(requestArg, 'sound_mode') || this.stringData(requestArg, 'soundMode');
}
if (commandArg === 'set_repeat') {
const repeat = this.stringData(requestArg, 'repeat') || this.stringData(requestArg, 'repeat_mode');
if (repeat === 'off' || repeat === 'all' || repeat === 'one') {
base.repeat = repeat as TFrontierSiliconRepeatMode;
}
}
if (commandArg === 'set_shuffle') {
base.shuffle = this.booleanData(requestArg, 'shuffle');
}
if (commandArg === 'seek') {
base.position = this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position');
}
if (commandArg === 'play_media') {
base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId');
base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
}
return base;
}
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 primitiveData(requestArg: IServiceCallRequest, keyArg: string): string | number | boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : undefined;
}
private paramsData(requestArg: IServiceCallRequest): Record<string, string | number | boolean> | undefined {
const params = requestArg.data?.params;
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return undefined;
}
const result: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
result[key] = value;
}
}
return result;
}
}
@@ -0,0 +1,194 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IFrontierSiliconManualEntry, IFrontierSiliconSsdpRecord, TFrontierSiliconProtocol } from './frontier_silicon.types.js';
import { frontierSiliconDefaultPort, frontierSiliconSsdpSt } from './frontier_silicon.types.js';
const frontierSiliconDomain = 'frontier_silicon';
const frontierSiliconNames = ['frontier silicon', 'frontier_silicon', 'frontier-silicon', 'undok', 'fsapi', 'hama', 'medion', 'auna'];
export class FrontierSiliconSsdpMatcher implements IDiscoveryMatcher<IFrontierSiliconSsdpRecord> {
public id = 'frontier_silicon-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Frontier Silicon UNDOK FSAPI SSDP advertisements.';
public async matches(recordArg: IFrontierSiliconSsdpRecord): Promise<IDiscoveryMatch> {
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
const usn = header(recordArg, 'usn');
const location = header(recordArg, 'location') || recordArg.ssdpLocation;
const speakerName = header(recordArg, 'speaker-name') || header(recordArg, 'SPEAKER-NAME');
const friendlyName = upnp(recordArg, 'friendlyName') || speakerName;
const manufacturer = upnp(recordArg, 'manufacturer') || 'Frontier Silicon';
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
const haystack = `${st || ''} ${usn || ''} ${friendlyName || ''} ${manufacturer || ''} ${model || ''}`.toLowerCase();
const exactMatch = normalizeUrn(st) === normalizeUrn(frontierSiliconSsdpSt);
const hintMatch = haystack.includes('frontier-silicon-com') || haystack.includes('undok') || haystack.includes('fsapi');
if (!exactMatch && !hintMatch) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Frontier Silicon FSAPI advertisement.' };
}
const url = parseUrl(location);
const host = url?.hostname;
const port = urlPort(url) || frontierSiliconDefaultPort;
const id = stripUuid(usn) || (host ? `${host}:${port}` : friendlyName);
return {
matched: true,
confidence: exactMatch && host ? 'certain' : host ? 'high' : 'medium',
reason: exactMatch ? 'SSDP ST matches Frontier Silicon FSAPI.' : 'SSDP metadata contains Frontier Silicon FSAPI hints.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: frontierSiliconDomain,
id,
host,
port,
name: friendlyName,
manufacturer,
model,
metadata: {
st,
usn,
location,
deviceUrl: location,
speakerName,
protocol: protocolFromUrl(url),
frontierSilicon: true,
},
},
metadata: { st, location, speakerName },
};
}
}
export class FrontierSiliconManualMatcher implements IDiscoveryMatcher<IFrontierSiliconManualEntry> {
public id = 'frontier_silicon-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Frontier Silicon FSAPI setup entries.';
public async matches(inputArg: IFrontierSiliconManualEntry): Promise<IDiscoveryMatch> {
const webfsapiUrl = inputArg.webfsapiUrl || stringMetadata(inputArg.metadata?.webfsapiUrl);
const deviceUrl = inputArg.deviceUrl || stringMetadata(inputArg.metadata?.deviceUrl);
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${webfsapiUrl || ''} ${deviceUrl || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || webfsapiUrl || deviceUrl || inputArg.metadata?.frontierSilicon || inputArg.metadata?.frontier_silicon || frontierSiliconNames.some((needleArg) => haystack.includes(needleArg)));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Frontier Silicon setup hints.' };
}
const endpointUrl = parseUrl(webfsapiUrl) || parseUrl(deviceUrl);
const host = inputArg.host || endpointUrl?.hostname;
const port = inputArg.port || urlPort(endpointUrl) || frontierSiliconDefaultPort;
const id = inputArg.radioId || inputArg.id || (host ? `${host}:${port}` : webfsapiUrl || deviceUrl);
return {
matched: true,
confidence: host || webfsapiUrl ? 'high' : 'medium',
reason: 'Manual entry can start Frontier Silicon setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: frontierSiliconDomain,
id,
host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Frontier Silicon',
model: inputArg.model,
metadata: {
...inputArg.metadata,
protocol: inputArg.protocol || protocolFromUrl(endpointUrl),
pin: inputArg.pin ? true : undefined,
deviceUrl,
webfsapiUrl,
frontierSilicon: true,
},
},
};
}
}
export class FrontierSiliconCandidateValidator implements IDiscoveryValidator {
public id = 'frontier_silicon-candidate-validator';
public description = 'Validate Frontier Silicon candidates have local FSAPI setup metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const st = stringMetadata(metadata.st);
const webfsapiUrl = stringMetadata(metadata.webfsapiUrl);
const deviceUrl = stringMetadata(metadata.deviceUrl) || stringMetadata(metadata.location);
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${webfsapiUrl || ''} ${deviceUrl || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === frontierSiliconDomain
|| Boolean(metadata.frontierSilicon || metadata.frontier_silicon)
|| normalizeUrn(st) === normalizeUrn(frontierSiliconSsdpSt)
|| frontierSiliconNames.some((needleArg) => haystack.includes(needleArg));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Candidate is not Frontier Silicon.' };
}
if (!candidateArg.host && !webfsapiUrl && !deviceUrl) {
return { matched: false, confidence: 'medium', reason: 'Frontier Silicon candidate lacks host, device URL, or webfsapi URL.' };
}
const endpointUrl = parseUrl(webfsapiUrl) || parseUrl(deviceUrl);
const port = candidateArg.port || urlPort(endpointUrl) || frontierSiliconDefaultPort;
return {
matched: true,
confidence: candidateArg.id ? 'certain' : 'high',
reason: 'Candidate has Frontier Silicon FSAPI metadata.',
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${port}` : webfsapiUrl || deviceUrl),
candidate: {
...candidateArg,
port,
metadata: {
...metadata,
protocol: stringMetadata(metadata.protocol) || protocolFromUrl(endpointUrl),
},
},
};
}
}
export const createFrontierSiliconDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: frontierSiliconDomain, displayName: 'Frontier Silicon' })
.addMatcher(new FrontierSiliconSsdpMatcher())
.addMatcher(new FrontierSiliconManualMatcher())
.addValidator(new FrontierSiliconCandidateValidator());
};
const header = (recordArg: IFrontierSiliconSsdpRecord, keyArg: string): string | undefined => {
return recordArg[keyArg as keyof IFrontierSiliconSsdpRecord] as string | undefined
|| valueForKey(recordArg.headers, keyArg)
|| valueForKey(recordArg.ssdpHeaders, keyArg);
};
const upnp = (recordArg: IFrontierSiliconSsdpRecord, keyArg: string): string | undefined => {
return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg) || valueForKey(recordArg.ssdpHeaders, keyArg);
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const parseUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const stripUuid = (valueArg: string | undefined): string | undefined => valueArg?.replace(/^uuid:/i, '').split('::')[0];
const normalizeUrn = (valueArg: string | undefined): string => (valueArg || '').toLowerCase();
const urlPort = (urlArg: URL | undefined): number | undefined => urlArg?.port ? Number(urlArg.port) : undefined;
const protocolFromUrl = (urlArg: URL | undefined): TFrontierSiliconProtocol | undefined => urlArg?.protocol === 'https:' ? 'https' : urlArg?.protocol === 'http:' ? 'http' : undefined;
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg ? valueArg : undefined;
@@ -0,0 +1,186 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IFrontierSiliconDeviceInfo, IFrontierSiliconPlayerState, IFrontierSiliconSnapshot } from './frontier_silicon.types.js';
export class FrontierSiliconMapper {
public static toDevices(snapshotArg: IFrontierSiliconSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const player = snapshotArg.player;
return [{
id: this.deviceId(snapshotArg),
integrationDomain: 'frontier_silicon',
name: this.deviceName(snapshotArg.device),
protocol: 'http',
manufacturer: snapshotArg.device.manufacturer || 'Frontier Silicon',
model: snapshotArg.device.model || 'FSAPI Radio',
online: snapshotArg.online,
features: [
{ id: 'power', capability: 'switch', name: 'Power', readable: true, writable: true },
{ 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: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
{ id: 'sound_mode', capability: 'media', name: 'Sound mode', readable: true, writable: true },
{ id: 'presets', capability: 'media', name: 'Presets', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
],
state: [
{ featureId: 'power', value: player.power ?? null, updatedAt },
{ featureId: 'playback', value: this.mediaState(snapshotArg, player), updatedAt },
{ featureId: 'volume', value: this.volumePercent(player) ?? null, updatedAt },
{ featureId: 'muted', value: player.muted ?? null, updatedAt },
{ featureId: 'source', value: player.source || null, updatedAt },
{ featureId: 'sound_mode', value: player.soundMode?.label || null, updatedAt },
{ featureId: 'presets', value: snapshotArg.presets.length, updatedAt },
{ featureId: 'current_title', value: player.mediaTitle || null, updatedAt },
],
metadata: {
radioId: snapshotArg.device.radioId,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
deviceUrl: snapshotArg.device.deviceUrl,
webfsapiUrl: snapshotArg.device.webfsapiUrl,
version: snapshotArg.device.version,
macAddress: snapshotArg.device.macAddress,
rssi: snapshotArg.device.rssi,
source: snapshotArg.source,
error: snapshotArg.error,
},
}];
}
public static toEntities(snapshotArg: IFrontierSiliconSnapshot): IIntegrationEntity[] {
const base = this.entityBase(snapshotArg);
const player = snapshotArg.player;
const available = snapshotArg.online;
return [{
id: this.mediaPlayerEntityId(snapshotArg),
uniqueId: `frontier_silicon_${this.uniqueBase(snapshotArg)}`,
integrationDomain: 'frontier_silicon',
deviceId: this.deviceId(snapshotArg),
platform: 'media_player',
name: this.deviceName(snapshotArg.device),
state: this.mediaState(snapshotArg, player),
attributes: {
deviceClass: 'speaker',
radioId: snapshotArg.device.radioId,
model: snapshotArg.device.model,
version: snapshotArg.device.version,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
volumeLevel: this.volumeLevel(player),
volume: player.volume,
maxVolume: player.maxVolume,
isVolumeMuted: player.muted,
source: player.source,
sourceList: snapshotArg.modes.map((modeArg) => modeArg.label),
sourceModes: snapshotArg.modes,
soundMode: player.soundMode?.label,
soundModeList: snapshotArg.equalisers.map((equaliserArg) => equaliserArg.label),
repeat: player.repeat,
shuffle: player.shuffle,
mediaContentType: 'channel',
mediaTitle: player.mediaTitle,
mediaName: player.mediaName,
mediaText: player.mediaText,
mediaArtist: player.mediaArtist,
mediaAlbumName: player.mediaAlbum,
mediaImageUrl: player.mediaImageUrl,
mediaDuration: player.mediaDuration,
mediaPosition: player.mediaPosition,
presets: snapshotArg.presets,
},
available,
}, {
id: `sensor.${base}_frontier_silicon_media`,
uniqueId: `frontier_silicon_${this.uniqueBase(snapshotArg)}_media`,
integrationDomain: 'frontier_silicon',
deviceId: this.deviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg.device)} Frontier Silicon Media`,
state: player.mediaTitle || player.mediaName || 'None',
attributes: {
radioId: snapshotArg.device.radioId,
mediaName: player.mediaName,
mediaText: player.mediaText,
mediaArtist: player.mediaArtist,
mediaAlbum: player.mediaAlbum,
mediaImageUrl: player.mediaImageUrl,
},
available,
}, {
id: `sensor.${base}_frontier_silicon_sources`,
uniqueId: `frontier_silicon_${this.uniqueBase(snapshotArg)}_sources`,
integrationDomain: 'frontier_silicon',
deviceId: this.deviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg.device)} Frontier Silicon Sources`,
state: snapshotArg.modes.length,
attributes: {
sourceList: snapshotArg.modes.map((modeArg) => modeArg.label),
modes: snapshotArg.modes,
},
available,
}, {
id: `sensor.${base}_frontier_silicon_presets`,
uniqueId: `frontier_silicon_${this.uniqueBase(snapshotArg)}_presets`,
integrationDomain: 'frontier_silicon',
deviceId: this.deviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg.device)} Frontier Silicon Presets`,
state: snapshotArg.presets.length,
attributes: {
presets: snapshotArg.presets,
},
available,
}];
}
public static entityDeviceId(snapshotArg: IFrontierSiliconSnapshot, entityIdArg: string): string | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
return entity?.deviceId;
}
public static mediaPlayerEntityId(snapshotArg: IFrontierSiliconSnapshot): string {
return `media_player.${this.entityBase(snapshotArg)}`;
}
public static deviceId(snapshotArg: IFrontierSiliconSnapshot): string {
return `frontier_silicon.radio.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'frontier_silicon').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'frontier_silicon';
}
private static uniqueBase(snapshotArg: IFrontierSiliconSnapshot): string {
return this.slug(snapshotArg.device.radioId || snapshotArg.device.id || snapshotArg.device.host || this.deviceName(snapshotArg.device));
}
private static entityBase(snapshotArg: IFrontierSiliconSnapshot): string {
return this.slug(this.deviceName(snapshotArg.device));
}
private static deviceName(deviceArg: IFrontierSiliconDeviceInfo): string {
return deviceArg.name || deviceArg.host || 'Frontier Silicon';
}
private static mediaState(snapshotArg: IFrontierSiliconSnapshot, playerArg: IFrontierSiliconPlayerState): string {
if (!snapshotArg.online || playerArg.power === false || playerArg.playState === 'off') {
return 'off';
}
return playerArg.playState || 'unknown';
}
private static volumeLevel(playerArg: IFrontierSiliconPlayerState): number | undefined {
if (typeof playerArg.volume !== 'number' || typeof playerArg.maxVolume !== 'number' || playerArg.maxVolume <= 0) {
return undefined;
}
return Math.max(0, Math.min(1, playerArg.volume / playerArg.maxVolume));
}
private static volumePercent(playerArg: IFrontierSiliconPlayerState): number | undefined {
const level = this.volumeLevel(playerArg);
return typeof level === 'number' ? Math.round(level * 100) : undefined;
}
}
@@ -1,4 +1,194 @@
export interface IHomeAssistantFrontierSiliconConfig {
// TODO: replace with the TypeScript-native config for frontier_silicon.
export const frontierSiliconDefaultPin = '1234';
export const frontierSiliconDefaultPort = 80;
export const frontierSiliconDefaultTimeoutMs = 15000;
export const frontierSiliconSsdpSt = 'urn:schemas-frontier-silicon-com:undok:fsapi:1';
export type TFrontierSiliconProtocol = 'http' | 'https';
export type TFrontierSiliconSnapshotSource = 'snapshot' | 'http' | 'executor' | 'runtime' | 'manual';
export type TFrontierSiliconPlayState = 'off' | 'idle' | 'buffering' | 'playing' | 'paused' | 'unknown' | (string & {});
export type TFrontierSiliconRepeatMode = 'off' | 'all' | 'one';
export type TFrontierSiliconFsapiOperation = 'GET' | 'SET' | 'LIST_GET_NEXT' | 'CREATE_SESSION';
export type TFrontierSiliconCommand =
| 'play'
| 'pause'
| 'stop'
| 'previous_track'
| 'next_track'
| 'set_power'
| 'set_volume'
| 'volume_up'
| 'volume_down'
| 'mute'
| 'select_source'
| 'set_sound_mode'
| 'set_repeat'
| 'set_shuffle'
| 'seek'
| 'play_preset'
| 'play_media'
| 'raw_command';
export interface IFrontierSiliconConfig {
host?: string;
port?: number;
protocol?: TFrontierSiliconProtocol;
pin?: string;
deviceUrl?: string;
webfsapiUrl?: string;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
timeoutMs?: number;
snapshot?: IFrontierSiliconSnapshot;
commandExecutor?: IFrontierSiliconCommandExecutor;
}
export interface IHomeAssistantFrontierSiliconConfig extends IFrontierSiliconConfig {}
export interface IFrontierSiliconCommandExecutor {
execute(requestArg: IFrontierSiliconRawCommandRequest): Promise<IFrontierSiliconCommandExecutorResult | unknown>;
}
export interface IFrontierSiliconRawCommandRequest {
operation: TFrontierSiliconFsapiOperation;
path: string;
node?: string;
value?: string | number | boolean;
params: Record<string, string | number | boolean>;
url?: string;
method: 'GET';
host?: string;
port?: number;
protocol?: TFrontierSiliconProtocol;
webfsapiUrl?: string;
pin: string;
sessionId?: string;
}
export interface IFrontierSiliconCommandExecutorResult {
ok?: boolean;
success?: boolean;
status?: number;
response?: string;
text?: string;
data?: unknown;
}
export interface IFrontierSiliconCommandRequest {
command: TFrontierSiliconCommand;
powered?: boolean;
volumeLevel?: number;
volume?: number;
muted?: boolean;
source?: string;
soundMode?: string;
repeat?: TFrontierSiliconRepeatMode;
shuffle?: boolean;
position?: number;
preset?: number;
presetIndex?: number;
mediaId?: string;
mediaType?: string;
path?: string;
node?: string;
value?: string | number | boolean;
params?: Record<string, string | number | boolean>;
}
export interface IFrontierSiliconDeviceInfo {
id: string;
name: string;
host?: string;
port?: number;
protocol?: TFrontierSiliconProtocol;
deviceUrl?: string;
webfsapiUrl?: string;
manufacturer?: string;
model?: string;
version?: string;
radioId?: string;
macAddress?: string;
rssi?: number;
}
export interface IFrontierSiliconMode {
key: string;
id: string;
label: string;
selectable?: number;
streamable?: number;
modetype?: number;
}
export interface IFrontierSiliconEqualiser {
key: string;
label: string;
}
export interface IFrontierSiliconPreset {
key: number;
type?: string;
name?: string;
}
export interface IFrontierSiliconPlayerState {
power?: boolean;
playState: TFrontierSiliconPlayState;
playStatusCode?: number;
volume?: number;
maxVolume?: number;
muted?: boolean;
mode?: IFrontierSiliconMode;
source?: string;
repeat?: TFrontierSiliconRepeatMode;
shuffle?: boolean;
soundMode?: IFrontierSiliconEqualiser;
mediaName?: string;
mediaText?: string;
mediaTitle?: string;
mediaArtist?: string;
mediaAlbum?: string;
mediaImageUrl?: string;
mediaDuration?: number;
mediaPosition?: number;
}
export interface IFrontierSiliconSnapshot {
device: IFrontierSiliconDeviceInfo;
player: IFrontierSiliconPlayerState;
modes: IFrontierSiliconMode[];
equalisers: IFrontierSiliconEqualiser[];
presets: IFrontierSiliconPreset[];
online: boolean;
updatedAt?: string;
source?: TFrontierSiliconSnapshotSource;
error?: string;
raw?: Record<string, unknown>;
}
export interface IFrontierSiliconSsdpRecord {
st?: string;
usn?: string;
location?: string;
ssdpLocation?: string;
headers?: Record<string, string | undefined>;
ssdpHeaders?: Record<string, string | undefined>;
upnp?: Record<string, string | undefined>;
[key: string]: unknown;
}
export interface IFrontierSiliconManualEntry {
id?: string;
host?: string;
port?: number;
protocol?: TFrontierSiliconProtocol;
pin?: string;
deviceUrl?: string;
webfsapiUrl?: string;
name?: string;
manufacturer?: string;
model?: string;
radioId?: string;
metadata?: Record<string, unknown>;
}
@@ -1,2 +1,6 @@
export * from './frontier_silicon.classes.client.js';
export * from './frontier_silicon.classes.configflow.js';
export * from './frontier_silicon.classes.integration.js';
export * from './frontier_silicon.discovery.js';
export * from './frontier_silicon.mapper.js';
export * from './frontier_silicon.types.js';
+7 -13
View File
@@ -137,7 +137,6 @@ import { HomeAssistantBrandsIntegration } from '../brands/index.js';
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
import { HomeAssistantBringIntegration } from '../bring/index.js';
import { HomeAssistantBrotherIntegration } from '../brother/index.js';
import { HomeAssistantBrottsplatskartanIntegration } from '../brottsplatskartan/index.js';
import { HomeAssistantBrowserIntegration } from '../browser/index.js';
import { HomeAssistantBruntIntegration } from '../brunt/index.js';
@@ -207,7 +206,6 @@ import { HomeAssistantCrownstoneIntegration } from '../crownstone/index.js';
import { HomeAssistantCurrencylayerIntegration } from '../currencylayer/index.js';
import { HomeAssistantCyncIntegration } from '../cync/index.js';
import { HomeAssistantDaciaIntegration } from '../dacia/index.js';
import { HomeAssistantDaikinIntegration } from '../daikin/index.js';
import { HomeAssistantDanfossAirIntegration } from '../danfoss_air/index.js';
import { HomeAssistantDatadogIntegration } from '../datadog/index.js';
import { HomeAssistantDateIntegration } from '../date/index.js';
@@ -237,7 +235,6 @@ import { HomeAssistantDialogflowIntegration } from '../dialogflow/index.js';
import { HomeAssistantDiazIntegration } from '../diaz/index.js';
import { HomeAssistantDigitalLoggersIntegration } from '../digital_loggers/index.js';
import { HomeAssistantDigitalOceanIntegration } from '../digital_ocean/index.js';
import { HomeAssistantDirectvIntegration } from '../directv/index.js';
import { HomeAssistantDiscogsIntegration } from '../discogs/index.js';
import { HomeAssistantDiscordIntegration } from '../discord/index.js';
import { HomeAssistantDiscovergyIntegration } from '../discovergy/index.js';
@@ -246,7 +243,6 @@ import { HomeAssistantDnsipIntegration } from '../dnsip/index.js';
import { HomeAssistantDoodsIntegration } from '../doods/index.js';
import { HomeAssistantDoorIntegration } from '../door/index.js';
import { HomeAssistantDoorbellIntegration } from '../doorbell/index.js';
import { HomeAssistantDoorbirdIntegration } from '../doorbird/index.js';
import { HomeAssistantDooyaIntegration } from '../dooya/index.js';
import { HomeAssistantDormakabaDkeyIntegration } from '../dormakaba_dkey/index.js';
import { HomeAssistantDovadoIntegration } from '../dovado/index.js';
@@ -371,7 +367,6 @@ import { HomeAssistantFolderIntegration } from '../folder/index.js';
import { HomeAssistantFolderWatcherIntegration } from '../folder_watcher/index.js';
import { HomeAssistantFoobotIntegration } from '../foobot/index.js';
import { HomeAssistantForecastSolarIntegration } from '../forecast_solar/index.js';
import { HomeAssistantForkedDaapdIntegration } from '../forked_daapd/index.js';
import { HomeAssistantFortiosIntegration } from '../fortios/index.js';
import { HomeAssistantFoscamIntegration } from '../foscam/index.js';
import { HomeAssistantFoursquareIntegration } from '../foursquare/index.js';
@@ -386,7 +381,6 @@ import { HomeAssistantFritzboxIntegration } from '../fritzbox/index.js';
import { HomeAssistantFritzboxCallmonitorIntegration } from '../fritzbox_callmonitor/index.js';
import { HomeAssistantFroniusIntegration } from '../fronius/index.js';
import { HomeAssistantFrontendIntegration } from '../frontend/index.js';
import { HomeAssistantFrontierSiliconIntegration } from '../frontier_silicon/index.js';
import { HomeAssistantFujitsuAnywairIntegration } from '../fujitsu_anywair/index.js';
import { HomeAssistantFujitsuFglairIntegration } from '../fujitsu_fglair/index.js';
import { HomeAssistantFullyKioskIntegration } from '../fully_kiosk/index.js';
@@ -1521,7 +1515,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrotherIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrottsplatskartanIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrowserIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBruntIntegration());
@@ -1591,7 +1584,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantCrownstoneIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCurrencylayerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCyncIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDaciaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDaikinIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDanfossAirIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDatadogIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDateIntegration());
@@ -1621,7 +1613,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDialogflowIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiazIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDigitalLoggersIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDigitalOceanIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDirectvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscogsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscordIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscovergyIntegration());
@@ -1630,7 +1621,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDnsipIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoodsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoorbellIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoorbirdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDooyaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDormakabaDkeyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDovadoIntegration());
@@ -1755,7 +1745,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFolderIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFolderWatcherIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoobotIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantForecastSolarIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantForkedDaapdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFortiosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoscamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoursquareIntegration());
@@ -1770,7 +1759,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxCallmonitorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFrontendIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFrontierSiliconIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuAnywairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuFglairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFullyKioskIntegration());
@@ -2768,7 +2756,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1382;
export const generatedHomeAssistantPortCount = 1376;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient",
@@ -2786,16 +2774,22 @@ export const handwrittenHomeAssistantPortDomains = [
"bosch_shc",
"braviatv",
"broadlink",
"brother",
"cast",
"daikin",
"deconz",
"denonavr",
"devolo_home_network",
"directv",
"dlna_dmr",
"dlna_dms",
"doorbird",
"dsmr",
"dunehd",
"esphome",
"forked_daapd",
"fritz",
"frontier_silicon",
"glances",
"go2rtc",
"heos",
+6
View File
@@ -16,16 +16,22 @@ export * from './bluetooth_le_tracker/index.js';
export * from './bosch_shc/index.js';
export * from './braviatv/index.js';
export * from './broadlink/index.js';
export * from './brother/index.js';
export * from './cast/index.js';
export * from './daikin/index.js';
export * from './deconz/index.js';
export * from './denonavr/index.js';
export * from './devolo_home_network/index.js';
export * from './directv/index.js';
export * from './dlna_dmr/index.js';
export * from './dlna_dms/index.js';
export * from './doorbird/index.js';
export * from './dsmr/index.js';
export * from './dunehd/index.js';
export * from './esphome/index.js';
export * from './forked_daapd/index.js';
export * from './fritz/index.js';
export * from './frontier_silicon/index.js';
export * from './glances/index.js';
export * from './go2rtc/index.js';
export * from './heos/index.js';