Add native local AV and device integrations
This commit is contained in:
+12
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user