Add native local NAS and network service integrations

This commit is contained in:
2026-05-05 19:37:20 +00:00
parent a144ef687c
commit ae901a3308
69 changed files with 13245 additions and 183 deletions
+12
View File
@@ -35,12 +35,16 @@ import { JellyfinIntegration } from './integrations/jellyfin/index.js';
import { KnxIntegration } from './integrations/knx/index.js';
import { KodiIntegration } from './integrations/kodi/index.js';
import { MatterIntegration } from './integrations/matter/index.js';
import { MikrotikIntegration } from './integrations/mikrotik/index.js';
import { ModbusIntegration } from './integrations/modbus/index.js';
import { MotionEyeIntegration } from './integrations/motioneye/index.js';
import { MqttIntegration } from './integrations/mqtt/index.js';
import { MpdIntegration } from './integrations/mpd/index.js';
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js';
import { OpnsenseIntegration } from './integrations/opnsense/index.js';
import { OnvifIntegration } from './integrations/onvif/index.js';
import { PiHoleIntegration } from './integrations/pi_hole/index.js';
import { PlexIntegration } from './integrations/plex/index.js';
import { RainbirdIntegration } from './integrations/rainbird/index.js';
import { RflinkIntegration } from './integrations/rflink/index.js';
@@ -49,6 +53,8 @@ import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
import { ShellyIntegration } from './integrations/shelly/index.js';
import { SnapcastIntegration } from './integrations/snapcast/index.js';
import { SonosIntegration } from './integrations/sonos/index.js';
import { SqueezeboxIntegration } from './integrations/squeezebox/index.js';
import { SynologyDsmIntegration } from './integrations/synology_dsm/index.js';
import { TplinkIntegration } from './integrations/tplink/index.js';
import { TradfriIntegration } from './integrations/tradfri/index.js';
import { UnifiIntegration } from './integrations/unifi/index.js';
@@ -98,12 +104,16 @@ export const integrations = [
new KnxIntegration(),
new KodiIntegration(),
new MatterIntegration(),
new MikrotikIntegration(),
new ModbusIntegration(),
new MotionEyeIntegration(),
new MqttIntegration(),
new MpdIntegration(),
new NanoleafIntegration(),
new OpenthermGwIntegration(),
new OpnsenseIntegration(),
new OnvifIntegration(),
new PiHoleIntegration(),
new PlexIntegration(),
new RainbirdIntegration(),
new RflinkIntegration(),
@@ -112,6 +122,8 @@ export const integrations = [
new ShellyIntegration(),
new SnapcastIntegration(),
new SonosIntegration(),
new SqueezeboxIntegration(),
new SynologyDsmIntegration(),
new TplinkIntegration(),
new TradfriIntegration(),
new UnifiIntegration(),
+7 -13
View File
@@ -726,7 +726,6 @@ import { HomeAssistantMicrosoftFaceDetectIntegration } from '../microsoft_face_d
import { HomeAssistantMicrosoftFaceIdentifyIntegration } from '../microsoft_face_identify/index.js';
import { HomeAssistantMieleIntegration } from '../miele/index.js';
import { HomeAssistantMijndomeinEnergieIntegration } from '../mijndomein_energie/index.js';
import { HomeAssistantMikrotikIntegration } from '../mikrotik/index.js';
import { HomeAssistantMillIntegration } from '../mill/index.js';
import { HomeAssistantMinMaxIntegration } from '../min_max/index.js';
import { HomeAssistantMinecraftServerIntegration } from '../minecraft_server/index.js';
@@ -750,7 +749,6 @@ import { HomeAssistantMopekaIntegration } from '../mopeka/index.js';
import { HomeAssistantMotionIntegration } from '../motion/index.js';
import { HomeAssistantMotionBlindsIntegration } from '../motion_blinds/index.js';
import { HomeAssistantMotionblindsBleIntegration } from '../motionblinds_ble/index.js';
import { HomeAssistantMotioneyeIntegration } from '../motioneye/index.js';
import { HomeAssistantMotionmountIntegration } from '../motionmount/index.js';
import { HomeAssistantMqttEventstreamIntegration } from '../mqtt_eventstream/index.js';
import { HomeAssistantMqttJsonIntegration } from '../mqtt_json/index.js';
@@ -862,7 +860,6 @@ import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js';
import { HomeAssistantOpnsenseIntegration } from '../opnsense/index.js';
import { HomeAssistantOpowerIntegration } from '../opower/index.js';
import { HomeAssistantOppleIntegration } from '../opple/index.js';
import { HomeAssistantOralbIntegration } from '../oralb/index.js';
@@ -897,7 +894,6 @@ import { HomeAssistantPersonIntegration } from '../person/index.js';
import { HomeAssistantPgeIntegration } from '../pge/index.js';
import { HomeAssistantPglabIntegration } from '../pglab/index.js';
import { HomeAssistantPhilipsJsIntegration } from '../philips_js/index.js';
import { HomeAssistantPiHoleIntegration } from '../pi_hole/index.js';
import { HomeAssistantPicnicIntegration } from '../picnic/index.js';
import { HomeAssistantPicottsIntegration } from '../picotts/index.js';
import { HomeAssistantPilightIntegration } from '../pilight/index.js';
@@ -1127,7 +1123,6 @@ import { HomeAssistantSpiderIntegration } from '../spider/index.js';
import { HomeAssistantSplunkIntegration } from '../splunk/index.js';
import { HomeAssistantSpotifyIntegration } from '../spotify/index.js';
import { HomeAssistantSqlIntegration } from '../sql/index.js';
import { HomeAssistantSqueezeboxIntegration } from '../squeezebox/index.js';
import { HomeAssistantSrpEnergyIntegration } from '../srp_energy/index.js';
import { HomeAssistantSsdpIntegration } from '../ssdp/index.js';
import { HomeAssistantStarlineIntegration } from '../starline/index.js';
@@ -1166,7 +1161,6 @@ import { HomeAssistantSymfoniskIntegration } from '../symfonisk/index.js';
import { HomeAssistantSyncthingIntegration } from '../syncthing/index.js';
import { HomeAssistantSyncthruIntegration } from '../syncthru/index.js';
import { HomeAssistantSynologyChatIntegration } from '../synology_chat/index.js';
import { HomeAssistantSynologyDsmIntegration } from '../synology_dsm/index.js';
import { HomeAssistantSynologySrmIntegration } from '../synology_srm/index.js';
import { HomeAssistantSyslogIntegration } from '../syslog/index.js';
import { HomeAssistantSystemBridgeIntegration } from '../system_bridge/index.js';
@@ -2128,7 +2122,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMicrosoftFaceDetect
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMicrosoftFaceIdentifyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMieleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMijndomeinEnergieIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMikrotikIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMillIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMinMaxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMinecraftServerIntegration());
@@ -2152,7 +2145,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMopekaIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionBlindsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionblindsBleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotioneyeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionmountIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttEventstreamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttJsonIntegration());
@@ -2264,7 +2256,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegra
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpowerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOppleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOralbIntegration());
@@ -2299,7 +2290,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantPersonIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPgeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPglabIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPhilipsJsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPiHoleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPicnicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPicottsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPilightIntegration());
@@ -2529,7 +2519,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpiderIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSplunkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpotifyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSqlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSqueezeboxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSrpEnergyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSsdpIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantStarlineIntegration());
@@ -2568,7 +2557,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSymfoniskIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyncthingIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyncthruIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologyChatIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologyDsmIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologySrmIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyslogIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSystemBridgeIntegration());
@@ -2804,7 +2792,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1400;
export const generatedHomeAssistantPortCount = 1394;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient",
@@ -2839,12 +2827,16 @@ export const handwrittenHomeAssistantPortDomains = [
"knx",
"kodi",
"matter",
"mikrotik",
"modbus",
"motioneye",
"mpd",
"mqtt",
"nanoleaf",
"onvif",
"opentherm_gw",
"opnsense",
"pi_hole",
"plex",
"rainbird",
"rflink",
@@ -2853,6 +2845,8 @@ export const handwrittenHomeAssistantPortDomains = [
"shelly",
"snapcast",
"sonos",
"squeezebox",
"synology_dsm",
"tplink",
"tradfri",
"unifi",
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './mikrotik.classes.client.js';
export * from './mikrotik.classes.configflow.js';
export * from './mikrotik.classes.integration.js';
export * from './mikrotik.discovery.js';
export * from './mikrotik.mapper.js';
export * from './mikrotik.types.js';
@@ -0,0 +1,110 @@
import { MikrotikMapper } from './mikrotik.mapper.js';
import type { IMikrotikCommand, IMikrotikCommandResult, IMikrotikConfig, IMikrotikEvent, IMikrotikSnapshot } from './mikrotik.types.js';
type TMikrotikEventHandler = (eventArg: IMikrotikEvent) => void;
export class MikrotikClient {
private currentSnapshot?: IMikrotikSnapshot;
private readonly eventHandlers = new Set<TMikrotikEventHandler>();
constructor(private readonly config: IMikrotikConfig) {}
public async getSnapshot(): Promise<IMikrotikSnapshot> {
if (this.config.nativeClient) {
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.snapshotProvider) {
const provided = await this.config.snapshotProvider();
if (provided) {
this.currentSnapshot = this.normalizeSnapshot(provided, 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
}
const snapshot = this.currentSnapshot ?? MikrotikMapper.toSnapshot(this.config);
this.currentSnapshot = snapshot;
return this.cloneSnapshot(snapshot);
}
public onEvent(handlerArg: TMikrotikEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IMikrotikCommandResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
this.emit({ type: 'snapshot_refreshed', snapshot, timestamp: Date.now() });
return { success: true, data: snapshot };
} catch (errorArg) {
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
const snapshot = MikrotikMapper.toSnapshot({ ...this.config, connected: false, snapshot: this.currentSnapshot }, false);
this.currentSnapshot = snapshot;
this.emit({ type: 'refresh_failed', snapshot, error, timestamp: Date.now() });
return { success: false, error, data: snapshot };
}
}
public async sendCommand(commandArg: IMikrotikCommand): Promise<IMikrotikCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
if (!executor) {
const result: IMikrotikCommandResult = {
success: false,
error: this.unsupportedCommandMessage(commandArg),
data: { command: commandArg },
};
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
try {
const result = this.commandResult(await executor(commandArg), commandArg);
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
} catch (errorArg) {
const result: IMikrotikCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
}
public async destroy(): Promise<void> {
await this.config.nativeClient?.destroy?.();
this.eventHandlers.clear();
}
private normalizeSnapshot(snapshotArg: IMikrotikSnapshot, sourceArg: IMikrotikSnapshot['source']): IMikrotikSnapshot {
const normalized = MikrotikMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
return { ...normalized, source: snapshotArg.source || sourceArg };
}
private commandResult(resultArg: unknown, commandArg: IMikrotikCommand): IMikrotikCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IMikrotikCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private unsupportedCommandMessage(commandArg: IMikrotikCommand): string {
return `Mikrotik RouterOS/API command ${commandArg.path} is not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live command execution.`;
}
private emit(eventArg: IMikrotikEvent): void {
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot<T extends IMikrotikSnapshot>(snapshotArg: T): T {
return JSON.parse(JSON.stringify(snapshotArg)) as T;
}
}
@@ -0,0 +1,117 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { MikrotikMapper } from './mikrotik.mapper.js';
import type { IMikrotikConfig, IMikrotikSnapshot } from './mikrotik.types.js';
import { mikrotikDefaultApiPort, mikrotikDefaultDetectionTime } from './mikrotik.types.js';
export class MikrotikConfigFlow implements IConfigFlow<IMikrotikConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMikrotikConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Set up Mikrotik Router',
description: 'Provide the local RouterOS API endpoint. Snapshot/manual data is supported directly; live RouterOS/API success is not assumed without an injected native client or command executor.',
fields: [
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
{ name: 'port', label: `API port (${candidateArg.port || mikrotikDefaultApiPort})`, type: 'number' },
{ name: 'verifySsl', label: 'Use SSL/TLS for RouterOS API', type: 'boolean' },
{ name: 'forceDhcp', label: 'Force scanning using DHCP', type: 'boolean' },
{ name: 'arpPing', label: 'Enable ARP ping', type: 'boolean' },
{ name: 'detectionTime', label: `Consider home interval (${mikrotikDefaultDetectionTime}s)`, type: 'number' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IMikrotikConfig>> {
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid Mikrotik snapshot', error: snapshot.message };
}
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(candidateArg.metadata?.verifySsl) ?? snapshot?.router.verifySsl ?? false;
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host;
if (!host && !snapshot) {
return { kind: 'error', title: 'Mikrotik setup failed', error: 'Mikrotik setup requires a host or snapshot JSON.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? MikrotikMapper.defaultPort(verifySsl) : undefined);
const config: IMikrotikConfig = {
host,
port,
username: this.stringValue(valuesArg.username),
password: this.stringValue(valuesArg.password),
verifySsl,
protocol: MikrotikMapper.protocol(verifySsl),
forceDhcp: this.booleanValue(valuesArg.forceDhcp) ?? false,
arpPing: this.booleanValue(valuesArg.arpPing) ?? false,
detectionTime: this.numberValue(valuesArg.detectionTime) || mikrotikDefaultDetectionTime,
uniqueId: candidateArg.id || snapshot?.router.serialNumber || snapshot?.router.macAddress,
name: candidateArg.name || snapshot?.router.name || snapshot?.router.identity || host,
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: candidateArg.metadata,
upstreamPlatforms: ['device_tracker'],
liveRouterOsApiImplemented: false,
},
};
return {
kind: 'done',
title: 'Mikrotik configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IMikrotikSnapshot | undefined | Error {
if (valueArg && typeof valueArg === 'object') {
return valueArg as IMikrotikSnapshot;
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as IMikrotikSnapshot;
if (!parsed || typeof parsed !== 'object' || !parsed.router) {
return new Error('Snapshot JSON must include a router object.');
}
return parsed;
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
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;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
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 undefined;
}
}
@@ -1,26 +1,95 @@
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 { MikrotikClient } from './mikrotik.classes.client.js';
import { MikrotikConfigFlow } from './mikrotik.classes.configflow.js';
import { createMikrotikDiscoveryDescriptor } from './mikrotik.discovery.js';
import { MikrotikMapper } from './mikrotik.mapper.js';
import type { IMikrotikConfig } from './mikrotik.types.js';
import { mikrotikDomain } from './mikrotik.types.js';
export class HomeAssistantMikrotikIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "mikrotik",
displayName: "Mikrotik",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/mikrotik",
"upstreamDomain": "mikrotik",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"librouteros==3.2.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@engrbm87"
]
},
});
export class MikrotikIntegration extends BaseIntegration<IMikrotikConfig> {
public readonly domain = mikrotikDomain;
public readonly displayName = 'Mikrotik';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createMikrotikDiscoveryDescriptor();
public readonly configFlow = new MikrotikConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/mikrotik',
upstreamDomain: mikrotikDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['librouteros==3.2.0'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@engrbm87'],
documentation: 'https://www.home-assistant.io/integrations/mikrotik',
configFlow: true,
runtime: {
mode: 'native TypeScript snapshot/manual RouterOS/API mapping',
platforms: ['device_tracker', 'binary_sensor', 'sensor', 'switch', 'button'],
services: ['refresh', 'snapshot', 'status', 'reboot', 'arp_ping', 'disconnect_client', 'enable_interface', 'disable_interface'],
},
localApi: {
implemented: [
'manual Mikrotik RouterOS/API setup candidates and config flow',
'snapshot mapping for router resources, device-tracker equivalents, interfaces, clients, traffic counters/rates, and represented interface/client/router controls',
'safe RouterOS/API command modeling for explicitly represented reboot, ARP ping, client disconnect, and interface enablement actions',
],
explicitUnsupported: [
'homeassistant_compat shims',
'fake RouterOS/API connection or command success without commandExecutor/nativeClient injection',
'full librouteros live protocol implementation in dependency-free TypeScript',
],
},
};
public async setup(configArg: IMikrotikConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new MikrotikRuntime(new MikrotikClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantMikrotikIntegration extends MikrotikIntegration {}
class MikrotikRuntime implements IIntegrationRuntime {
public domain = mikrotikDomain;
constructor(private readonly client: MikrotikClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return MikrotikMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return MikrotikMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(MikrotikMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === mikrotikDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === mikrotikDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = MikrotikMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Mikrotik service mapping: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,162 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { MikrotikMapper } from './mikrotik.mapper.js';
import type { IMikrotikManualDiscoveryRecord, IMikrotikSnapshot } from './mikrotik.types.js';
import { mikrotikDefaultApiPort, mikrotikDomain, mikrotikManufacturer } from './mikrotik.types.js';
const mikrotikTextHints = ['mikrotik', 'routeros', 'routerboard', 'router os', 'crs', 'ccr', 'hex', 'hap', 'cap ac', 'cap ax'];
export class MikrotikManualMatcher implements IDiscoveryMatcher<IMikrotikManualDiscoveryRecord> {
public id = 'mikrotik-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Mikrotik RouterOS/API setup entries, including snapshot-only records.';
public async matches(inputArg: IMikrotikManualDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot as IMikrotikSnapshot | undefined;
const host = inputArg.host || snapshot?.router.host;
const verifySsl = this.booleanValue(inputArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? snapshot?.router.verifySsl ?? false;
const mac = MikrotikMapper.normalizeMac(inputArg.macAddress || snapshot?.router.macAddress);
const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.protocol, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.boardName, snapshot?.router.name);
const hasSnapshot = Boolean(snapshot);
const matched = inputArg.integrationDomain === mikrotikDomain
|| metadata.mikrotik === true
|| metadata.routeros === true
|| metadata.routerOs === true
|| hasSnapshot
|| mikrotikTextHints.some((hintArg) => text.includes(hintArg))
|| Boolean(host && !text);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Mikrotik RouterOS/API setup hints.' };
}
const port = inputArg.port || snapshot?.router.port || mikrotikDefaultApiPort;
const id = inputArg.id || inputArg.serialNumber || snapshot?.router.serialNumber || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: hasSnapshot || mac || inputArg.serialNumber ? 'certain' : host ? 'high' : 'medium',
reason: hasSnapshot ? 'Manual entry includes a Mikrotik snapshot.' : 'Manual entry can start Mikrotik RouterOS/API setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: mikrotikDomain,
id,
host,
port,
name: inputArg.name || snapshot?.router.name || snapshot?.router.identity || host || 'Mikrotik',
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || mikrotikManufacturer,
model: inputArg.model || snapshot?.router.model || snapshot?.router.boardName || 'RouterOS device',
serialNumber: inputArg.serialNumber || snapshot?.router.serialNumber,
macAddress: mac,
metadata: {
...metadata,
mikrotik: true,
routeros: true,
protocol: MikrotikMapper.protocol(verifySsl),
verifySsl,
hasSnapshot,
upstreamPlatforms: ['device_tracker'],
liveRouterOsApiImplemented: false,
},
},
metadata: { hasSnapshot, verifySsl, protocol: MikrotikMapper.protocol(verifySsl), liveRouterOsApiImplemented: false },
};
}
private text(...valuesArg: unknown[]): string {
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
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 undefined;
}
}
export class MikrotikCandidateValidator implements IDiscoveryValidator {
public id = 'mikrotik-candidate-validator';
public description = 'Validate Mikrotik candidates have a host or snapshot and RouterOS identity metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IMikrotikSnapshot | undefined;
const mac = MikrotikMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress);
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.protocol, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.boardName]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = candidateArg.integrationDomain === mikrotikDomain
|| metadata.mikrotik === true
|| metadata.routeros === true
|| metadata.routerOs === true
|| Boolean(snapshot)
|| mikrotikTextHints.some((hintArg) => text.includes(hintArg));
const hasUsableSource = Boolean(candidateArg.host || snapshot);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Mikrotik candidate lacks host or snapshot information.' : 'Candidate is not Mikrotik RouterOS/API.',
};
}
const verifySsl = this.booleanValue(metadata.verifySsl) ?? snapshot?.router.verifySsl ?? false;
const port = candidateArg.port || snapshot?.router.port || mikrotikDefaultApiPort;
const normalizedDeviceId = candidateArg.id || snapshot?.router.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id);
return {
matched: true,
confidence: mac || snapshot ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Mikrotik metadata and a usable local RouterOS/API source.',
normalizedDeviceId,
candidate: {
...candidateArg,
id: candidateArg.id || normalizedDeviceId,
port,
macAddress: mac || candidateArg.macAddress,
metadata: {
...metadata,
mikrotik: true,
routeros: true,
protocol: MikrotikMapper.protocol(verifySsl),
verifySsl,
upstreamPlatforms: ['device_tracker'],
liveRouterOsApiImplemented: false,
},
},
metadata: { verifySsl, protocol: MikrotikMapper.protocol(verifySsl), liveRouterOsApiImplemented: false },
};
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
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 undefined;
}
}
export const createMikrotikDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: mikrotikDomain, displayName: 'Mikrotik' })
.addMatcher(new MikrotikManualMatcher())
.addValidator(new MikrotikCandidateValidator());
};
File diff suppressed because it is too large Load Diff
+344 -2
View File
@@ -1,4 +1,346 @@
export interface IHomeAssistantMikrotikConfig {
// TODO: replace with the TypeScript-native config for mikrotik.
import type { IServiceCallResult } from '../../core/types.js';
export const mikrotikDomain = 'mikrotik';
export const mikrotikDefaultApiPort = 8728;
export const mikrotikDefaultDetectionTime = 300;
export const mikrotikManufacturer = 'Mikrotik';
export type TMikrotikProtocol = 'routeros-api' | 'routeros-api-ssl';
export type TMikrotikSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
export type TMikrotikActionTarget = 'router' | 'client' | 'interface';
export type TMikrotikRouterAction = 'reboot';
export type TMikrotikClientAction = 'arp_ping' | 'disconnect';
export type TMikrotikInterfaceAction = 'set_enabled';
export type TMikrotikAction = TMikrotikRouterAction | TMikrotikClientAction | TMikrotikInterfaceAction;
export type TMikrotikCommandType = 'router.action' | 'client.action' | 'interface.set';
export type TMikrotikApiPath =
| '/system/reboot'
| '/ping'
| '/interface/set'
| '/interface/wireless/registration-table/remove'
| '/caps-man/registration-table/remove'
| '/interface/wifiwave2/registration-table/remove'
| '/interface/wifi/registration-table/remove';
export interface IMikrotikConfig {
host?: string;
port?: number;
username?: string;
password?: string;
verifySsl?: boolean;
protocol?: TMikrotikProtocol;
arpPing?: boolean;
forceDhcp?: boolean;
detectionTime?: number;
connected?: boolean;
uniqueId?: string;
name?: string;
snapshot?: IMikrotikSnapshot;
router?: IMikrotikRouterInfo;
resources?: IMikrotikResourceInfo;
traffic?: IMikrotikTrafficStats;
devices?: IMikrotikClientDevice[];
clients?: IMikrotikClientDevice[];
interfaces?: IMikrotikInterfaceStats[];
sensors?: IMikrotikSensorMap;
actions?: IMikrotikActionDescriptor[];
manualEntries?: IMikrotikManualEntry[];
events?: IMikrotikEvent[];
snapshotProvider?: TMikrotikSnapshotProvider;
commandExecutor?: TMikrotikCommandExecutor;
nativeClient?: IMikrotikNativeClient;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IHomeAssistantMikrotikConfig extends IMikrotikConfig {}
export interface IMikrotikRouterInfo {
id?: string;
host?: string;
port?: number;
name?: string;
identity?: string;
model?: string;
boardName?: string;
serialNumber?: string;
firmware?: string;
currentFirmware?: string;
factoryFirmware?: string;
upgradeFirmware?: string;
routerOsVersion?: string;
architectureName?: string;
macAddress?: string;
configurationUrl?: string;
manufacturer?: string;
verifySsl?: boolean;
protocol?: TMikrotikProtocol;
supportsCapsman?: boolean;
supportsWireless?: boolean;
supportsWifiwave2?: boolean;
supportsWifi?: boolean;
actions?: TMikrotikRouterAction[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IMikrotikResourceInfo {
uptime?: string | number | Date;
version?: string;
routerOsVersion?: string;
buildTime?: string;
factorySoftware?: string;
freeMemory?: number;
totalMemory?: number;
usedMemory?: number;
memoryUsagePercent?: number;
cpu?: string;
cpuCount?: number;
cpuFrequency?: number;
cpuLoad?: number;
freeHddSpace?: number;
totalHddSpace?: number;
badBlocks?: number;
architectureName?: string;
boardName?: string;
platform?: string;
temperature?: number;
voltage?: number;
current?: number;
fanSpeed?: number;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IMikrotikTrafficStats {
rxBytes?: number;
txBytes?: number;
rxRateMbps?: number;
txRateMbps?: number;
rxBitsPerSecond?: number;
txBitsPerSecond?: number;
rxRate?: number;
txRate?: number;
downloadBytes?: number;
uploadBytes?: number;
downloadRateMbps?: number;
uploadRateMbps?: number;
[key: string]: unknown;
}
export interface IMikrotikClientDevice {
id?: string;
mac?: string;
macAddress?: string;
name?: string;
hostname?: string;
hostName?: string;
ip?: string;
ipAddress?: string;
address?: string;
activeAddress?: string;
connected?: boolean;
connectedTo?: string;
interface?: string;
ssid?: string;
comment?: string;
signalStrength?: number | string;
signalToNoise?: number | string;
rxRate?: number | string;
txRate?: number | string;
uptime?: string | number | Date;
lastSeen?: string | number | Date;
lastActivity?: string | number | Date;
manufacturer?: string;
model?: string;
source?: 'dhcp' | 'arp' | 'wireless' | 'capsman' | 'wifiwave2' | 'wifi' | 'manual' | string;
actions?: TMikrotikClientAction[];
metadata?: Record<string, unknown>;
'.id'?: string;
'mac-address'?: string;
'host-name'?: string;
'active-address'?: string;
'last-seen'?: string | number | Date;
'signal-strength'?: number | string;
'signal-to-noise'?: number | string;
'rx-rate'?: number | string;
'tx-rate'?: number | string;
[key: string]: unknown;
}
export interface IMikrotikInterfaceStats {
id?: string;
name: string;
label?: string;
type?: string;
connected?: boolean;
running?: boolean;
enabled?: boolean;
disabled?: boolean;
macAddress?: string;
ipAddress?: string;
ssid?: string;
comment?: string;
mtu?: number;
actualMtu?: number;
rxBytes?: number;
txBytes?: number;
rxRate?: number;
txRate?: number;
rxBitsPerSecond?: number;
txBitsPerSecond?: number;
downloadBytes?: number;
uploadBytes?: number;
rxPackets?: number;
txPackets?: number;
rxDrops?: number;
txDrops?: number;
rxErrors?: number;
txErrors?: number;
lastLinkUpTime?: string | number | Date;
actions?: TMikrotikInterfaceAction[];
metadata?: Record<string, unknown>;
'.id'?: string;
'mac-address'?: string;
'actual-mtu'?: number;
'rx-byte'?: number;
'tx-byte'?: number;
'rx-packet'?: number;
'tx-packet'?: number;
'rx-drop'?: number;
'tx-drop'?: number;
'rx-error'?: number;
'tx-error'?: number;
'rx-bits-per-second'?: number;
'tx-bits-per-second'?: number;
'last-link-up-time'?: string | number | Date;
[key: string]: unknown;
}
export interface IMikrotikSensorMap {
connected_clients?: number;
cpu_load?: number;
cpu_count?: number;
cpu_frequency?: number;
memory_free?: number;
memory_total?: number;
memory_used?: number;
memory_usage_percent?: number;
hdd_free?: number;
hdd_total?: number;
bad_blocks?: number;
uptime?: string | number | Date;
routeros_version?: string;
firmware?: string;
rx_bytes?: number;
tx_bytes?: number;
rx_rate?: number;
tx_rate?: number;
temperature?: number;
voltage?: number;
current?: number;
fan_speed?: number;
[key: string]: string | number | boolean | Date | null | undefined;
}
export interface IMikrotikActionDescriptor {
target: TMikrotikActionTarget;
action: TMikrotikAction;
command?: TMikrotikApiPath;
params?: Record<string, unknown>;
mac?: string;
id?: string | number;
interfaceName?: string;
entityId?: string;
deviceId?: string;
label?: string;
metadata?: Record<string, unknown>;
}
export interface IMikrotikSnapshot {
connected: boolean;
source?: TMikrotikSnapshotSource;
updatedAt?: string;
router: IMikrotikRouterInfo;
resources: IMikrotikResourceInfo;
devices: IMikrotikClientDevice[];
interfaces: IMikrotikInterfaceStats[];
sensors: IMikrotikSensorMap;
traffic?: IMikrotikTrafficStats;
actions?: IMikrotikActionDescriptor[];
events?: IMikrotikEvent[];
error?: string;
metadata?: Record<string, unknown>;
}
export interface IMikrotikManualEntry {
id?: string;
host?: string;
port?: number;
username?: string;
verifySsl?: boolean;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
router?: IMikrotikRouterInfo;
resources?: IMikrotikResourceInfo;
traffic?: IMikrotikTrafficStats;
devices?: IMikrotikClientDevice[];
clients?: IMikrotikClientDevice[];
interfaces?: IMikrotikInterfaceStats[];
sensors?: IMikrotikSensorMap;
actions?: IMikrotikActionDescriptor[];
snapshot?: IMikrotikSnapshot;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IMikrotikManualDiscoveryRecord extends IMikrotikManualEntry {
integrationDomain?: string;
}
export interface IMikrotikCommand {
type: TMikrotikCommandType;
service: string;
action: TMikrotikAction;
path: TMikrotikApiPath;
params: Record<string, unknown>;
target: {
entityId?: string;
deviceId?: string;
};
routerId?: string;
mac?: string;
interfaceId?: string | number;
interfaceName?: string;
entityId?: string;
deviceId?: string;
payload?: Record<string, unknown>;
}
export interface IMikrotikCommandResult extends IServiceCallResult {}
export interface IMikrotikEvent {
type: string;
timestamp?: number;
deviceId?: string;
entityId?: string;
command?: IMikrotikCommand;
snapshot?: IMikrotikSnapshot;
error?: string;
data?: unknown;
[key: string]: unknown;
}
export interface IMikrotikNativeClient {
getSnapshot(): Promise<IMikrotikSnapshot> | IMikrotikSnapshot;
executeCommand?(commandArg: IMikrotikCommand): Promise<IMikrotikCommandResult | unknown> | IMikrotikCommandResult | unknown;
destroy?(): Promise<void> | void;
}
export type TMikrotikSnapshotProvider = () => Promise<IMikrotikSnapshot | undefined> | IMikrotikSnapshot | undefined;
export type TMikrotikCommandExecutor = (
commandArg: IMikrotikCommand
) => Promise<IMikrotikCommandResult | unknown> | IMikrotikCommandResult | unknown;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './motioneye.classes.client.js';
export * from './motioneye.classes.configflow.js';
export * from './motioneye.classes.integration.js';
export * from './motioneye.discovery.js';
export * from './motioneye.mapper.js';
export * from './motioneye.types.js';
@@ -0,0 +1,676 @@
import * as plugins from '../../plugins.js';
import type {
IMotionEyeCamera,
IMotionEyeClientCommand,
IMotionEyeCommandResponse,
IMotionEyeConfig,
IMotionEyeDeviceInfo,
IMotionEyeRawCamera,
IMotionEyeSensor,
IMotionEyeSnapshot,
IMotionEyeSnapshotImage,
IMotionEyeSwitch,
TMotionEyeMediaKind,
TMotionEyeProtocol,
} from './motioneye.types.js';
import {
motionEyeDefaultAdminUsername,
motionEyeDefaultPort,
motionEyeDefaultSurveillanceUsername,
motionEyeDefaultTimeoutMs,
motionEyeSwitchDescriptions,
} from './motioneye.types.js';
const signatureRegex = /[^a-zA-Z0-9/?_.=&{}\[\]":, -]/g;
export class MotionEyeHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'MotionEyeHttpError';
}
}
export class MotionEyeClient {
private snapshot?: IMotionEyeSnapshot;
constructor(private readonly config: IMotionEyeConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IMotionEyeSnapshot> {
if (!forceRefreshArg && this.snapshot) {
return this.snapshot;
}
if (!forceRefreshArg && this.config.snapshot) {
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
return this.snapshot;
}
if (this.hasLiveTarget()) {
try {
this.snapshot = await this.fetchLiveSnapshot();
return this.snapshot;
} catch (errorArg) {
this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg));
return this.snapshot;
}
}
this.snapshot = this.snapshotFromConfig(this.config.connected ?? false);
return this.snapshot;
}
public async validateConnection(): Promise<void> {
await this.requestJson('/login');
}
public async execute(commandArg: IMotionEyeClientCommand): Promise<unknown> {
if (commandArg.type === 'refresh') {
return this.getSnapshot(true);
}
if (commandArg.type === 'stream_source') {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, commandArg.cameraId);
return {
cameraId: camera.id,
numericId: camera.numericId,
streamSource: camera.mjpegUrl,
mjpegUrl: camera.mjpegUrl,
stillImageUrl: camera.snapshotUrl,
snapshotUrl: camera.snapshotUrl,
streamingAuthMode: camera.streamingAuthMode,
verified: false,
};
}
if (commandArg.type === 'snapshot_image') {
if (commandArg.filename) {
throw new Error('motionEye snapshot file writes are not implemented; request data as base64 without data.filename.');
}
const image = await this.getSnapshotImage(commandArg.cameraId);
return {
contentType: image.contentType,
dataBase64: Buffer.from(image.data).toString('base64'),
};
}
if (commandArg.type === 'action') {
if (!commandArg.action) {
throw new Error('motionEye action command requires a non-empty action.');
}
const response = await this.action(commandArg.cameraId, commandArg.action);
return { ok: true, command: commandArg.type, action: commandArg.action, response };
}
if (commandArg.type === 'set_switch') {
if (!commandArg.key || typeof commandArg.enabled !== 'boolean') {
throw new Error('motionEye set_switch requires key and boolean enabled values.');
}
const response = await this.setCameraValue(commandArg.cameraId, commandArg.key, commandArg.enabled);
this.patchCachedSwitch(commandArg.cameraId, commandArg.key, commandArg.enabled);
return { ok: true, command: commandArg.type, key: commandArg.key, enabled: commandArg.enabled, response };
}
if (commandArg.type === 'set_text_overlay') {
const response = await this.setTextOverlay(commandArg);
return { ok: true, command: commandArg.type, response };
}
if (commandArg.type === 'media_list') {
const kind = commandArg.mediaKind || 'images';
return this.getMediaList(commandArg.cameraId, kind, commandArg.prefix);
}
throw new Error(`Unsupported motionEye command: ${commandArg.type}`);
}
public async getSnapshotImage(cameraIdArg?: string): Promise<IMotionEyeSnapshotImage> {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, cameraIdArg);
if (camera.numericId === undefined) {
throw new Error('motionEye snapshot image requires a numeric camera id.');
}
const response = await this.requestResponse(`/picture/${camera.numericId}/current/`, { admin: false });
return {
contentType: response.headers.get('content-type') || 'image/jpeg',
data: new Uint8Array(await response.arrayBuffer()),
};
}
public getCameraStreamUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
if (!this.isCameraStreaming(cameraArg)) {
return undefined;
}
const endpoint = this.endpoint();
const host = stringValue(cameraArg.host) || endpoint.host;
const port = numberValue(cameraArg.streaming_port);
if (!host || port === undefined) {
return undefined;
}
return `http://${host}:${port}/`;
}
public getCameraSnapshotUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
const cameraId = numberValue(cameraArg.id);
if (!this.isCameraStreaming(cameraArg) || cameraId === undefined || !this.hasLiveTarget()) {
return undefined;
}
return this.buildSignedUrl(`/picture/${cameraId}/current/`, undefined, undefined, 'GET', false);
}
public getMovieUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
if (!this.hasLiveTarget()) {
return undefined;
}
return this.buildSignedUrl(`/movie/${cameraIdArg}/${previewArg ? 'preview' : 'playback'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
}
public getImageUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
if (!this.hasLiveTarget()) {
return undefined;
}
return this.buildSignedUrl(`/picture/${cameraIdArg}/${previewArg ? 'preview' : 'download'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
}
public isCameraStreaming(cameraArg: IMotionEyeRawCamera | undefined): boolean {
return Boolean(cameraArg && numberValue(cameraArg.streaming_port) !== undefined && cameraArg.video_streaming === true);
}
public async destroy(): Promise<void> {}
private async fetchLiveSnapshot(): Promise<IMotionEyeSnapshot> {
await this.requestJson('/login');
const camerasResponse = await this.requestJson('/config/list');
const [manifest, serverConfig] = await Promise.all([
this.requestJson('/manifest.json').then((responseArg) => responseArg.data).catch(() => undefined),
this.requestJson('/config/main/get').then((responseArg) => responseArg.data).catch(() => undefined),
]);
const rawCameras = this.rawCamerasFromResponse(camerasResponse.data);
return this.normalizeSnapshot({
deviceInfo: this.deviceInfo(true),
cameras: this.camerasFromRaw(rawCameras, true),
sensors: [],
switches: [],
rawCameras,
connected: true,
updatedAt: new Date().toISOString(),
manifest: record(manifest),
serverConfig: record(serverConfig),
});
}
private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IMotionEyeSnapshot {
const rawCameras = this.config.rawCameras || this.config.snapshot?.rawCameras || this.rawCamerasFromConfiguredCameras(this.config.cameras || this.config.snapshot?.cameras || []);
const cameras = this.config.cameras || this.config.snapshot?.cameras || this.camerasFromRaw(rawCameras, connectedArg);
return this.normalizeSnapshot({
deviceInfo: this.deviceInfo(connectedArg),
cameras,
sensors: this.config.sensors || this.config.snapshot?.sensors || [],
switches: this.config.switches || this.config.snapshot?.switches || [],
rawCameras,
connected: connectedArg,
updatedAt: new Date().toISOString(),
manifest: this.config.manifest || this.config.snapshot?.manifest,
serverConfig: this.config.serverConfig || this.config.snapshot?.serverConfig,
metadata: {
...this.config.snapshot?.metadata,
lastLiveError: lastErrorArg,
},
});
}
private normalizeSnapshot(snapshotArg: IMotionEyeSnapshot): IMotionEyeSnapshot {
const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
const deviceInfo = {
...this.deviceInfo(connected),
...snapshotArg.deviceInfo,
online: connected,
};
const cameras = (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, connected));
const sensors = snapshotArg.sensors.length ? snapshotArg.sensors : this.sensorsFromCameras(cameras, connected);
const switches = snapshotArg.switches.length ? snapshotArg.switches : this.switchesFromCameras(cameras, connected);
return {
...snapshotArg,
deviceInfo,
cameras,
sensors,
switches,
rawCameras: snapshotArg.rawCameras || [],
connected,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private normalizeCamera(cameraArg: IMotionEyeCamera, connectedArg: boolean): IMotionEyeCamera {
const raw = cameraArg.raw;
const numericId = cameraArg.numericId ?? numberValue(raw?.id) ?? numberValue(cameraArg.id);
const rawStreamUrl = raw ? this.getCameraStreamUrl(raw) : undefined;
const rawSnapshotUrl = raw ? this.getCameraSnapshotUrl(raw) : undefined;
return {
...cameraArg,
id: String(cameraArg.id || numericId),
numericId,
name: cameraArg.name || `Camera ${cameraArg.id || numericId}`,
streamingPort: cameraArg.streamingPort ?? numberValue(raw?.streaming_port),
streamingAuthMode: cameraArg.streamingAuthMode ?? raw?.streaming_auth_mode,
mjpegUrl: cameraArg.mjpegUrl || this.renderStreamUrlTemplate(raw) || rawStreamUrl,
snapshotUrl: cameraArg.snapshotUrl || rawSnapshotUrl,
isStreaming: cameraArg.isStreaming ?? Boolean(raw && this.isCameraStreaming(raw)),
motionDetectionEnabled: cameraArg.motionDetectionEnabled ?? Boolean(raw?.motion_detection),
actions: cameraArg.actions || this.stringList(raw?.actions),
available: connectedArg && cameraArg.available !== false && cameraArg.isStreaming !== false,
};
}
private camerasFromRaw(rawCamerasArg: IMotionEyeRawCamera[], connectedArg: boolean): IMotionEyeCamera[] {
return rawCamerasArg
.filter((cameraArg) => cameraArg.id !== undefined && cameraArg.name !== undefined)
.map((cameraArg) => {
const numericId = numberValue(cameraArg.id);
const id = String(cameraArg.id);
return this.normalizeCamera({
id,
numericId,
name: stringValue(cameraArg.name) || `Camera ${id}`,
host: stringValue(cameraArg.host),
streamingPort: numberValue(cameraArg.streaming_port),
streamingAuthMode: cameraArg.streaming_auth_mode,
mjpegUrl: this.renderStreamUrlTemplate(cameraArg) || this.getCameraStreamUrl(cameraArg),
snapshotUrl: this.getCameraSnapshotUrl(cameraArg),
isStreaming: this.isCameraStreaming(cameraArg),
motionDetectionEnabled: Boolean(cameraArg.motion_detection),
textOverlayEnabled: booleanValue(cameraArg.text_overlay),
stillImagesEnabled: booleanValue(cameraArg.still_images),
moviesEnabled: booleanValue(cameraArg.movies),
uploadEnabled: booleanValue(cameraArg.upload_enabled),
actions: this.stringList(cameraArg.actions),
rootDirectory: stringValue(cameraArg.root_directory),
raw: cameraArg,
available: connectedArg,
}, connectedArg);
});
}
private rawCamerasFromResponse(valueArg: unknown): IMotionEyeRawCamera[] {
const cameras = record(valueArg)?.cameras;
return Array.isArray(cameras) ? cameras.filter(record).map((cameraArg) => cameraArg as IMotionEyeRawCamera) : [];
}
private rawCamerasFromConfiguredCameras(camerasArg: IMotionEyeCamera[]): IMotionEyeRawCamera[] {
return camerasArg.map((cameraArg) => ({
id: cameraArg.numericId ?? cameraArg.id,
name: cameraArg.name,
host: cameraArg.host,
streaming_port: cameraArg.streamingPort,
streaming_auth_mode: cameraArg.streamingAuthMode,
video_streaming: cameraArg.isStreaming,
motion_detection: cameraArg.motionDetectionEnabled,
text_overlay: cameraArg.textOverlayEnabled,
still_images: cameraArg.stillImagesEnabled,
movies: cameraArg.moviesEnabled,
upload_enabled: cameraArg.uploadEnabled,
actions: cameraArg.actions,
root_directory: cameraArg.rootDirectory,
...cameraArg.raw,
}));
}
private sensorsFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSensor[] {
return camerasArg.map((cameraArg) => ({
key: 'actions',
name: `${cameraArg.name} Actions`,
cameraId: cameraArg.id,
value: cameraArg.actions.length,
entityCategory: 'diagnostic',
available: connectedArg,
attributes: cameraArg.actions.length ? { actions: cameraArg.actions } : undefined,
}));
}
private switchesFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSwitch[] {
const valuesByKey = (cameraArg: IMotionEyeCamera): Record<string, boolean | undefined> => ({
motion_detection: cameraArg.motionDetectionEnabled,
text_overlay: cameraArg.textOverlayEnabled,
video_streaming: cameraArg.isStreaming,
still_images: cameraArg.stillImagesEnabled,
movies: cameraArg.moviesEnabled,
upload_enabled: cameraArg.uploadEnabled,
});
return camerasArg.flatMap((cameraArg) => {
const values = valuesByKey(cameraArg);
return motionEyeSwitchDescriptions.map((descriptionArg) => ({
key: descriptionArg.key,
name: `${cameraArg.name} ${descriptionArg.name}`,
cameraId: cameraArg.id,
isOn: Boolean(values[descriptionArg.key]),
entityCategory: descriptionArg.entityCategory,
available: connectedArg,
}));
});
}
private async action(cameraIdArg: string | undefined, actionArg: string): Promise<IMotionEyeCommandResponse> {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, cameraIdArg);
if (camera.numericId === undefined) {
throw new Error('motionEye action requires a numeric camera id.');
}
return this.requestCommand(`action:${actionArg}`, 'POST', `/action/${camera.numericId}/${encodeURIComponent(actionArg)}`, {}, true);
}
private async setCameraValue(cameraIdArg: string | undefined, keyArg: string, valueArg: unknown): Promise<IMotionEyeCommandResponse> {
const camera = await this.getLatestRawCamera(cameraIdArg);
camera[keyArg] = valueArg;
return this.setRawCamera(camera, keyArg);
}
private async setTextOverlay(commandArg: IMotionEyeClientCommand): Promise<IMotionEyeCommandResponse> {
const camera = await this.getLatestRawCamera(commandArg.cameraId);
if (commandArg.leftText !== undefined) {
camera.left_text = commandArg.leftText;
}
if (commandArg.rightText !== undefined) {
camera.right_text = commandArg.rightText;
}
if (commandArg.customLeftText !== undefined) {
camera.custom_left_text = unicodeEscape(commandArg.customLeftText);
}
if (commandArg.customRightText !== undefined) {
camera.custom_right_text = unicodeEscape(commandArg.customRightText);
}
return this.setRawCamera(camera, 'set_text_overlay');
}
private async getLatestRawCamera(cameraIdArg: string | undefined): Promise<IMotionEyeRawCamera> {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, cameraIdArg);
if (camera.numericId === undefined) {
throw new Error('motionEye camera configuration updates require a numeric camera id.');
}
const response = await this.requestJson(`/config/${camera.numericId}/get`);
const raw = record(response.data) as IMotionEyeRawCamera | undefined;
if (!raw) {
throw new Error(`motionEye camera ${camera.numericId} config response was empty.`);
}
return raw;
}
private async setRawCamera(cameraArg: IMotionEyeRawCamera, labelArg: string): Promise<IMotionEyeCommandResponse> {
const cameraId = numberValue(cameraArg.id);
if (cameraId === undefined) {
throw new Error('motionEye set camera requires a numeric camera id.');
}
return this.requestCommand(labelArg, 'POST', `/config/${cameraId}/set`, cameraArg as Record<string, unknown>, true);
}
private async getMediaList(cameraIdArg: string | undefined, kindArg: TMotionEyeMediaKind, prefixArg?: string): Promise<unknown> {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, cameraIdArg);
if (camera.numericId === undefined) {
throw new Error('motionEye media list requires a numeric camera id.');
}
const response = await this.requestJson(`/${kindArg === 'movies' ? 'movie' : 'picture'}/${camera.numericId}/list`, prefixArg ? { prefix: prefixArg } : undefined);
return response.data;
}
private async requestCommand(labelArg: string, methodArg: 'GET' | 'POST', pathArg: string, dataArg: Record<string, unknown> | undefined, adminArg: boolean): Promise<IMotionEyeCommandResponse> {
const response = await this.requestJson(pathArg, undefined, dataArg, methodArg, adminArg);
return {
ok: true,
label: labelArg,
method: methodArg,
path: pathArg,
status: response.status,
response: response.data,
};
}
private async requestJson(pathArg: string, paramsArg?: Record<string, unknown>, dataArg?: Record<string, unknown>, methodArg: 'GET' | 'POST' = 'GET', adminArg = true): Promise<{ status: number; data: unknown }> {
const serializedData = dataArg === undefined ? undefined : JSON.stringify(dataArg);
const response = await this.requestResponse(pathArg, { params: paramsArg, data: serializedData, method: methodArg, admin: adminArg });
const text = await response.text();
return {
status: response.status,
data: text.trim() ? JSON.parse(text) : undefined,
};
}
private async requestResponse(pathArg: string, optionsArg: { params?: Record<string, unknown>; data?: string; method?: 'GET' | 'POST'; admin?: boolean } = {}): Promise<Response> {
const method = optionsArg.method || 'GET';
const url = this.buildSignedUrl(pathArg, optionsArg.params, optionsArg.data, method, optionsArg.admin !== false);
const headers = new Headers();
if (optionsArg.data !== undefined) {
headers.set('content-type', 'application/json');
}
const response = await this.fetchWithTimeout(url, { method, headers, body: method === 'GET' ? undefined : optionsArg.data });
if (!response.ok) {
const text = await response.text().catch(() => '');
if (response.status === 403) {
throw new MotionEyeHttpError(response.status, 'motionEye authentication failed.');
}
throw new MotionEyeHttpError(response.status, `motionEye request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
return response;
}
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || motionEyeDefaultTimeoutMs);
try {
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
} finally {
clearTimeout(timeout);
}
}
private buildSignedUrl(pathArg: string, paramsArg: Record<string, unknown> | undefined, dataArg: string | undefined, methodArg: 'GET' | 'POST', adminArg: boolean): string {
const baseUrl = this.baseUrl();
if (!baseUrl) {
throw new Error('motionEye live HTTP client requires config.url or config.host.');
}
const url = safeUrl(pathArg) || new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${baseUrl}/`);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(paramsArg || {})) {
if (value !== undefined && value !== null) {
params.set(key, String(value));
}
}
params.set('_username', adminArg ? this.adminUsername() : this.surveillanceUsername());
url.search = params.toString();
const key = sha1(adminArg ? this.adminPassword() : this.surveillancePassword());
url.searchParams.set('_signature', computeMotionEyeSignature(methodArg, url.toString(), dataArg, key));
return url.toString();
}
private renderStreamUrlTemplate(cameraArg: IMotionEyeRawCamera | undefined): string | undefined {
const template = this.config.streamUrlTemplate?.trim();
if (!template || !cameraArg) {
return undefined;
}
return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_matchArg, keyArg: string) => String(cameraArg[keyArg] ?? ''));
}
private stripLeadingSlash(pathArg: string): string {
const path = pathArg.trim();
if (!path) {
throw new Error('motionEye media path must not be empty.');
}
return path.replace(/^\/+/, '');
}
private patchCachedSwitch(cameraIdArg: string | undefined, keyArg: string, valueArg: boolean): void {
if (!this.snapshot) {
return;
}
const camera = this.findCamera(this.snapshot, cameraIdArg);
const raw = this.snapshot.rawCameras.find((rawArg) => String(rawArg.id) === camera.id || String(rawArg.id) === String(camera.numericId));
if (raw) {
raw[keyArg] = valueArg;
}
const propertyByKey: Record<string, keyof IMotionEyeCamera> = {
motion_detection: 'motionDetectionEnabled',
text_overlay: 'textOverlayEnabled',
video_streaming: 'isStreaming',
still_images: 'stillImagesEnabled',
movies: 'moviesEnabled',
upload_enabled: 'uploadEnabled',
};
const property = propertyByKey[keyArg];
if (property) {
(camera[property] as boolean | undefined) = valueArg;
}
for (const switchArg of this.snapshot.switches) {
if (switchArg.cameraId === camera.id && switchArg.key === keyArg) {
switchArg.isOn = valueArg;
}
}
}
private findCamera(snapshotArg: IMotionEyeSnapshot, cameraIdArg?: string): IMotionEyeCamera {
const cameraId = cameraIdArg || '';
const camera = cameraId
? snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.numericId) === cameraId || cameraArg.name === cameraId)
: snapshotArg.cameras[0];
if (!camera) {
throw new Error('motionEye camera command requires a configured or discovered camera.');
}
return camera;
}
private deviceInfo(connectedArg: boolean): IMotionEyeDeviceInfo {
const endpoint = this.endpoint();
return {
...this.config.deviceInfo,
id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.url || 'manual-motioneye',
name: this.config.deviceInfo?.name || this.config.name || endpoint.host || this.config.url || 'motionEye',
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'motionEye',
model: this.config.deviceInfo?.model || this.config.model,
host: this.config.deviceInfo?.host || endpoint.host,
port: this.config.deviceInfo?.port || endpoint.port,
protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
url: this.config.deviceInfo?.url || this.baseUrl(),
online: connectedArg,
};
}
private baseUrl(): string | undefined {
if (this.config.url) {
const url = safeUrl(this.config.url);
if (url) {
return url.toString().replace(/\/$/, '');
}
}
const endpoint = this.endpoint();
if (!endpoint.host) {
return undefined;
}
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || motionEyeDefaultPort}`;
}
private endpoint(): { protocol: TMotionEyeProtocol; host?: string; port: number } {
const url = safeUrl(this.config.url || this.config.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
return {
protocol,
host: url.hostname,
port: url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort,
};
}
return {
protocol: this.config.protocol || 'http',
host: this.config.host,
port: this.config.port || motionEyeDefaultPort,
};
}
private adminUsername(): string {
return this.config.adminUsername || motionEyeDefaultAdminUsername;
}
private adminPassword(): string {
return this.config.adminPassword || '';
}
private surveillanceUsername(): string {
return this.config.surveillanceUsername || motionEyeDefaultSurveillanceUsername;
}
private surveillancePassword(): string {
return this.config.surveillancePassword || '';
}
private hasLiveTarget(): boolean {
return Boolean(this.baseUrl());
}
private stringList(valueArg: unknown): string[] {
return Array.isArray(valueArg) ? valueArg.map((entryArg) => stringValue(entryArg)).filter((entryArg): entryArg is string => Boolean(entryArg)) : [];
}
private cloneSnapshot(snapshotArg: IMotionEyeSnapshot): IMotionEyeSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IMotionEyeSnapshot;
}
}
export const computeMotionEyeSignature = (methodArg: string, pathArg: string, bodyArg: string | undefined, keyArg: string): string => {
const url = new URL(pathArg);
const query = [...url.searchParams.entries()].filter(([nameArg]) => nameArg !== '_signature').sort(([leftArg], [rightArg]) => leftArg.localeCompare(rightArg));
const queryString = query.map(([nameArg, valueArg]) => `${nameArg}=${encodeURIComponent(valueArg)}`).join('&');
const unsignedPath = `${url.pathname}${queryString ? `?${queryString}` : ''}`.replace(signatureRegex, '-');
const key = keyArg.replace(signatureRegex, '-');
const body = bodyArg && bodyArg.startsWith('---') ? '' : (bodyArg || '').replace(signatureRegex, '-');
return sha1(`${methodArg}:${unsignedPath}:${body}:${key}`).toLowerCase();
};
const sha1 = (valueArg: string): string => plugins.crypto.createHash('sha1').update(valueArg, 'utf8').digest('hex');
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const record = (valueArg: unknown): Record<string, unknown> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
};
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : undefined;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
if (typeof valueArg === 'string') {
if (['true', 'yes', 'on', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['false', 'no', 'off', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
};
const unicodeEscape = (valueArg: string): string => {
return valueArg
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/[^\x20-\x7e]/g, (charArg) => `\\u${charArg.charCodeAt(0).toString(16).padStart(4, '0')}`);
};
@@ -0,0 +1,100 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IMotionEyeConfig, TMotionEyeProtocol } from './motioneye.types.js';
import { motionEyeDefaultPort, motionEyeDefaultTimeoutMs } from './motioneye.types.js';
export class MotionEyeConfigFlow implements IConfigFlow<IMotionEyeConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMotionEyeConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect motionEye',
description: 'Configure the local motionEye HTTP endpoint. Use a base URL such as http://192.168.1.20:8765 or host plus port.',
fields: [
{ name: 'url', label: 'Base URL', type: 'text' },
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'adminUsername', label: 'Admin username', type: 'text' },
{ name: 'adminPassword', label: 'Admin password', type: 'password' },
{ name: 'surveillanceUsername', label: 'Surveillance username', type: 'text' },
{ name: 'surveillancePassword', label: 'Surveillance password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const urlValue = this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'url');
const endpoint = this.endpoint(urlValue, this.stringValue(valuesArg.host) || candidateArg.host, this.numberValue(valuesArg.port) || candidateArg.port, this.protocolMetadata(candidateArg));
if (!endpoint.host || !endpoint.url) {
return { kind: 'error', error: 'motionEye requires a base URL or host.' };
}
return {
kind: 'done',
title: 'motionEye configured',
config: {
protocol: endpoint.protocol,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
adminUsername: this.stringValue(valuesArg.adminUsername) || this.stringMetadata(candidateArg, 'adminUsername'),
adminPassword: this.stringValue(valuesArg.adminPassword) || this.stringMetadata(candidateArg, 'adminPassword'),
surveillanceUsername: this.stringValue(valuesArg.surveillanceUsername) || this.stringMetadata(candidateArg, 'surveillanceUsername'),
surveillancePassword: this.stringValue(valuesArg.surveillancePassword) || this.stringMetadata(candidateArg, 'surveillancePassword'),
name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host,
uniqueId: candidateArg.id || endpoint.host,
manufacturer: candidateArg.manufacturer || 'motionEye',
model: candidateArg.model,
timeoutMs: motionEyeDefaultTimeoutMs,
},
};
},
};
}
private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TMotionEyeProtocol | undefined): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } {
const url = safeUrl(urlArg || hostArg);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
}
const protocol = protocolArg || 'http';
const port = portArg || motionEyeDefaultPort;
return { protocol, host: hostArg, port, url: hostArg ? `${protocol}://${hostArg}:${port}` : 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)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
}
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
const value = candidateArg.metadata?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private protocolMetadata(candidateArg: IDiscoveryCandidate): TMotionEyeProtocol | undefined {
const protocol = candidateArg.metadata?.protocol;
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
}
}
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
@@ -1,31 +1,78 @@
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 { MotionEyeClient } from './motioneye.classes.client.js';
import { MotionEyeConfigFlow } from './motioneye.classes.configflow.js';
import { createMotionEyeDiscoveryDescriptor } from './motioneye.discovery.js';
import { MotionEyeMapper } from './motioneye.mapper.js';
import type { IMotionEyeConfig } from './motioneye.types.js';
export class HomeAssistantMotioneyeIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "motioneye",
displayName: "motionEye",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/motioneye",
"upstreamDomain": "motioneye",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"motioneye-client==0.3.14"
],
"dependencies": [
"http",
"webhook"
],
"afterDependencies": [
"media_source"
],
"codeowners": [
"@dermotduffy"
]
},
});
export class MotionEyeIntegration extends BaseIntegration<IMotionEyeConfig> {
public readonly domain = 'motioneye';
public readonly displayName = 'motionEye';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createMotionEyeDiscoveryDescriptor();
public readonly configFlow = new MotionEyeConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/motioneye',
upstreamDomain: 'motioneye',
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['motioneye-client==0.3.14'],
dependencies: ['http', 'webhook'],
afterDependencies: ['media_source'],
codeowners: ['@dermotduffy'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/motioneye',
nativePort: {
snapshotMapping: true,
manualUrlDiscovery: true,
liveHttpCommands: true,
liveEvents: false,
homeAssistantCompat: false,
},
};
public async setup(configArg: IMotionEyeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new MotionEyeRuntime(new MotionEyeClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantMotioneyeIntegration extends MotionEyeIntegration {}
export class HomeAssistantMotionEyeIntegration extends MotionEyeIntegration {}
class MotionEyeRuntime implements IIntegrationRuntime {
public domain = 'motioneye';
constructor(private readonly client: MotionEyeClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return MotionEyeMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return MotionEyeMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
const snapshot = await this.client.getSnapshot();
const command = MotionEyeMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported motionEye service: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
return { success: true, 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,192 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IMotionEyeManualEntry, IMotionEyeUrlRecord, TMotionEyeProtocol } from './motioneye.types.js';
import { motionEyeDefaultPort } from './motioneye.types.js';
export class MotionEyeManualMatcher implements IDiscoveryMatcher<IMotionEyeManualEntry> {
public id = 'motioneye-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual motionEye base URL or host entries.';
public async matches(inputArg: IMotionEyeManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromInput(inputArg);
const hint = hasMotionEyeHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.motioneye);
if (!endpoint.host && !hint) {
return { matched: false, confidence: 'low', reason: 'Manual motionEye entry requires a URL, host, or motionEye metadata.' };
}
const normalizedDeviceId = inputArg.id || endpoint.host || endpoint.url;
return {
matched: true,
confidence: endpoint.host ? 'high' : 'medium',
reason: endpoint.host ? 'Manual entry contains a local motionEye endpoint.' : 'Manual entry contains motionEye metadata.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: 'motioneye',
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.port,
name: inputArg.name || endpoint.host,
manufacturer: inputArg.manufacturer || 'motionEye',
model: inputArg.model,
metadata: {
...inputArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
adminUsername: inputArg.adminUsername,
adminPassword: inputArg.adminPassword,
surveillanceUsername: inputArg.surveillanceUsername,
surveillancePassword: inputArg.surveillancePassword,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export class MotionEyeUrlMatcher implements IDiscoveryMatcher<IMotionEyeUrlRecord> {
public id = 'motioneye-url-match';
public source = 'http' as const;
public description = 'Recognize local HTTP URL candidates that point at motionEye.';
public async matches(recordArg: IMotionEyeUrlRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromInput(recordArg);
const urlText = recordArg.url || String(recordArg.metadata?.url || '');
const hint = hasMotionEyeHint(recordArg.name, recordArg.manufacturer, recordArg.model, urlText) || Boolean(recordArg.metadata?.motioneye);
const defaultPortHint = endpoint.port === motionEyeDefaultPort;
if (!endpoint.host || (!hint && !defaultPortHint)) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a motionEye endpoint.' };
}
return {
matched: true,
confidence: hint ? 'high' : 'medium',
reason: hint ? 'HTTP candidate contains motionEye metadata.' : 'HTTP candidate uses the default motionEye port.',
normalizedDeviceId: endpoint.host,
candidate: {
source: 'http',
integrationDomain: 'motioneye',
id: endpoint.host,
host: endpoint.host,
port: endpoint.port,
name: recordArg.name || endpoint.host,
manufacturer: recordArg.manufacturer || 'motionEye',
model: recordArg.model,
metadata: {
...recordArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
discoveryProtocol: 'http',
},
},
};
}
}
export class MotionEyeCandidateValidator implements IDiscoveryValidator {
public id = 'motioneye-candidate-validator';
public description = 'Validate that a discovery candidate can be configured as a local motionEye server.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'motioneye') {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not motionEye.` };
}
const endpoint = endpointFromCandidate(candidateArg);
if (!endpoint.host) {
return { matched: false, confidence: 'low', reason: 'motionEye candidates require a local URL or host.' };
}
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) {
return { matched: false, confidence: 'low', reason: 'motionEye candidate has an invalid port.' };
}
const hasHint = candidateArg.integrationDomain === 'motioneye'
|| candidateArg.source === 'manual'
|| candidateArg.source === 'http'
|| endpoint.port === motionEyeDefaultPort
|| hasMotionEyeHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model)
|| Boolean(candidateArg.metadata?.motioneye);
if (!hasHint) {
return { matched: false, confidence: 'low', reason: 'Candidate does not contain motionEye metadata.' };
}
return {
matched: true,
confidence: candidateArg.integrationDomain === 'motioneye' || candidateArg.source === 'manual' ? 'high' : 'medium',
reason: 'Candidate has enough local motionEye metadata to start configuration.',
normalizedDeviceId: candidateArg.id || endpoint.host,
candidate: {
...candidateArg,
integrationDomain: 'motioneye',
id: candidateArg.id || endpoint.host,
host: endpoint.host,
port: endpoint.port,
manufacturer: candidateArg.manufacturer || 'motionEye',
metadata: {
...candidateArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
},
},
metadata: {
manualSupported: candidateArg.source === 'manual',
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export const createMotionEyeDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'motioneye', displayName: 'motionEye' })
.addMatcher(new MotionEyeManualMatcher())
.addMatcher(new MotionEyeUrlMatcher())
.addValidator(new MotionEyeCandidateValidator());
};
const endpointFromInput = (inputArg: IMotionEyeManualEntry | IMotionEyeUrlRecord): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } => {
const metadataUrl = typeof inputArg.metadata?.url === 'string' ? inputArg.metadata.url : undefined;
const url = safeUrl(inputArg.url || metadataUrl || inputArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
}
const protocol = ('protocol' in inputArg && inputArg.protocol) || 'http';
const port = inputArg.port || motionEyeDefaultPort;
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } => {
const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
const metadataProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
const url = safeUrl(metadataUrl || candidateArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
}
const port = candidateArg.port || motionEyeDefaultPort;
return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}` : metadataUrl };
};
const hasMotionEyeHint = (...valuesArgs: Array<string | undefined>): boolean => {
return valuesArgs.filter(Boolean).join(' ').toLowerCase().includes('motioneye')
|| valuesArgs.filter(Boolean).join(' ').toLowerCase().includes('motion eye');
};
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
@@ -0,0 +1,362 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IMotionEyeCamera,
IMotionEyeClientCommand,
IMotionEyeSnapshot,
IMotionEyeSwitch,
} from './motioneye.types.js';
import { motionEyeKnownActions, motionEyeSwitchDescriptions } from './motioneye.types.js';
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
const serviceBooleanKeys = ['enabled', 'enable', 'on', 'state', 'value'];
const recordStartServices = new Set(['record_start', 'start_recording', 'enable_recording']);
const recordStopServices = new Set(['record_stop', 'stop_recording', 'disable_recording']);
export class MotionEyeMapper {
public static toDevices(snapshotArg: IMotionEyeSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
return snapshotArg.cameras.map((cameraArg) => {
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
{ id: 'camera', capability: 'camera', name: cameraArg.name, readable: true, writable: true },
{ id: 'motion_detection', capability: 'switch', name: 'Motion detection', readable: true, writable: true },
{ id: 'recording', capability: 'switch', name: 'Recording', readable: false, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: snapshotArg.connected && cameraArg.available !== false ? 'online' : 'offline', updatedAt },
{ featureId: 'camera', value: { mjpegUrl: cameraArg.mjpegUrl || null, snapshotUrl: cameraArg.snapshotUrl || null, isStreaming: cameraArg.isStreaming }, updatedAt },
{ featureId: 'motion_detection', value: cameraArg.motionDetectionEnabled, updatedAt },
{ featureId: 'recording', value: 'action', updatedAt },
];
for (const switchArg of snapshotArg.switches.filter((switchArg) => switchArg.cameraId === cameraArg.id)) {
if (!features.some((featureArg) => featureArg.id === `switch_${this.slug(switchArg.key)}`)) {
features.push({ id: `switch_${this.slug(switchArg.key)}`, capability: 'switch', name: switchArg.name, readable: true, writable: true });
}
state.push({ featureId: `switch_${this.slug(switchArg.key)}`, value: switchArg.isOn, updatedAt });
}
return {
id: this.deviceId(snapshotArg, cameraArg),
integrationDomain: 'motioneye',
name: cameraArg.name,
protocol: 'http',
manufacturer: snapshotArg.deviceInfo.manufacturer || 'motionEye',
model: snapshotArg.deviceInfo.model || 'motionEye camera',
online: snapshotArg.connected && cameraArg.available !== false,
features,
state,
metadata: {
motionEyeUrl: snapshotArg.deviceInfo.url,
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
protocol: snapshotArg.deviceInfo.protocol,
cameraId: cameraArg.id,
numericId: cameraArg.numericId,
streamingPort: cameraArg.streamingPort,
streamingAuthMode: cameraArg.streamingAuthMode,
rootDirectory: cameraArg.rootDirectory,
actions: cameraArg.actions,
},
};
});
}
public static toEntities(snapshotArg: IMotionEyeSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
for (const camera of snapshotArg.cameras) {
const deviceId = this.deviceId(snapshotArg, camera);
entities.push(this.entity('camera' as TEntityPlatform, camera.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_camera_${this.slug(camera.id)}`, snapshotArg.connected && camera.available !== false ? 'idle' : 'unavailable', usedIds, {
cameraId: camera.id,
numericId: camera.numericId,
mjpegUrl: camera.mjpegUrl,
streamSource: camera.mjpegUrl,
snapshotUrl: camera.snapshotUrl,
stillImageUrl: camera.snapshotUrl,
streamingPort: camera.streamingPort,
streamingAuthMode: camera.streamingAuthMode,
motionDetectionEnabled: camera.motionDetectionEnabled,
actions: camera.actions,
rootDirectory: camera.rootDirectory,
supportedFeatures: this.supportedCameraFeatures(camera),
serviceMappings: {
snapshot: 'camera.snapshot',
streamSource: 'camera.stream_source',
motionDetection: 'camera.enable_motion_detection',
action: 'motioneye.action',
recordStart: 'motioneye.record_start',
recordStop: 'motioneye.record_stop',
},
...camera.attributes,
}, snapshotArg.connected && camera.available !== false));
}
for (const switchArg of snapshotArg.switches) {
const camera = this.cameraById(snapshotArg, switchArg.cameraId);
const deviceId = camera ? this.deviceId(snapshotArg, camera) : `motioneye.device.${this.uniqueBase(snapshotArg)}`;
entities.push(this.entity('switch', switchArg.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_${this.slug(switchArg.cameraId)}_${this.slug(switchArg.key)}`, switchArg.isOn ? 'on' : 'off', usedIds, {
key: switchArg.key,
cameraId: switchArg.cameraId,
entityCategory: switchArg.entityCategory,
...switchArg.attributes,
}, snapshotArg.connected && switchArg.available !== false));
}
for (const sensor of snapshotArg.sensors) {
const camera = sensor.cameraId ? this.cameraById(snapshotArg, sensor.cameraId) : undefined;
const deviceId = camera ? this.deviceId(snapshotArg, camera) : `motioneye.device.${this.uniqueBase(snapshotArg)}`;
entities.push(this.entity('sensor', sensor.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.cameraId || 'hub')}_${this.slug(sensor.key)}`, sensor.value ?? 'unknown', usedIds, {
key: sensor.key,
cameraId: sensor.cameraId,
unit: sensor.unit,
deviceClass: sensor.deviceClass,
entityCategory: sensor.entityCategory,
...sensor.attributes,
}, snapshotArg.connected && sensor.available !== false));
}
return entities;
}
public static commandForService(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
const camera = this.findCamera(snapshotArg, requestArg);
return { type: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id };
}
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
const camera = this.findCamera(snapshotArg, requestArg);
return {
type: 'snapshot_image',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
cameraId: camera?.id,
filename: this.stringValue(requestArg.data?.filename),
httpCommands: camera?.numericId === undefined ? undefined : [{ label: 'snapshot', method: 'GET', path: `/picture/${camera.numericId}/current/`, admin: false }],
};
}
if (requestArg.domain === 'camera' && (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection')) {
const camera = this.findCamera(snapshotArg, requestArg);
const enabled = requestArg.service === 'enable_motion_detection';
return this.switchCommand(camera, 'motion_detection', enabled, requestArg);
}
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
const switchEntity = this.findSwitch(snapshotArg, requestArg);
if (!switchEntity) {
return undefined;
}
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !switchEntity.isOn;
return this.switchCommand(this.cameraById(snapshotArg, switchEntity.cameraId), switchEntity.key, enabled, requestArg);
}
if (requestArg.domain === 'motioneye') {
return this.motionEyeCommand(snapshotArg, requestArg);
}
return undefined;
}
public static deviceId(snapshotArg: IMotionEyeSnapshot, cameraArg: IMotionEyeCamera): string {
return `motioneye.camera.${this.uniqueBase(snapshotArg)}.${this.slug(cameraArg.id)}`;
}
private static motionEyeCommand(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
if (requestArg.service === 'refresh') {
return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (cameraStreamServices.has(requestArg.service) || cameraSnapshotServices.has(requestArg.service)) {
return this.commandForService(snapshotArg, { ...requestArg, domain: 'camera' });
}
const camera = this.findCamera(snapshotArg, requestArg);
if (requestArg.service === 'action') {
const action = this.stringValue(requestArg.data?.action);
return action ? this.actionCommand(camera, action, requestArg) : undefined;
}
if (requestArg.service === 'snapshot') {
return this.actionCommand(camera, 'snapshot', requestArg);
}
if (recordStartServices.has(requestArg.service)) {
return this.actionCommand(camera, 'record_start', requestArg);
}
if (recordStopServices.has(requestArg.service)) {
return this.actionCommand(camera, 'record_stop', requestArg);
}
if (requestArg.service === 'set_text_overlay') {
const leftText = this.stringValue(requestArg.data?.left_text ?? requestArg.data?.leftText);
const rightText = this.stringValue(requestArg.data?.right_text ?? requestArg.data?.rightText);
const customLeftText = this.stringValue(requestArg.data?.custom_left_text ?? requestArg.data?.customLeftText);
const customRightText = this.stringValue(requestArg.data?.custom_right_text ?? requestArg.data?.customRightText);
if ([leftText, rightText, customLeftText, customRightText].every((valueArg) => valueArg === undefined)) {
return undefined;
}
return {
type: 'set_text_overlay',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
cameraId: camera?.id,
leftText,
rightText,
customLeftText,
customRightText,
httpCommands: camera?.numericId === undefined ? undefined : [{ label: 'set_text_overlay', method: 'POST', path: `/config/${camera.numericId}/set`, admin: true }],
};
}
if (requestArg.service === 'set_switch' || requestArg.service === 'set_camera_setting') {
const key = this.switchKey(requestArg.data?.key ?? requestArg.data?.setting);
const enabled = this.booleanFromData(requestArg.data);
return key && enabled !== undefined ? this.switchCommand(camera, key, enabled, requestArg) : undefined;
}
if (requestArg.service === 'media_list') {
const kind = requestArg.data?.kind === 'movies' || requestArg.data?.mediaKind === 'movies' ? 'movies' : 'images';
return { type: 'media_list', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, mediaKind: kind, prefix: this.stringValue(requestArg.data?.prefix) };
}
const switchKey = requestArg.service.startsWith('set_') ? this.switchKey(requestArg.service.slice(4)) : this.switchKey(requestArg.service);
if (switchKey) {
const enabled = this.booleanFromData(requestArg.data) ?? true;
return this.switchCommand(camera, switchKey, enabled, requestArg);
}
const action = motionEyeKnownActions.includes(requestArg.service as typeof motionEyeKnownActions[number]) ? requestArg.service : undefined;
return action ? this.actionCommand(camera, action, requestArg) : undefined;
}
private static actionCommand(cameraArg: IMotionEyeCamera | undefined, actionArg: string, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
if (!actionArg.trim()) {
return undefined;
}
return {
type: 'action',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
cameraId: cameraArg?.id,
action: actionArg,
httpCommands: cameraArg?.numericId === undefined ? undefined : [{ label: `action:${actionArg}`, method: 'POST', path: `/action/${cameraArg.numericId}/${encodeURIComponent(actionArg)}`, admin: true, data: {} }],
};
}
private static switchCommand(cameraArg: IMotionEyeCamera | undefined, keyArg: string, enabledArg: boolean, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
if (!cameraArg) {
return undefined;
}
return {
type: 'set_switch',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
cameraId: cameraArg.id,
key: keyArg,
enabled: enabledArg,
httpCommands: cameraArg.numericId === undefined ? undefined : [{ label: keyArg, method: 'POST', path: `/config/${cameraArg.numericId}/set`, admin: true }],
};
}
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
const baseId = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
const seen = usedIdsArg.get(baseId) || 0;
usedIdsArg.set(baseId, seen + 1);
return {
id: seen ? `${baseId}_${seen + 1}` : baseId,
uniqueId: uniqueIdArg,
integrationDomain: 'motioneye',
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static findCamera(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeCamera | undefined {
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.cameraId ?? requestArg.data?.camera_id ?? requestArg.data?.camera);
if (!target) {
return snapshotArg.cameras[0];
}
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === ('camera' as TEntityPlatform));
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.cameraId === target || entityArg.attributes?.numericId === target);
const cameraId = this.stringValue(entity?.attributes?.cameraId) || target;
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.numericId) === cameraId || this.deviceId(snapshotArg, cameraArg) === target) || snapshotArg.cameras[0];
}
private static findSwitch(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeSwitch | undefined {
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key ?? requestArg.data?.setting);
if (!target) {
return snapshotArg.switches[0];
}
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch');
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target);
const key = this.stringValue(entity?.attributes?.key) || this.switchKey(target);
const cameraId = this.stringValue(entity?.attributes?.cameraId);
return snapshotArg.switches.find((switchArg) => (key ? switchArg.key === key : false) && (!cameraId || switchArg.cameraId === cameraId))
|| snapshotArg.switches.find((switchArg) => switchArg.key === target || switchArg.name === target);
}
private static cameraById(snapshotArg: IMotionEyeSnapshot, cameraIdArg: string): IMotionEyeCamera | undefined {
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraIdArg || String(cameraArg.numericId) === cameraIdArg);
}
private static supportedCameraFeatures(cameraArg: IMotionEyeCamera): string[] {
const features = ['stream', 'snapshot', 'motion_detection'];
if (cameraArg.actions.includes('record_start') || cameraArg.actions.includes('record_stop')) {
features.push('recording');
}
if (cameraArg.actions.length) {
features.push('actions');
}
return features;
}
private static switchKey(valueArg: unknown): string | undefined {
const value = this.stringValue(valueArg);
return value && motionEyeSwitchDescriptions.some((descriptionArg) => descriptionArg.key === value) ? value : undefined;
}
private static deviceName(snapshotArg: IMotionEyeSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'motionEye';
}
private static uniqueBase(snapshotArg: IMotionEyeSnapshot): string {
return this.slug(snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || snapshotArg.deviceInfo.url || this.deviceName(snapshotArg));
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
private static booleanFromData(dataArg: Record<string, unknown> | undefined): boolean | undefined {
for (const key of serviceBooleanKeys) {
const value = this.booleanValue(dataArg?.[key]);
if (value !== undefined) {
return value;
}
}
return undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : undefined;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'motioneye';
}
}
+242 -2
View File
@@ -1,4 +1,244 @@
export interface IHomeAssistantMotioneyeConfig {
// TODO: replace with the TypeScript-native config for motioneye.
export const motionEyeDefaultPort = 8765;
export const motionEyeDefaultTimeoutMs = 10000;
export const motionEyeDefaultAdminUsername = 'admin';
export const motionEyeDefaultSurveillanceUsername = 'user';
export const motionEyeKnownActions = [
'snapshot',
'record_start',
'record_stop',
'lock',
'unlock',
'light_on',
'light_off',
'alarm_on',
'alarm_off',
'up',
'right',
'down',
'left',
'zoom_in',
'zoom_out',
'preset1',
'preset2',
'preset3',
'preset4',
'preset5',
'preset6',
'preset7',
'preset8',
'preset9',
] as const;
export type TMotionEyeProtocol = 'http' | 'https';
export type TMotionEyeAuthMode = 'basic' | 'digest' | string;
export type TMotionEyeHttpMethod = 'GET' | 'POST';
export type TMotionEyeCommandType =
| 'refresh'
| 'stream_source'
| 'snapshot_image'
| 'action'
| 'set_switch'
| 'set_text_overlay'
| 'media_list';
export type TMotionEyeMediaKind = 'images' | 'movies';
export interface IMotionEyeConfig {
protocol?: TMotionEyeProtocol;
host?: string;
port?: number;
url?: string;
adminUsername?: string;
adminPassword?: string;
surveillanceUsername?: string;
surveillancePassword?: string;
timeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
streamUrlTemplate?: string;
connected?: boolean;
deviceInfo?: IMotionEyeDeviceInfo;
cameras?: IMotionEyeCamera[];
rawCameras?: IMotionEyeRawCamera[];
sensors?: IMotionEyeSensor[];
switches?: IMotionEyeSwitch[];
manifest?: Record<string, unknown>;
serverConfig?: Record<string, unknown>;
snapshot?: IMotionEyeSnapshot;
}
export interface IHomeAssistantMotioneyeConfig extends IMotionEyeConfig {}
export interface IHomeAssistantMotionEyeConfig extends IMotionEyeConfig {}
export interface IMotionEyeDeviceInfo {
id?: string;
name?: string;
manufacturer?: string;
model?: string;
host?: string;
port?: number;
protocol?: TMotionEyeProtocol;
url?: string;
online?: boolean;
}
export interface IMotionEyeRawCamera {
id?: number | string;
name?: string;
host?: string;
streaming_port?: number | string;
streaming_auth_mode?: TMotionEyeAuthMode;
video_streaming?: boolean;
motion_detection?: boolean;
text_overlay?: boolean;
still_images?: boolean;
movies?: boolean;
upload_enabled?: boolean;
actions?: string[];
root_directory?: string;
[key: string]: unknown;
}
export interface IMotionEyeCamera {
id: string;
numericId?: number;
name: string;
host?: string;
streamingPort?: number;
streamingAuthMode?: TMotionEyeAuthMode;
mjpegUrl?: string;
snapshotUrl?: string;
isStreaming: boolean;
motionDetectionEnabled: boolean;
textOverlayEnabled?: boolean;
stillImagesEnabled?: boolean;
moviesEnabled?: boolean;
uploadEnabled?: boolean;
actions: string[];
rootDirectory?: string;
available?: boolean;
raw?: IMotionEyeRawCamera;
attributes?: Record<string, unknown>;
}
export interface IMotionEyeSensor<TValue = unknown> {
key: string;
name: string;
cameraId?: string;
value: TValue;
unit?: string;
deviceClass?: string;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IMotionEyeSwitch {
key: string;
name: string;
cameraId: string;
isOn: boolean;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IMotionEyeSnapshot {
deviceInfo: IMotionEyeDeviceInfo;
cameras: IMotionEyeCamera[];
sensors: IMotionEyeSensor[];
switches: IMotionEyeSwitch[];
rawCameras: IMotionEyeRawCamera[];
connected: boolean;
updatedAt?: string;
manifest?: Record<string, unknown>;
serverConfig?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface IMotionEyeHttpCommand {
label: string;
method: TMotionEyeHttpMethod;
path: string;
admin: boolean;
data?: Record<string, unknown>;
}
export interface IMotionEyeCommandResponse {
ok: boolean;
label: string;
method: TMotionEyeHttpMethod;
path: string;
status: number;
response?: unknown;
}
export interface IMotionEyeClientCommand {
type: TMotionEyeCommandType;
service: string;
target?: {
entityId?: string;
deviceId?: string;
};
data?: Record<string, unknown>;
cameraId?: string;
action?: string;
key?: string;
enabled?: boolean;
leftText?: string;
rightText?: string;
customLeftText?: string;
customRightText?: string;
mediaKind?: TMotionEyeMediaKind;
prefix?: string;
filename?: string;
httpCommands?: IMotionEyeHttpCommand[];
}
export interface IMotionEyeSnapshotImage {
contentType: string;
data: Uint8Array;
}
export interface IMotionEyeManualEntry {
host?: string;
port?: number;
url?: string;
protocol?: TMotionEyeProtocol;
adminUsername?: string;
adminPassword?: string;
surveillanceUsername?: string;
surveillancePassword?: string;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export interface IMotionEyeUrlRecord {
url?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export interface IMotionEyeSwitchDescription {
key: string;
name: string;
entityCategory?: string;
}
export const motionEyeSwitchDescriptions: IMotionEyeSwitchDescription[] = [
{ key: 'motion_detection', name: 'Motion detection', entityCategory: 'config' },
{ key: 'text_overlay', name: 'Text overlay', entityCategory: 'config' },
{ key: 'video_streaming', name: 'Video streaming', entityCategory: 'config' },
{ key: 'still_images', name: 'Still images', entityCategory: 'config' },
{ key: 'movies', name: 'Movies', entityCategory: 'config' },
{ key: 'upload_enabled', name: 'Upload enabled', entityCategory: 'config' },
];
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './opnsense.classes.client.js';
export * from './opnsense.classes.configflow.js';
export * from './opnsense.classes.integration.js';
export * from './opnsense.discovery.js';
export * from './opnsense.mapper.js';
export * from './opnsense.types.js';
@@ -0,0 +1,107 @@
import type { IOpnsenseCommand, IOpnsenseCommandResult, IOpnsenseConfig, IOpnsenseEvent, IOpnsenseSnapshot } from './opnsense.types.js';
import { OpnsenseMapper } from './opnsense.mapper.js';
type TOpnsenseEventHandler = (eventArg: IOpnsenseEvent) => void;
export class OpnsenseClient {
private currentSnapshot?: IOpnsenseSnapshot;
private readonly eventHandlers = new Set<TOpnsenseEventHandler>();
constructor(private readonly config: IOpnsenseConfig) {}
public async getSnapshot(): Promise<IOpnsenseSnapshot> {
if (this.config.nativeClient) {
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.snapshotProvider) {
const provided = await this.config.snapshotProvider();
if (provided) {
this.currentSnapshot = this.normalizeSnapshot(provided, 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
}
if (!this.currentSnapshot) {
this.currentSnapshot = OpnsenseMapper.toSnapshot(this.config);
}
return this.cloneSnapshot(this.currentSnapshot);
}
public onEvent(handlerArg: TOpnsenseEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IOpnsenseCommandResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
this.emit({ type: 'snapshot_refreshed', snapshot, timestamp: Date.now() });
return { success: true, data: snapshot };
} catch (errorArg) {
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
const snapshot = OpnsenseMapper.toSnapshot({ ...this.config, connected: false, snapshot: this.currentSnapshot }, false);
this.currentSnapshot = snapshot;
this.emit({ type: 'refresh_failed', snapshot, error, timestamp: Date.now() });
return { success: false, error, data: snapshot };
}
}
public async sendCommand(commandArg: IOpnsenseCommand): Promise<IOpnsenseCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
if (!executor) {
const result: IOpnsenseCommandResult = {
success: false,
error: 'OPNsense live API commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for reboot, service, firewall, VPN, interface, firmware, or switch actions.',
data: { command: commandArg },
};
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
try {
const result = this.commandResult(await executor(commandArg), commandArg);
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
} catch (errorArg) {
const result: IOpnsenseCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
}
public async destroy(): Promise<void> {
await this.config.nativeClient?.destroy?.();
this.eventHandlers.clear();
}
private normalizeSnapshot(snapshotArg: IOpnsenseSnapshot, sourceArg: IOpnsenseSnapshot['source']): IOpnsenseSnapshot {
const normalized = OpnsenseMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
return { ...normalized, source: snapshotArg.source || sourceArg };
}
private commandResult(resultArg: unknown, commandArg: IOpnsenseCommand): IOpnsenseCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IOpnsenseCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private emit(eventArg: IOpnsenseEvent): void {
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot<T extends IOpnsenseSnapshot | undefined>(snapshotArg: T): T {
return snapshotArg ? JSON.parse(JSON.stringify(snapshotArg)) as T : snapshotArg;
}
}
@@ -0,0 +1,152 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IOpnsenseConfig, IOpnsenseSnapshot } from './opnsense.types.js';
import { opnsenseDefaultPort, opnsenseDefaultVerifySsl } from './opnsense.types.js';
export class OpnsenseConfigFlow implements IConfigFlow<IOpnsenseConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOpnsenseConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect OPNsense',
description: 'Provide a local HTTPS OPNsense API endpoint. Snapshot/manual data is supported natively; live API success is only reported through an injected native client or command executor.',
fields: [
{ name: 'url', label: candidateArg.host ? `URL or host (${candidateArg.host})` : 'URL or host', type: 'text', required: true },
{ name: 'port', label: `HTTPS port (${candidateArg.port || opnsenseDefaultPort})`, type: 'number' },
{ name: 'apiKey', label: 'API key', type: 'text' },
{ name: 'apiSecret', label: 'API secret', type: 'password' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'trackerInterfaces', label: 'Tracker interface descriptions, comma-separated', type: 'text' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IOpnsenseConfig>> {
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid OPNsense snapshot', error: snapshot.message };
}
const endpoint = parseHttpsEndpoint(stringValue(valuesArg.url) || candidateArg.metadata?.url as string | undefined || candidateArg.host || snapshot?.router.url || snapshot?.router.host || '');
if (endpoint.error) {
return { kind: 'error', title: 'Invalid OPNsense endpoint', error: endpoint.error };
}
if (!endpoint.host && !snapshot) {
return { kind: 'error', title: 'OPNsense setup failed', error: 'OPNsense setup requires a local HTTPS host/URL or snapshot JSON.' };
}
const port = numberValue(valuesArg.port) || candidateArg.port || endpoint.port || snapshot?.router.port || (endpoint.host ? opnsenseDefaultPort : undefined);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
return { kind: 'error', title: 'Invalid OPNsense port', error: 'OPNsense port must be an integer between 1 and 65535.' };
}
const apiKey = stringValue(valuesArg.apiKey) || stringValue(candidateArg.metadata?.apiKey);
const apiSecret = stringValue(valuesArg.apiSecret) || stringValue(candidateArg.metadata?.apiSecret);
if (Boolean(apiKey) !== Boolean(apiSecret)) {
return { kind: 'error', title: 'Incomplete OPNsense API credentials', error: 'OPNsense API key and API secret must be provided together.' };
}
const trackerInterfaces = listValue(valuesArg.trackerInterfaces) || listValue(candidateArg.metadata?.trackerInterfaces) || [];
const verifySsl = booleanValue(valuesArg.verifySsl) ?? booleanValue(candidateArg.metadata?.verifySsl) ?? snapshot?.router.verifySsl ?? opnsenseDefaultVerifySsl;
const url = endpoint.host ? endpointUrl(endpoint.host, port) : snapshot?.router.url;
const config: IOpnsenseConfig = {
url,
host: endpoint.host || snapshot?.router.host,
port,
ssl: true,
verifySsl,
apiKey,
apiSecret,
trackerInterfaces,
uniqueId: candidateArg.id || snapshot?.router.macAddress || snapshot?.router.id,
name: candidateArg.name || snapshot?.router.name || endpoint.host || 'OPNsense',
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: candidateArg.metadata,
liveHttpImplemented: false,
},
};
return {
kind: 'done',
title: 'OPNsense configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IOpnsenseSnapshot | undefined | Error {
if (isOpnsenseSnapshot(valueArg)) {
return valueArg;
}
const text = stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as IOpnsenseSnapshot;
if (!isOpnsenseSnapshot(parsed)) {
return new Error('Snapshot JSON must include router, interfaces, services, vpn, firewall, system, telemetry, sensors, and connected fields.');
}
return parsed;
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
}
const parseHttpsEndpoint = (valueArg: string): { host?: string; port?: number; error?: string } => {
const value = valueArg.trim();
if (!value) {
return {};
}
try {
const parsed = new URL(value.includes('://') ? value : `https://${value}`);
if (parsed.protocol !== 'https:') {
return { error: 'OPNsense setup only supports local HTTPS candidates.' };
}
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
};
} catch {
return { error: 'OPNsense endpoint must be a hostname, IP address, or HTTPS URL.' };
}
};
const endpointUrl = (hostArg: string, portArg: number | undefined): string => {
const port = portArg || opnsenseDefaultPort;
return `https://${hostArg}${port === opnsenseDefaultPort ? '' : `:${port}`}`;
};
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return Math.round(valueArg);
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Math.round(Number(valueArg));
return undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
return typeof valueArg === 'boolean' ? valueArg : undefined;
};
const listValue = (valueArg: unknown): string[] | undefined => {
if (Array.isArray(valueArg)) {
const values = valueArg.filter((entryArg): entryArg is string => typeof entryArg === 'string' && entryArg.trim().length > 0).map((entryArg) => entryArg.trim());
return values.length ? values : undefined;
}
const text = stringValue(valueArg);
if (!text) {
return undefined;
}
const values = text.split(',').map((entryArg) => entryArg.trim()).filter(Boolean);
return values.length ? values : undefined;
};
const isOpnsenseSnapshot = (valueArg: unknown): valueArg is IOpnsenseSnapshot => {
return Boolean(valueArg && typeof valueArg === 'object' && 'router' in valueArg && 'connected' in valueArg);
};
@@ -1,28 +1,99 @@
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 { OpnsenseClient } from './opnsense.classes.client.js';
import { OpnsenseConfigFlow } from './opnsense.classes.configflow.js';
import { createOpnsenseDiscoveryDescriptor } from './opnsense.discovery.js';
import { OpnsenseMapper } from './opnsense.mapper.js';
import type { IOpnsenseConfig } from './opnsense.types.js';
import { opnsenseDomain } from './opnsense.types.js';
export class HomeAssistantOpnsenseIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "opnsense",
displayName: "OPNsense",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/opnsense",
"upstreamDomain": "opnsense",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"aiopnsense==1.0.8"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@HarlemSquirrel",
"@Snuffy2"
]
},
});
export class OpnsenseIntegration extends BaseIntegration<IOpnsenseConfig> {
public readonly domain = opnsenseDomain;
public readonly displayName = 'OPNsense';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createOpnsenseDiscoveryDescriptor();
public readonly configFlow = new OpnsenseConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/opnsense',
upstreamDomain: opnsenseDomain,
integrationType: 'hub',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: ['aiopnsense==1.0.8'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@HarlemSquirrel', '@Snuffy2'],
documentation: 'https://www.home-assistant.io/integrations/opnsense',
runtime: {
mode: 'native TypeScript snapshot/manual OPNsense mapping',
platforms: ['binary_sensor', 'button', 'device_tracker', 'sensor', 'switch', 'update'],
services: ['refresh', 'snapshot', 'status', 'reboot', 'halt', 'start_service', 'stop_service', 'restart_service', 'reload_interface', 'firmware_update', 'upgrade_firmware', 'close_notice', 'send_wol', 'run_speedtest'],
},
localApi: {
implemented: [
'manual and local HTTPS OPNsense setup candidates plus config flow matching Home Assistant api_key/api_secret/verify_ssl/tracker_interfaces inputs',
'Home Assistant legacy device_tracker-style ARP table mapping filtered by interface description',
'snapshot mapping for system, firewall/NAT/aliases, interfaces, gateways, VPN, services, telemetry sensors, generic sensors, and switches where represented',
'safe command modeling for represented router, service, firewall, NAT, alias, VPN, interface, firmware, notice, Wake-on-LAN, speedtest, and switch actions',
],
explicitUnsupported: [
'Home Assistant compatibility shims',
'fake OPNsense HTTPS API connection, validation, or command success without commandExecutor/nativeClient injection',
'full aiopnsense live HTTP implementation in dependency-free TypeScript',
],
},
};
public async setup(configArg: IOpnsenseConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new OpnsenseRuntime(new OpnsenseClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantOpnsenseIntegration extends OpnsenseIntegration {}
class OpnsenseRuntime implements IIntegrationRuntime {
public domain = opnsenseDomain;
constructor(private readonly client: OpnsenseClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return OpnsenseMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return OpnsenseMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OpnsenseMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === opnsenseDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === opnsenseDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = OpnsenseMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported OPNsense service mapping: ${requestArg.domain}.${requestArg.service}` };
}
if ('error' in command) {
return { success: false, error: command.error };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,151 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { OpnsenseMapper } from './opnsense.mapper.js';
import type { IOpnsenseManualDiscoveryRecord, IOpnsenseSnapshot } from './opnsense.types.js';
import { opnsenseDefaultPort, opnsenseDefaultVerifySsl, opnsenseDomain } from './opnsense.types.js';
const opnsenseTextHints = ['opnsense', 'opn sense', 'deciso'];
export class OpnsenseManualMatcher implements IDiscoveryMatcher<IOpnsenseManualDiscoveryRecord> {
public id = 'opnsense-manual-https-match';
public source = 'manual' as const;
public description = 'Recognize manual OPNsense HTTPS setup entries, including snapshot-only records.';
public async matches(inputArg: IOpnsenseManualDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot as IOpnsenseSnapshot | undefined;
const endpoint = parseEndpoint(inputArg.url || inputArg.host || snapshot?.router.url || snapshot?.router.host);
const mac = OpnsenseMapper.normalizeMac(inputArg.macAddress || snapshot?.router.macAddress);
const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.name]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const hasSnapshot = Boolean(snapshot);
const matched = inputArg.integrationDomain === opnsenseDomain
|| metadata.opnsense === true
|| hasSnapshot
|| opnsenseTextHints.some((hintArg) => text.includes(hintArg))
|| Boolean(endpoint.host && !text);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain OPNsense setup hints.' };
}
if (endpoint.error) {
return { matched: false, confidence: 'medium', reason: endpoint.error };
}
const port = inputArg.port || endpoint.port || snapshot?.router.port || opnsenseDefaultPort;
const id = inputArg.id || mac || snapshot?.router.id || (endpoint.host ? `${endpoint.host}:${port}` : undefined);
return {
matched: true,
confidence: hasSnapshot || mac ? 'certain' : endpoint.host ? 'high' : 'medium',
reason: hasSnapshot ? 'Manual entry includes an OPNsense snapshot.' : 'Manual entry can start OPNsense HTTPS setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: opnsenseDomain,
id,
host: endpoint.host,
port,
name: inputArg.name || snapshot?.router.name || endpoint.host || 'OPNsense',
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'OPNsense',
model: inputArg.model || snapshot?.router.model || 'OPNsense Firewall',
macAddress: mac,
metadata: {
...metadata,
opnsense: true,
protocol: 'https',
ssl: true,
verifySsl: inputArg.verifySsl ?? opnsenseDefaultVerifySsl,
url: endpoint.host ? endpointUrl(endpoint.host, port) : inputArg.url,
hasSnapshot,
liveHttpImplemented: false,
trackerInterfaces: inputArg.trackerInterfaces,
snapshot,
},
},
metadata: { hasSnapshot, protocol: 'https', liveHttpImplemented: false },
};
}
}
export class OpnsenseHttpsCandidateValidator implements IDiscoveryValidator {
public id = 'opnsense-https-candidate-validator';
public description = 'Validate OPNsense candidates have HTTPS metadata and a host or snapshot before config flow.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IOpnsenseSnapshot | undefined;
const endpoint = parseEndpoint(metadata.url as string | undefined || candidateArg.host || snapshot?.router.url || snapshot?.router.host);
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = candidateArg.integrationDomain === opnsenseDomain
|| metadata.opnsense === true
|| Boolean(snapshot)
|| opnsenseTextHints.some((hintArg) => text.includes(hintArg));
const hasUsableSource = Boolean(endpoint.host || snapshot);
if (endpoint.error) {
return { matched: false, confidence: matched ? 'medium' : 'low', reason: endpoint.error };
}
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'OPNsense candidate lacks a usable HTTPS host or snapshot.' : 'Candidate is not OPNsense.',
};
}
const mac = OpnsenseMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress);
const port = candidateArg.port || endpoint.port || snapshot?.router.port || opnsenseDefaultPort;
const normalizedDeviceId = candidateArg.id || mac || (endpoint.host ? `${endpoint.host}:${port}` : snapshot?.router.id);
return {
matched: true,
confidence: mac || snapshot ? 'certain' : endpoint.host ? 'high' : 'medium',
reason: 'Candidate has OPNsense metadata and a usable local HTTPS source.',
normalizedDeviceId,
candidate: {
...candidateArg,
id: candidateArg.id || normalizedDeviceId,
host: endpoint.host || candidateArg.host,
port,
macAddress: mac || candidateArg.macAddress,
metadata: {
...metadata,
protocol: 'https',
ssl: true,
verifySsl: metadata.verifySsl ?? opnsenseDefaultVerifySsl,
liveHttpImplemented: false,
},
},
metadata: { protocol: 'https', liveHttpImplemented: false },
};
}
}
export const createOpnsenseDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: opnsenseDomain, displayName: 'OPNsense' })
.addMatcher(new OpnsenseManualMatcher())
.addValidator(new OpnsenseHttpsCandidateValidator());
};
const parseEndpoint = (valueArg: string | undefined): { host?: string; port?: number; error?: string } => {
if (!valueArg) return {};
try {
const parsed = new URL(valueArg.includes('://') ? valueArg : `https://${valueArg}`);
if (parsed.protocol !== 'https:') {
return { error: 'OPNsense discovery only accepts local HTTPS candidates.' };
}
return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
} catch {
return { error: 'OPNsense endpoint must be a hostname, IP address, or HTTPS URL.' };
}
};
const endpointUrl = (hostArg: string, portArg: number | undefined): string => {
const port = portArg || opnsenseDefaultPort;
return `https://${hostArg}${port === opnsenseDefaultPort ? '' : `:${port}`}`;
};
File diff suppressed because it is too large Load Diff
+493 -2
View File
@@ -1,4 +1,495 @@
export interface IHomeAssistantOpnsenseConfig {
// TODO: replace with the TypeScript-native config for opnsense.
import type { IServiceCallResult } from '../../core/types.js';
export const opnsenseDomain = 'opnsense';
export const opnsenseDefaultPort = 443;
export const opnsenseDefaultVerifySsl = false;
export const opnsenseDefaultTimeoutMs = 10000;
export type TOpnsenseSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
export type TOpnsenseHttpMethod = 'GET' | 'POST';
export type TOpnsenseRouterAction = 'reboot' | 'halt' | 'firmware_update' | 'firmware_upgrade';
export type TOpnsenseServiceAction = 'start' | 'stop' | 'restart';
export type TOpnsenseToggleAction = 'toggle' | 'enable' | 'disable';
export type TOpnsenseInterfaceAction = 'reload';
export type TOpnsenseCommandType =
| 'router.action'
| 'service.action'
| 'firewall.toggle'
| 'nat.toggle'
| 'alias.toggle'
| 'vpn.toggle'
| 'interface.reload'
| 'notice.close'
| 'wol.send'
| 'firmware.action'
| 'unbound.toggle'
| 'speedtest.run'
| 'switch.action';
export type TOpnsenseJsonValue = string | number | boolean | null | TOpnsenseJsonValue[] | {
[key: string]: TOpnsenseJsonValue | undefined;
};
export interface IOpnsenseConfig {
url?: string;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
verify_ssl?: boolean;
apiKey?: string;
api_key?: string;
apiSecret?: string;
api_secret?: string;
timeoutMs?: number;
trackerInterfaces?: string[];
tracker_interfaces?: string[];
connected?: boolean;
uniqueId?: string;
name?: string;
snapshot?: IOpnsenseSnapshot;
router?: IOpnsenseRouterInfo;
devices?: IOpnsenseClientDevice[];
clients?: IOpnsenseClientDevice[];
arpTable?: IOpnsenseArpEntry[];
interfaces?: IOpnsenseInterfaceInfo[];
gateways?: IOpnsenseGatewayInfo[];
firewall?: IOpnsenseFirewallSnapshot;
system?: IOpnsenseSystemInfo;
telemetry?: IOpnsenseTelemetryInfo;
services?: IOpnsenseServiceInfo[];
vpn?: IOpnsenseVpnSnapshot;
vpns?: IOpnsenseVpnSnapshot;
sensors?: IOpnsenseSensorMap;
switches?: IOpnsenseSwitchInfo[];
actions?: IOpnsenseActionDescriptor[];
manualEntries?: IOpnsenseManualEntry[];
events?: IOpnsenseEvent[];
snapshotProvider?: TOpnsenseSnapshotProvider;
commandExecutor?: TOpnsenseCommandExecutor;
nativeClient?: IOpnsenseNativeClient;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IHomeAssistantOpnsenseConfig extends IOpnsenseConfig {}
export interface IOpnsenseRouterInfo {
id?: string;
url?: string;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
name?: string;
model?: string;
firmware?: string;
productVersion?: string;
latestFirmware?: string;
updateAvailable?: boolean;
serialNumber?: string;
macAddress?: string;
configurationUrl?: string;
manufacturer?: string;
actions?: TOpnsenseRouterAction[];
metadata?: Record<string, unknown>;
}
export interface IOpnsenseClientDevice {
id?: string;
mac?: string;
macAddress?: string;
name?: string;
hostname?: string | null;
ip?: string;
ipAddress?: string;
address?: string;
connected?: boolean;
interface?: string;
interfaceDescription?: string;
intf_description?: string;
manufacturer?: string;
model?: string;
lastActivity?: string | number | Date;
expires?: string | number | Date;
leaseType?: string;
type?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseArpEntry {
mac?: string;
macAddress?: string;
'mac-address'?: string;
ip?: string;
ipAddress?: string;
'ip-address'?: string;
address?: string;
hostname?: string;
interface?: string;
intf_description?: string;
interfaceDescription?: string;
manufacturer?: string;
expires?: string | number;
type?: string;
[key: string]: unknown;
}
export interface IOpnsenseInterfaceInfo {
id?: string;
name: string;
label?: string;
description?: string;
status?: string;
enabled?: boolean | string | number;
connected?: boolean;
mac?: string;
macAddress?: string;
ipv4?: string | null;
ipv6?: string | null;
media?: string | null;
device?: string | null;
gateways?: string[];
routes?: unknown[];
vlanTag?: string | number | null;
rxBytes?: number;
txBytes?: number;
inbytes?: number;
outbytes?: number;
rxPackets?: number;
txPackets?: number;
inpkts?: number;
outpkts?: number;
inputErrors?: number;
outputErrors?: number;
inerrs?: number;
outerrs?: number;
collisions?: number;
actions?: TOpnsenseInterfaceAction[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseGatewayInfo {
id?: string;
name: string;
status?: string;
address?: string;
interface?: string;
monitor?: string;
delay?: number | string;
rtt?: number | string;
latency?: number | string;
loss?: number | string;
stddev?: number | string;
disabled?: boolean | string | number;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseFirewallRule {
id?: string;
uuid?: string;
description?: string;
descr?: string;
enabled?: boolean | string | number;
disabled?: boolean | string | number;
interface?: string;
direction?: string;
protocol?: string;
action?: string;
source?: unknown;
destination?: unknown;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseFirewallAlias {
id?: string;
uuid?: string;
name: string;
description?: string;
enabled?: boolean | string | number;
disabled?: boolean | string | number;
type?: string;
content?: unknown;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseFirewallSnapshot {
rules?: IOpnsenseFirewallRule[] | Record<string, IOpnsenseFirewallRule>;
nat?: Record<string, IOpnsenseFirewallRule[] | Record<string, IOpnsenseFirewallRule> | undefined>;
aliases?: IOpnsenseFirewallAlias[] | Record<string, IOpnsenseFirewallAlias>;
state?: {
used?: number;
total?: number;
usedPercent?: number;
used_percent?: number;
[key: string]: unknown;
};
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseSystemInfo {
name?: string;
hostname?: string;
firmwareVersion?: string;
productVersion?: string;
productLatest?: string;
updateAvailable?: boolean;
uptime?: number;
boottime?: string | number | Date;
loadAverage?: {
oneMinute?: number;
fiveMinute?: number;
fifteenMinute?: number;
one_minute?: number;
five_minute?: number;
fifteen_minute?: number;
};
pendingNoticesPresent?: boolean;
pending_notices_present?: boolean;
pendingNotices?: IOpnsenseNotice[];
pending_notices?: IOpnsenseNotice[];
carp?: Record<string, unknown>;
certificates?: Record<string, unknown>;
speedtest?: Record<string, unknown>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseNotice {
id?: string;
notice?: string;
createdAt?: string | number | Date;
created_at?: string | number | Date;
[key: string]: unknown;
}
export interface IOpnsenseTelemetryInfo {
cpu?: Record<string, unknown>;
memory?: Record<string, unknown>;
mbuf?: Record<string, unknown>;
pfstate?: Record<string, unknown>;
system?: Record<string, unknown>;
filesystems?: Array<Record<string, unknown>>;
temps?: Record<string, Record<string, unknown>>;
vnstat?: Record<string, unknown>;
speedtest?: Record<string, unknown>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseServiceInfo {
id?: string;
name: string;
displayName?: string;
description?: string;
running?: boolean | string | number;
status?: boolean | string | number;
enabled?: boolean | string | number;
locked?: boolean | string | number;
actions?: TOpnsenseServiceAction[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseVpnSnapshot {
openvpn?: {
servers?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
clients?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
[key: string]: unknown;
};
wireguard?: {
servers?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
clients?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
[key: string]: unknown;
};
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseVpnInstance {
id?: string;
uuid?: string;
name?: string;
description?: string;
type?: 'openvpn' | 'wireguard' | string;
role?: 'server' | 'client' | string;
enabled?: boolean | string | number;
status?: string;
connected?: boolean;
interface?: string;
endpoint?: string;
connectedClients?: number;
connected_clients?: number;
connectedServers?: number;
connected_servers?: number;
totalBytesRecv?: number;
total_bytes_recv?: number;
totalBytesSent?: number;
total_bytes_sent?: number;
latestHandshake?: string | number | Date;
latest_handshake?: string | number | Date;
clients?: Array<Record<string, unknown>>;
servers?: Array<Record<string, unknown>>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseSensorMap {
connected_clients?: number;
pending_notices?: boolean;
cpu_usage?: number;
memory_usage?: number;
pf_state_used?: number;
pf_state_used_percent?: number;
mbuf_used_percent?: number;
uptime?: number;
[key: string]: string | number | boolean | Date | null | undefined;
}
export interface IOpnsenseSwitchInfo {
id?: string;
name: string;
enabled?: boolean | string | number;
available?: boolean;
nativeType?: string;
action?: string;
uuid?: string;
service?: string;
path?: string;
method?: TOpnsenseHttpMethod;
payload?: Record<string, unknown>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpnsenseActionDescriptor {
target: 'router' | 'interface' | 'service' | 'firewall_rule' | 'nat_rule' | 'firewall_alias' | 'vpn' | 'switch' | 'firmware' | 'notice' | 'wol' | 'unbound' | 'speedtest';
action: string;
id?: string | number;
uuid?: string;
service?: string;
interface?: string;
mac?: string;
vpnType?: 'openvpn' | 'wireguard' | string;
vpnRole?: 'server' | 'client' | string;
natRuleType?: string;
entityId?: string;
deviceId?: string;
path?: string;
method?: TOpnsenseHttpMethod;
payload?: Record<string, unknown>;
label?: string;
metadata?: Record<string, unknown>;
}
export interface IOpnsenseSnapshot {
connected: boolean;
source?: TOpnsenseSnapshotSource;
updatedAt?: string;
router: IOpnsenseRouterInfo;
devices: IOpnsenseClientDevice[];
interfaces: IOpnsenseInterfaceInfo[];
gateways: IOpnsenseGatewayInfo[];
firewall: IOpnsenseFirewallSnapshot;
system: IOpnsenseSystemInfo;
telemetry: IOpnsenseTelemetryInfo;
services: IOpnsenseServiceInfo[];
vpn: IOpnsenseVpnSnapshot;
sensors: IOpnsenseSensorMap;
switches: IOpnsenseSwitchInfo[];
actions?: IOpnsenseActionDescriptor[];
events?: IOpnsenseEvent[];
error?: string;
metadata?: Record<string, unknown>;
}
export interface IOpnsenseManualEntry {
id?: string;
url?: string;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
apiKey?: string;
apiSecret?: string;
trackerInterfaces?: string[];
name?: string;
manufacturer?: string;
model?: string;
macAddress?: string;
snapshot?: IOpnsenseSnapshot;
router?: IOpnsenseRouterInfo;
devices?: IOpnsenseClientDevice[];
clients?: IOpnsenseClientDevice[];
arpTable?: IOpnsenseArpEntry[];
interfaces?: IOpnsenseInterfaceInfo[];
gateways?: IOpnsenseGatewayInfo[];
firewall?: IOpnsenseFirewallSnapshot;
system?: IOpnsenseSystemInfo;
telemetry?: IOpnsenseTelemetryInfo;
services?: IOpnsenseServiceInfo[];
vpn?: IOpnsenseVpnSnapshot;
vpns?: IOpnsenseVpnSnapshot;
sensors?: IOpnsenseSensorMap;
switches?: IOpnsenseSwitchInfo[];
actions?: IOpnsenseActionDescriptor[];
metadata?: Record<string, unknown>;
integrationDomain?: string;
[key: string]: unknown;
}
export interface IOpnsenseManualDiscoveryRecord extends IOpnsenseManualEntry {}
export interface IOpnsenseCommand {
type: TOpnsenseCommandType;
service: string;
action: string;
target: {
entityId?: string;
deviceId?: string;
};
method?: TOpnsenseHttpMethod;
path?: string;
payload?: Record<string, unknown>;
routerId?: string;
entityId?: string;
deviceId?: string;
uuid?: string;
serviceName?: string;
interface?: string;
mac?: string;
vpnType?: string;
vpnRole?: string;
natRuleType?: string;
}
export interface IOpnsenseCommandResult extends IServiceCallResult {}
export interface IOpnsenseEvent {
type: string;
timestamp?: number;
deviceId?: string;
entityId?: string;
command?: IOpnsenseCommand;
snapshot?: IOpnsenseSnapshot;
error?: string;
data?: unknown;
[key: string]: unknown;
}
export interface IOpnsenseNativeClient {
getSnapshot(): Promise<IOpnsenseSnapshot> | IOpnsenseSnapshot;
executeCommand?(commandArg: IOpnsenseCommand): Promise<IOpnsenseCommandResult | unknown> | IOpnsenseCommandResult | unknown;
destroy?(): Promise<void> | void;
}
export type TOpnsenseSnapshotProvider = () => Promise<IOpnsenseSnapshot | undefined> | IOpnsenseSnapshot | undefined;
export type TOpnsenseCommandExecutor = (
commandArg: IOpnsenseCommand
) => Promise<IOpnsenseCommandResult | unknown> | IOpnsenseCommandResult | unknown;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './pi_hole.classes.client.js';
export * from './pi_hole.classes.configflow.js';
export * from './pi_hole.classes.integration.js';
export * from './pi_hole.discovery.js';
export * from './pi_hole.mapper.js';
export * from './pi_hole.types.js';
@@ -0,0 +1,388 @@
import { PiHoleMapper } from './pi_hole.mapper.js';
import type {
IPiHoleClientCommand,
IPiHoleCommandResult,
IPiHoleConfig,
IPiHoleRawData,
IPiHoleSnapshot,
IPiHoleV5Summary,
IPiHoleV5Versions,
IPiHoleV6BlockingStatus,
IPiHoleV6InfoVersionResponse,
IPiHoleV6Summary,
TPiHoleApiVersion,
} from './pi_hole.types.js';
import { piHoleDefaultLocation, piHoleDefaultPort, piHoleDefaultTimeoutMs } from './pi_hole.types.js';
export class PiHoleApiError extends Error {}
export class PiHoleConnectionError extends PiHoleApiError {}
export class PiHoleAuthorizationError extends PiHoleApiError {}
export class PiHoleClient {
private currentSnapshot?: IPiHoleSnapshot;
private sessionId?: string;
private csrfToken?: string;
private sessionValidUntil?: number;
constructor(private readonly config: IPiHoleConfig) {}
public async getSnapshot(): Promise<IPiHoleSnapshot> {
if (this.hasManualData()) {
this.currentSnapshot = PiHoleMapper.toSnapshot({
config: this.config,
source: this.config.snapshot ? 'snapshot' : 'manual',
online: this.config.snapshot?.online ?? this.config.online ?? true,
});
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 Pi-hole HTTP endpoint or snapshot/manual data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<{ success: boolean; snapshot: IPiHoleSnapshot; error?: string; data?: Record<string, unknown> }> {
if (this.hasManualData()) {
const snapshot = await this.getSnapshot();
return { success: true, snapshot, data: { source: snapshot.source, apiVersion: snapshot.apiVersion } };
}
if (!this.config.host) {
const snapshot = await this.getSnapshot();
return {
success: false,
snapshot,
error: 'Pi-hole refresh requires a configured HTTP endpoint or snapshot/manual data.',
};
}
try {
const snapshot = await this.fetchSnapshot();
this.currentSnapshot = snapshot;
return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'http', apiVersion: snapshot.apiVersion } };
} 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 ping(): Promise<boolean> {
if (this.hasManualData()) {
return true;
}
if (!this.config.host) {
return false;
}
return (await this.refresh()).success;
}
public async sendCommand(commandArg: IPiHoleClientCommand): Promise<IPiHoleCommandResult> {
if (this.config.commandExecutor) {
return this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
}
if (commandArg.type === 'refresh') {
const result = await this.refresh();
return { success: result.success, error: result.error, data: result.snapshot };
}
if (!this.config.host) {
return {
success: false,
error: 'Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.',
data: { command: commandArg },
};
}
const apiVersion = commandArg.apiVersion || this.currentSnapshot?.apiVersion || this.config.apiVersion || await this.detectApiVersion();
if (commandArg.type === 'enable' || commandArg.type === 'disable') {
const response = apiVersion === 6
? await this.setV6Blocking(commandArg.type === 'enable', commandArg.durationSeconds)
: await this.setV5Blocking(commandArg.type === 'enable', commandArg.durationSeconds);
return { success: true, data: { command: { ...commandArg, apiVersion }, response } };
}
return { success: false, error: `Unsupported Pi-hole command: ${commandArg.type}`, data: { command: commandArg } };
}
public async destroy(): Promise<void> {
if (this.sessionId) {
await this.logoutV6().catch(() => undefined);
}
}
public async fetchSnapshot(): Promise<IPiHoleSnapshot> {
if (this.config.apiVersion === 5) {
return this.fetchV5Snapshot();
}
if (this.config.apiVersion === 6) {
return this.fetchV6Snapshot();
}
try {
return await this.fetchV6Snapshot();
} catch (errorArg) {
if (errorArg instanceof PiHoleAuthorizationError) {
throw errorArg;
}
return this.fetchV5Snapshot();
}
}
private async fetchV5Snapshot(): Promise<IPiHoleSnapshot> {
const [summary, versions] = await Promise.all([
this.requestV5<IPiHoleV5Summary | unknown[]>('summaryRaw'),
this.requestV5<IPiHoleV5Versions>('versions'),
]);
if (!summary || Array.isArray(summary) || typeof summary !== 'object') {
throw new PiHoleAuthorizationError('Pi-hole v5 returned an unauthenticated or invalid summary response.');
}
if ('error' in summary) {
throw new PiHoleApiError(`Pi-hole v5 summary returned an error: ${JSON.stringify(summary.error)}`);
}
return PiHoleMapper.toSnapshot({
config: this.config,
rawData: { v5Summary: summary as IPiHoleV5Summary, v5Versions: versions },
online: true,
source: 'http',
apiVersion: 5,
});
}
private async fetchV6Snapshot(): Promise<IPiHoleSnapshot> {
await this.ensureV6Auth();
const [summary, blocking, versions] = await Promise.all([
this.requestV6<IPiHoleV6Summary>('/api/stats/summary'),
this.requestV6<IPiHoleV6BlockingStatus>('/api/dns/blocking'),
this.requestV6<IPiHoleV6InfoVersionResponse>('/api/info/version'),
]);
return PiHoleMapper.toSnapshot({
config: this.config,
rawData: { v6Summary: summary, v6Blocking: blocking, v6Versions: versions },
online: true,
source: 'http',
apiVersion: 6,
});
}
private async detectApiVersion(): Promise<TPiHoleApiVersion> {
if (this.config.apiVersion) {
return this.config.apiVersion;
}
if (this.currentSnapshot?.apiVersion) {
return this.currentSnapshot.apiVersion;
}
const snapshot = await this.fetchSnapshot();
this.currentSnapshot = snapshot;
return snapshot.apiVersion || 6;
}
private async setV5Blocking(enabledArg: boolean, durationSecondsArg?: number): Promise<unknown> {
const apiKey = this.apiKey();
if (!apiKey) {
throw new PiHoleAuthorizationError('Pi-hole v5 enable/disable requires apiKey.');
}
const query = enabledArg ? 'enable=True' : `disable=${durationSecondsArg ?? true}`;
const response = await this.requestV5<unknown>(query, false);
this.currentSnapshot = await this.fetchV5Snapshot().catch(() => this.currentSnapshot);
return response;
}
private async setV6Blocking(enabledArg: boolean, durationSecondsArg?: number): Promise<unknown> {
await this.ensureV6Auth();
const response = await this.requestV6<unknown>('/api/dns/blocking', {
method: 'POST',
body: JSON.stringify({ blocking: enabledArg, timer: enabledArg ? null : durationSecondsArg ?? null }),
headers: { 'content-type': 'application/json' },
});
this.currentSnapshot = await this.fetchV6Snapshot().catch(() => this.currentSnapshot);
return response;
}
private async requestV5<TResponse>(queryArg: string, appendAuthArg = true): Promise<TResponse> {
const auth = this.apiKey();
const query = appendAuthArg || auth ? `${queryArg}&auth=${encodeURIComponent(auth || '')}` : queryArg;
const url = new URL(this.v5ApiUrl());
url.search = query;
return this.requestJson<TResponse>(String(url), { method: 'GET' });
}
private async requestV6<TResponse>(pathArg: string, initArg: RequestInit = {}, retryArg = true): Promise<TResponse> {
const headers: Record<string, string> = {
...(initArg.headers as Record<string, string> | undefined),
};
if (this.sessionId) {
headers['X-FTL-SID'] = this.sessionId;
if (this.csrfToken) {
headers['X-FTL-CSRF'] = this.csrfToken;
}
}
try {
return await this.requestJson<TResponse>(`${this.baseUrl()}${pathArg}`, { ...initArg, headers });
} catch (errorArg) {
if (retryArg && errorArg instanceof PiHoleAuthorizationError && this.sessionId) {
this.sessionId = undefined;
this.csrfToken = undefined;
this.sessionValidUntil = undefined;
await this.ensureV6Auth();
return this.requestV6<TResponse>(pathArg, initArg, false);
}
throw errorArg;
}
}
private async ensureV6Auth(): Promise<void> {
const password = this.apiKey();
if (!password) {
return;
}
if (this.sessionId && this.sessionValidUntil && Date.now() < this.sessionValidUntil) {
return;
}
await this.authenticateV6(password);
}
private async authenticateV6(passwordArg: string): Promise<void> {
const response = await this.requestJson<{ session?: { valid?: boolean; sid?: string; csrf?: string; validity?: number } }>(`${this.baseUrl()}/api/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: passwordArg }),
});
const session = response.session;
if (!session?.valid || !session.sid) {
throw new PiHoleAuthorizationError('Pi-hole v6 authentication did not return a valid session.');
}
this.sessionId = session.sid;
this.csrfToken = session.csrf;
this.sessionValidUntil = Date.now() + Math.max((session.validity || 300) - 5, 1) * 1000;
}
private async logoutV6(): Promise<void> {
if (!this.sessionId) {
return;
}
await this.requestJson<unknown>(`${this.baseUrl()}/api/auth`, {
method: 'DELETE',
headers: { 'X-FTL-SID': this.sessionId },
}).catch(() => undefined);
this.sessionId = undefined;
this.csrfToken = undefined;
this.sessionValidUntil = undefined;
}
private async requestJson<TResponse>(urlArg: string, initArg: RequestInit): Promise<TResponse> {
let response: Response;
try {
response = await globalThis.fetch(urlArg, {
...initArg,
signal: AbortSignal.timeout(this.config.timeoutMs || piHoleDefaultTimeoutMs),
});
} catch (errorArg) {
throw new PiHoleConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
}
const text = await response.text();
if (response.status === 401) {
throw new PiHoleAuthorizationError('Pi-hole authentication failed.');
}
if (!response.ok) {
throw new PiHoleApiError(`Pi-hole request ${urlArg} failed with HTTP ${response.status}: ${text}`);
}
if (!text) {
return {} as TResponse;
}
try {
return JSON.parse(text) as TResponse;
} catch (errorArg) {
throw new PiHoleConnectionError(`Unable to parse Pi-hole response from ${urlArg}: ${this.errorMessage(errorArg)}`);
}
}
private offlineSnapshot(errorArg: string): IPiHoleSnapshot {
return PiHoleMapper.toSnapshot({
config: this.config,
online: false,
source: 'runtime',
error: errorArg,
});
}
private hasManualData(): boolean {
return Boolean(
this.config.snapshot
|| this.config.rawData
|| this.config.v5Summary
|| this.config.v5Versions
|| this.config.v6Summary
|| this.config.v6Blocking
|| this.config.v6Versions
|| this.config.status !== undefined
|| this.config.statistics
|| this.config.versions
);
}
private v5ApiUrl(): string {
return `${this.baseUrl()}/${this.location()}/api.php`;
}
private baseUrl(): string {
if (!this.config.host) {
throw new PiHoleConnectionError('Pi-hole host is required for HTTP API access.');
}
const protocol = this.config.ssl ? 'https' : 'http';
const port = this.config.port || (this.config.ssl ? 443 : piHoleDefaultPort);
const defaultPort = protocol === 'https' ? 443 : 80;
return `${protocol}://${this.hostForUrl(this.config.host)}${port === defaultPort ? '' : `:${port}`}`;
}
private hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private location(): string {
const location = this.config.location || piHoleDefaultLocation;
return location.trim().replace(/^\/+|\/+$/g, '') || piHoleDefaultLocation;
}
private apiKey(): string | undefined {
return this.stringValue(this.config.apiKey) || this.stringValue(this.config.password);
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
}
private commandResult(resultArg: unknown, commandArg: IPiHoleClientCommand): IPiHoleCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IPiHoleCommandResult {
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IPiHoleSnapshot): IPiHoleSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IPiHoleSnapshot;
}
}
@@ -0,0 +1,150 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IPiHoleConfig, IPiHoleSnapshot, TPiHoleApiVersion } from './pi_hole.types.js';
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDefaultTimeoutMs } from './pi_hole.types.js';
export class PiHoleConfigFlow implements IConfigFlow<IPiHoleConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IPiHoleConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Pi-hole',
description: 'Configure a local Pi-hole HTTP API endpoint. Runtime writes are only reported successful after a real HTTP call or an explicit command executor.',
fields: [
{ name: 'host', label: 'Host or URL', type: 'text', required: true },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'location', label: 'Admin location', type: 'text' },
{ name: 'apiKey', label: 'App password or API key', type: 'password', required: true },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'apiVersion', label: 'API version', type: 'select', options: [
{ label: 'Auto (v6 then v5)', value: 'auto' },
{ label: 'v6', value: '6' },
{ label: 'v5', value: '5' },
] },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IPiHoleConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseHostInput(this.stringValue(valuesArg.host) || candidateArg.host || this.stringValue(metadata.url) || '');
const host = parsed.host || candidateArg.host;
const snapshot = this.snapshotValue(metadata.snapshot);
const rawData = this.recordValue(metadata.rawData);
const port = this.numberValue(valuesArg.port) || parsed.port || candidateArg.port || piHoleDefaultPort;
const ssl = this.booleanValue(valuesArg.ssl) ?? parsed.ssl ?? this.booleanValue(metadata.ssl) ?? false;
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? true;
const location = this.stringValue(valuesArg.location) || parsed.location || this.stringValue(metadata.location) || piHoleDefaultLocation;
const apiKey = this.stringValue(valuesArg.apiKey) || this.stringValue(metadata.apiKey) || this.stringValue(metadata.password);
const apiVersion = this.apiVersionValue(valuesArg.apiVersion) || this.apiVersionValue(metadata.apiVersion);
if (!host && !snapshot && !rawData) {
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole setup requires a host, URL, or snapshot/manual data.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole port must be an integer between 1 and 65535.' };
}
if (host && !apiKey) {
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole App password or API key is required for HTTP API access.' };
}
return {
kind: 'done',
title: 'Pi-hole configured',
config: {
host,
port,
ssl,
verifySsl,
location,
apiKey,
apiVersion,
name: this.stringValue(valuesArg.name) || candidateArg.name || this.stringValue(metadata.name) || piHoleDefaultName,
uniqueId: candidateArg.id || (host ? `${host}:${port}` : undefined),
timeoutMs: piHoleDefaultTimeoutMs,
snapshot,
rawData,
},
};
}
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 booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (valueArg.toLowerCase() === 'true') return true;
if (valueArg.toLowerCase() === 'false') return false;
}
return undefined;
}
private apiVersionValue(valueArg: unknown): TPiHoleApiVersion | undefined {
const value = typeof valueArg === 'number' ? String(valueArg) : this.stringValue(valueArg);
if (value === '5' || value === '6') {
return Number(value) as TPiHoleApiVersion;
}
return undefined;
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
private snapshotValue(valueArg: unknown): IPiHoleSnapshot | undefined {
const record = this.recordValue(valueArg);
return record && 'statistics' in record && 'status' in record ? record as unknown as IPiHoleSnapshot : undefined;
}
private recordValue(valueArg: unknown): Record<string, unknown> | undefined {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
}
const parseHostInput = (valueArg: string): { host?: string; port?: number; ssl?: boolean; location?: string } => {
const value = valueArg.trim();
if (!value) {
return {};
}
if (!value.includes('://')) {
const hostPort = value.match(/^([^/:]+):(\d+)$/);
return hostPort ? { host: hostPort[1], port: Number(hostPort[2]) } : { host: value };
}
try {
const parsed = new URL(value);
const location = locationFromPath(parsed.pathname);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
ssl: parsed.protocol === 'https:',
location,
};
} catch {
return {};
}
};
const locationFromPath = (pathArg: string): string | undefined => {
const parts = pathArg.split('/').filter(Boolean);
if (!parts.length || parts[0] === 'api') {
return undefined;
}
return parts[0];
};
@@ -1,26 +1,101 @@
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 { PiHoleClient } from './pi_hole.classes.client.js';
import { PiHoleConfigFlow } from './pi_hole.classes.configflow.js';
import { createPiHoleDiscoveryDescriptor } from './pi_hole.discovery.js';
import { PiHoleMapper } from './pi_hole.mapper.js';
import type { IPiHoleConfig } from './pi_hole.types.js';
import { piHoleDomain } from './pi_hole.types.js';
export class HomeAssistantPiHoleIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "pi_hole",
displayName: "Pi-hole",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/pi_hole",
"upstreamDomain": "pi_hole",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"hole==0.9.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@shenxn"
]
},
});
export class PiHoleIntegration extends BaseIntegration<IPiHoleConfig> {
public readonly domain = piHoleDomain;
public readonly displayName = 'Pi-hole';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createPiHoleDiscoveryDescriptor();
public readonly configFlow = new PiHoleConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/pi_hole',
upstreamDomain: piHoleDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['hole==0.9.0'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@shenxn'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/pi_hole',
protocolSource: 'Pi-hole HTTP APIs: v5 /admin/api.php summaryRaw/versions/enable/disable and v6 /api/auth, /api/stats/summary, /api/dns/blocking, /api/info/version.',
runtime: {
type: 'control-runtime',
polling: 'local Pi-hole HTTP API',
services: ['snapshot', 'status', 'refresh', 'enable', 'disable'],
controls: ['dns_blocking'],
liveCommandSuccessRequiresClientOrExecutor: true,
},
localApi: {
implemented: [
'Pi-hole API v6 authenticated summary, blocking status, version, enable, and disable endpoints',
'Pi-hole API v5 summaryRaw, versions, enable, and disable endpoints',
'manual raw API data and normalized snapshot inputs',
'status, statistics, update, and DNS-blocking switch entity mappings',
],
explicitUnsupported: [
'Home Assistant Python hole compatibility wrapper',
'fake live enable/disable success without a configured HTTP endpoint or command executor',
],
},
};
public async setup(configArg: IPiHoleConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new PiHoleRuntime(new PiHoleClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantPiHoleIntegration extends PiHoleIntegration {}
class PiHoleRuntime implements IIntegrationRuntime {
public domain = piHoleDomain;
constructor(private readonly client: PiHoleClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return PiHoleMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return PiHoleMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === piHoleDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === piHoleDomain && requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot };
}
const snapshot = await this.client.getSnapshot();
const command = PiHoleMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Pi-hole service mapping: ${requestArg.domain}.${requestArg.service}` };
}
if ('error' in command) {
return { success: false, error: command.error };
}
return this.client.sendCommand(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,193 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IPiHoleHttpCandidateRecord, IPiHoleManualEntry, IPiHoleSnapshot } from './pi_hole.types.js';
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDomain } from './pi_hole.types.js';
export class PiHoleManualMatcher implements IDiscoveryMatcher<IPiHoleManualEntry> {
public id = 'pi-hole-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Pi-hole local HTTP and snapshot setup entries.';
public async matches(inputArg: IPiHoleManualEntry): Promise<IDiscoveryMatch> {
const parsedUrl = parseUrl(inputArg.url);
const metadata = inputArg.metadata || {};
const hasManualData = Boolean(inputArg.snapshot || inputArg.rawData || metadata.snapshot || metadata.rawData || inputArg.statistics || inputArg.status || inputArg.versions);
const matched = isPiHoleHint(inputArg) || Boolean(inputArg.host || parsedUrl || hasManualData);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Pi-hole setup hints.' };
}
const host = inputArg.host || parsedUrl?.host;
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanMetadata(metadata.ssl) ?? false;
const port = inputArg.port || parsedUrl?.port || piHoleDefaultPort;
const id = inputArg.id || snapshotId(inputArg.snapshot || metadata.snapshot) || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start Pi-hole setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: piHoleDomain,
id,
host,
port,
name: inputArg.name || piHoleDefaultName,
manufacturer: 'Pi-hole',
model: inputArg.model || 'Pi-hole',
metadata: {
...metadata,
piHole: true,
ssl,
verifySsl: inputArg.verifySsl ?? metadata.verifySsl,
location: inputArg.location || parsedUrl?.location || metadata.location || piHoleDefaultLocation,
apiKey: inputArg.apiKey ?? metadata.apiKey,
password: inputArg.password ?? metadata.password,
apiVersion: inputArg.apiVersion || metadata.apiVersion || parsedUrl?.apiVersion,
url: inputArg.url,
snapshot: inputArg.snapshot || metadata.snapshot,
rawData: inputArg.rawData || metadata.rawData,
hasManualData,
},
},
};
}
}
export class PiHoleHttpMatcher implements IDiscoveryMatcher<IPiHoleHttpCandidateRecord> {
public id = 'pi-hole-http-match';
public source = 'http' as const;
public description = 'Recognize local HTTP candidates that point at a Pi-hole API.';
public async matches(recordArg: IPiHoleHttpCandidateRecord): Promise<IDiscoveryMatch> {
const url = recordArg.url || recordArg.location;
const parsedUrl = parseUrl(url);
const headers = normalizeKeys(recordArg.headers || {});
const metadata = recordArg.metadata || {};
const text = [url, recordArg.name, recordArg.manufacturer, recordArg.model, headers.server, headers['x-powered-by']].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(parsedUrl?.apiVersion || parsedUrl?.location === piHoleDefaultLocation || metadata.piHole || metadata.pi_hole || metadata.pihole || text.includes('pi-hole') || text.includes('pihole'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Pi-hole API.' };
}
const host = recordArg.host || parsedUrl?.host;
const ssl = recordArg.ssl ?? parsedUrl?.ssl ?? false;
const port = recordArg.port || parsedUrl?.port || piHoleDefaultPort;
const id = host ? `${host}:${port}` : undefined;
return {
matched: true,
confidence: parsedUrl?.apiVersion && host ? 'high' : host ? 'medium' : 'low',
reason: 'HTTP candidate has Pi-hole API hints.',
normalizedDeviceId: id,
candidate: {
source: 'http',
integrationDomain: piHoleDomain,
id,
host,
port,
name: recordArg.name || piHoleDefaultName,
manufacturer: recordArg.manufacturer || 'Pi-hole',
model: recordArg.model || 'Pi-hole',
metadata: {
...metadata,
piHole: true,
ssl,
url,
location: parsedUrl?.location || metadata.location || piHoleDefaultLocation,
apiVersion: parsedUrl?.apiVersion || metadata.apiVersion,
headers,
},
},
};
}
}
export class PiHoleCandidateValidator implements IDiscoveryValidator {
public id = 'pi-hole-candidate-validator';
public description = 'Validate Pi-hole candidates have a usable HTTP endpoint or snapshot/manual data.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const matched = candidateArg.integrationDomain === piHoleDomain || isPiHoleHint(candidateArg);
const hasManualData = Boolean(metadata.snapshot || metadata.rawData || metadata.statistics || metadata.status || metadata.versions);
const port = candidateArg.port || piHoleDefaultPort;
const hasUsableAddress = Boolean(candidateArg.host && isValidPort(port));
if (!matched || (!hasUsableAddress && !hasManualData)) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Pi-hole candidate lacks a usable host or snapshot/manual data.' : 'Candidate is not Pi-hole.',
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined),
};
}
const normalizedDeviceId = candidateArg.id || snapshotId(metadata.snapshot) || (candidateArg.host ? `${candidateArg.host}:${port}` : undefined);
return {
matched: true,
confidence: normalizedDeviceId && hasUsableAddress ? 'certain' : hasUsableAddress ? 'high' : 'medium',
reason: 'Candidate has Pi-hole metadata and a usable HTTP endpoint or snapshot/manual data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: piHoleDomain,
port,
manufacturer: candidateArg.manufacturer || 'Pi-hole',
model: candidateArg.model || 'Pi-hole',
},
};
}
}
export const createPiHoleDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: piHoleDomain, displayName: 'Pi-hole' })
.addMatcher(new PiHoleManualMatcher())
.addMatcher(new PiHoleHttpMatcher())
.addValidator(new PiHoleCandidateValidator());
};
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; location?: string; apiVersion?: 5 | 6 } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
const path = url.pathname.toLowerCase();
const apiVersion = path.includes('/api.php') ? 5 : path.startsWith('/api/') || path === '/api' ? 6 : undefined;
const parts = url.pathname.split('/').filter(Boolean);
return {
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
ssl: url.protocol === 'https:',
location: parts[0] && parts[0] !== 'api' ? parts[0] : undefined,
apiVersion,
};
} catch {
return undefined;
}
};
const isPiHoleHint = (valueArg: { integrationDomain?: string; manufacturer?: string; model?: string; name?: string; metadata?: Record<string, unknown> }): boolean => {
const text = [valueArg.integrationDomain, valueArg.manufacturer, valueArg.model, valueArg.name].filter(Boolean).join(' ').toLowerCase();
return valueArg.integrationDomain === piHoleDomain
|| text.includes('pi-hole')
|| text.includes('pihole')
|| Boolean(valueArg.metadata?.piHole || valueArg.metadata?.pi_hole || valueArg.metadata?.pihole);
};
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 isValidPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
const booleanMetadata = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined;
const isPiHoleSnapshot = (valueArg: unknown): valueArg is IPiHoleSnapshot => Boolean(valueArg && typeof valueArg === 'object' && 'statistics' in valueArg && 'status' in valueArg);
const snapshotId = (valueArg: unknown): string | undefined => {
const snapshot = isPiHoleSnapshot(valueArg) ? valueArg : undefined;
return snapshot?.uniqueId || snapshot?.host;
};
+512
View File
@@ -0,0 +1,512 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
import type {
IPiHoleClientCommand,
IPiHoleConfig,
IPiHoleRawData,
IPiHoleSnapshot,
IPiHoleStatistics,
IPiHoleVersions,
TPiHoleApiVersion,
TPiHoleBlockingStatus,
TPiHoleSnapshotSource,
} from './pi_hole.types.js';
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDomain } from './pi_hole.types.js';
interface IPiHoleSnapshotOptions {
config: IPiHoleConfig;
rawData?: IPiHoleRawData;
online?: boolean;
source?: TPiHoleSnapshotSource;
apiVersion?: TPiHoleApiVersion;
error?: string;
}
interface IPiHoleStatDescription {
key: keyof IPiHoleStatistics;
entityKey: string;
name: string;
unit?: string;
precision?: number;
}
const releaseBaseUrls = {
core: 'https://github.com/pi-hole/pi-hole/releases/tag',
web: 'https://github.com/pi-hole/AdminLTE/releases/tag',
ftl: 'https://github.com/pi-hole/FTL/releases/tag',
};
const v5StatisticDescriptions: IPiHoleStatDescription[] = [
{ key: 'adsBlocked', entityKey: 'ads_blocked_today', name: 'Ads blocked today', unit: 'ads', precision: 0 },
{ key: 'adsPercentage', entityKey: 'ads_percentage_today', name: 'Ads percentage blocked today', unit: '%', precision: 1 },
{ key: 'clientsSeen', entityKey: 'clients_ever_seen', name: 'Seen clients', unit: 'clients', precision: 0 },
{ key: 'dnsQueries', entityKey: 'dns_queries_today', name: 'DNS queries today', unit: 'queries', precision: 0 },
{ key: 'domainsBlocked', entityKey: 'domains_being_blocked', name: 'Domains blocked', unit: 'domains', precision: 0 },
{ key: 'queriesCached', entityKey: 'queries_cached', name: 'DNS queries cached', unit: 'queries', precision: 0 },
{ key: 'queriesForwarded', entityKey: 'queries_forwarded', name: 'DNS queries forwarded', unit: 'queries', precision: 0 },
{ key: 'uniqueClients', entityKey: 'unique_clients', name: 'DNS unique clients', unit: 'clients', precision: 0 },
{ key: 'uniqueDomains', entityKey: 'unique_domains', name: 'DNS unique domains', unit: 'domains', precision: 0 },
];
const v6StatisticDescriptions: IPiHoleStatDescription[] = [
{ key: 'adsBlocked', entityKey: 'ads_blocked', name: 'Ads blocked', unit: 'ads', precision: 0 },
{ key: 'adsPercentage', entityKey: 'percent_ads_blocked', name: 'Ads percentage blocked', unit: '%', precision: 2 },
{ key: 'clientsSeen', entityKey: 'clients_ever_seen', name: 'Seen clients', unit: 'clients', precision: 0 },
{ key: 'dnsQueries', entityKey: 'dns_queries', name: 'DNS queries', unit: 'queries', precision: 0 },
{ key: 'domainsBlocked', entityKey: 'domains_being_blocked', name: 'Domains blocked', unit: 'domains', precision: 0 },
{ key: 'queriesCached', entityKey: 'queries_cached', name: 'DNS queries cached', unit: 'queries', precision: 0 },
{ key: 'queriesForwarded', entityKey: 'queries_forwarded', name: 'DNS queries forwarded', unit: 'queries', precision: 0 },
{ key: 'uniqueClients', entityKey: 'unique_clients', name: 'DNS unique clients', unit: 'clients', precision: 0 },
{ key: 'uniqueDomains', entityKey: 'unique_domains', name: 'DNS unique domains', unit: 'domains', precision: 0 },
];
const updateDescriptions: Array<{ key: keyof IPiHoleVersions; name: string; title: string }> = [
{ key: 'core', name: 'Core update available', title: 'Pi-hole Core' },
{ key: 'web', name: 'Web update available', title: 'Pi-hole Web interface' },
{ key: 'ftl', name: 'FTL update available', title: 'Pi-hole FTL DNS' },
];
export class PiHoleMapper {
public static toSnapshot(optionsArg: IPiHoleSnapshotOptions): IPiHoleSnapshot {
if (optionsArg.config.snapshot && !optionsArg.rawData) {
return this.normalizeSnapshot(optionsArg.config.snapshot, optionsArg.config, optionsArg.source || 'snapshot');
}
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
const apiVersion = optionsArg.apiVersion || optionsArg.config.apiVersion || this.versionFromRaw(rawData);
const statistics = this.statisticsFromRaw(rawData, optionsArg.config.statistics);
const versions = this.versionsFromRaw(rawData, optionsArg.config.versions);
const status = this.statusFromRaw(rawData, optionsArg.config.status);
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(optionsArg.rawData || optionsArg.config.rawData || optionsArg.config.v5Summary || optionsArg.config.v6Summary || optionsArg.config.status || optionsArg.config.statistics || optionsArg.config.versions);
return {
online,
apiVersion,
status,
statistics,
versions,
raw: rawData,
host: optionsArg.config.host,
port: optionsArg.config.port || (optionsArg.config.host ? this.defaultPort(optionsArg.config.ssl) : undefined),
ssl: optionsArg.config.ssl ?? false,
verifySsl: optionsArg.config.verifySsl ?? true,
location: optionsArg.config.location || piHoleDefaultLocation,
name: optionsArg.config.name || piHoleDefaultName,
uniqueId: optionsArg.config.uniqueId,
updatedAt: new Date().toISOString(),
source: optionsArg.source || (Object.keys(rawData).length ? 'manual' : 'runtime'),
error: optionsArg.error,
};
}
public static toDevices(snapshotArg: IPiHoleSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const deviceId = this.deviceId(snapshotArg);
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
{ id: 'blocking', capability: 'switch', name: 'DNS blocking', readable: true, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
{ featureId: 'blocking', value: snapshotArg.status === 'enabled', updatedAt },
];
for (const description of this.statisticDescriptions(snapshotArg)) {
const value = this.statisticValue(snapshotArg.statistics, description.key, description.precision);
features.push({ id: description.entityKey, capability: 'sensor', name: description.name, readable: true, writable: false, unit: description.unit });
state.push({ featureId: description.entityKey, value, updatedAt });
}
for (const description of updateDescriptions) {
const update = snapshotArg.versions[description.key];
features.push({ id: `${description.key}_update_available`, capability: 'sensor', name: description.name, readable: true, writable: false });
state.push({ featureId: `${description.key}_update_available`, value: Boolean(update.updateAvailable), updatedAt });
}
return [{
id: deviceId,
integrationDomain: piHoleDomain,
name: this.deviceName(snapshotArg),
protocol: snapshotArg.host ? 'http' : 'unknown',
manufacturer: 'Pi-hole',
model: snapshotArg.apiVersion ? `Pi-hole API v${snapshotArg.apiVersion}` : 'Pi-hole',
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
host: snapshotArg.host,
port: snapshotArg.port,
ssl: snapshotArg.ssl,
location: snapshotArg.location,
apiVersion: snapshotArg.apiVersion,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IPiHoleSnapshot): IIntegrationEntity[] {
const deviceId = this.deviceId(snapshotArg);
const baseName = this.deviceName(snapshotArg);
const baseSlug = this.slug(baseName);
const uniqueBase = this.uniqueBase(snapshotArg);
const entities: IIntegrationEntity[] = [
this.entity('binary_sensor', `${baseName} Status`, deviceId, `${uniqueBase}_status`, this.statusState(snapshotArg.status), snapshotArg.online, {
deviceClass: 'running',
piHoleStatus: snapshotArg.status,
apiVersion: snapshotArg.apiVersion,
}),
{
id: `switch.${baseSlug}`,
uniqueId: `${piHoleDomain}_${uniqueBase}_switch`,
integrationDomain: piHoleDomain,
deviceId,
platform: 'switch',
name: baseName,
state: this.statusState(snapshotArg.status),
attributes: this.cleanAttributes({
piHoleSwitch: 'blocking',
writable: true,
apiVersion: snapshotArg.apiVersion,
}),
available: snapshotArg.online && snapshotArg.status !== 'unknown',
},
];
for (const description of this.statisticDescriptions(snapshotArg)) {
const value = this.statisticValue(snapshotArg.statistics, description.key, description.precision);
entities.push(this.entity('sensor', `${baseName} ${description.name}`, deviceId, `${uniqueBase}_sensor_${description.entityKey}`, value, snapshotArg.online && value !== null, {
unit: description.unit,
stateClass: typeof value === 'number' ? 'measurement' : undefined,
apiVersion: snapshotArg.apiVersion,
}));
}
for (const description of updateDescriptions) {
const update = snapshotArg.versions[description.key];
const latestVersion = update.updateAvailable ? update.latest : update.current || update.latest;
entities.push(this.entity('update', `${baseName} ${description.name}`, deviceId, `${uniqueBase}_update_${description.key}`, update.updateAvailable ? 'on' : 'off', snapshotArg.online && Boolean(update.current || update.latest), {
title: description.title,
entityCategory: 'diagnostic',
installedVersion: update.current,
latestVersion,
releaseUrl: latestVersion ? `${releaseBaseUrls[description.key]}/${latestVersion}` : undefined,
}));
}
return entities;
}
public static commandForService(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): IPiHoleClientCommand | { error: string } | undefined {
if (requestArg.domain === piHoleDomain) {
if (requestArg.service === 'refresh') {
return this.command(snapshotArg, 'refresh', requestArg, undefined);
}
if (requestArg.service === 'enable' || requestArg.service === 'disable') {
return this.command(snapshotArg, requestArg.service, requestArg, undefined);
}
return undefined;
}
if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) {
const target = this.targetSwitch(snapshotArg, requestArg);
if ('error' in target) {
return target;
}
return this.command(snapshotArg, requestArg.service === 'turn_on' ? 'enable' : 'disable', requestArg, target.entity);
}
return undefined;
}
public static deviceId(snapshotArg: IPiHoleSnapshot): string {
return `${piHoleDomain}.service.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'pi_hole';
}
private static normalizeSnapshot(snapshotArg: IPiHoleSnapshot, configArg: IPiHoleConfig, sourceArg: TPiHoleSnapshotSource): IPiHoleSnapshot {
const raw = this.rawData(configArg, snapshotArg.raw);
return {
...snapshotArg,
online: snapshotArg.online,
apiVersion: snapshotArg.apiVersion || configArg.apiVersion || this.versionFromRaw(raw),
status: snapshotArg.status || this.statusFromRaw(raw, configArg.status),
statistics: this.completeStatistics(snapshotArg.statistics || this.statisticsFromRaw(raw, configArg.statistics)),
versions: this.completeVersions(snapshotArg.versions || this.versionsFromRaw(raw, configArg.versions)),
raw,
host: snapshotArg.host || configArg.host,
port: snapshotArg.port || configArg.port || (snapshotArg.host || configArg.host ? this.defaultPort(snapshotArg.ssl ?? configArg.ssl) : undefined),
ssl: snapshotArg.ssl ?? configArg.ssl ?? false,
verifySsl: snapshotArg.verifySsl ?? configArg.verifySsl ?? true,
location: snapshotArg.location || configArg.location || piHoleDefaultLocation,
name: snapshotArg.name || configArg.name || piHoleDefaultName,
uniqueId: snapshotArg.uniqueId || configArg.uniqueId,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
error: snapshotArg.error,
};
}
private static rawData(configArg: IPiHoleConfig, rawDataArg?: IPiHoleRawData): IPiHoleRawData {
return this.cleanAttributes({
...(configArg.rawData || {}),
...(rawDataArg || {}),
v5Summary: rawDataArg?.v5Summary || configArg.v5Summary || configArg.rawData?.v5Summary,
v5Versions: rawDataArg?.v5Versions || configArg.v5Versions || configArg.rawData?.v5Versions,
v6Summary: rawDataArg?.v6Summary || configArg.v6Summary || configArg.rawData?.v6Summary,
v6Blocking: rawDataArg?.v6Blocking || configArg.v6Blocking || configArg.rawData?.v6Blocking,
v6Versions: rawDataArg?.v6Versions || configArg.v6Versions || configArg.rawData?.v6Versions,
}) as IPiHoleRawData;
}
private static statisticsFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: Partial<IPiHoleStatistics>): IPiHoleStatistics {
const queries = this.recordValue(rawDataArg.v6Summary?.queries);
const clients = this.recordValue(rawDataArg.v6Summary?.clients);
const gravity = this.recordValue(rawDataArg.v6Summary?.gravity);
const statistics = this.completeStatistics({
adsBlocked: this.numberValue(rawDataArg.v5Summary?.ads_blocked_today) ?? this.numberValue(queries?.blocked),
adsPercentage: this.numberValue(rawDataArg.v5Summary?.ads_percentage_today) ?? this.numberValue(queries?.percent_blocked),
clientsSeen: this.numberValue(rawDataArg.v5Summary?.clients_ever_seen) ?? this.numberValue(clients?.total),
dnsQueries: this.numberValue(rawDataArg.v5Summary?.dns_queries_today) ?? this.numberValue(queries?.total),
domainsBlocked: this.numberValue(rawDataArg.v5Summary?.domains_being_blocked) ?? this.numberValue(gravity?.domains_being_blocked),
queriesCached: this.numberValue(rawDataArg.v5Summary?.queries_cached) ?? this.numberValue(queries?.cached),
queriesForwarded: this.numberValue(rawDataArg.v5Summary?.queries_forwarded) ?? this.numberValue(queries?.forwarded),
uniqueClients: this.numberValue(rawDataArg.v5Summary?.unique_clients) ?? this.numberValue(clients?.active),
uniqueDomains: this.numberValue(rawDataArg.v5Summary?.unique_domains) ?? this.numberValue(queries?.unique_domains),
});
return this.completeStatistics({ ...statistics, ...(overrideArg || {}) });
}
private static versionsFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: Partial<IPiHoleVersions>): IPiHoleVersions {
const versionRoot = rawDataArg.v6Versions?.version;
const versions = this.completeVersions({
core: {
current: this.stringValue(rawDataArg.v5Versions?.core_current) || this.stringValue(versionRoot?.core?.local?.version),
latest: this.stringValue(rawDataArg.v5Versions?.core_latest) || this.stringValue(versionRoot?.core?.remote?.version),
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.core_update) ?? this.updateAvailable(versionRoot?.core),
},
web: {
current: this.stringValue(rawDataArg.v5Versions?.web_current) || this.stringValue(versionRoot?.web?.local?.version),
latest: this.stringValue(rawDataArg.v5Versions?.web_latest) || this.stringValue(versionRoot?.web?.remote?.version),
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.web_update) ?? this.updateAvailable(versionRoot?.web),
},
ftl: {
current: this.stringValue(rawDataArg.v5Versions?.FTL_current) || this.stringValue(versionRoot?.ftl?.local?.version),
latest: this.stringValue(rawDataArg.v5Versions?.FTL_latest) || this.stringValue(versionRoot?.ftl?.remote?.version),
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.FTL_update) ?? this.updateAvailable(versionRoot?.ftl),
},
});
return this.completeVersions({
core: { ...versions.core, ...(overrideArg?.core || {}) },
web: { ...versions.web, ...(overrideArg?.web || {}) },
ftl: { ...versions.ftl, ...(overrideArg?.ftl || {}) },
});
}
private static statusFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: string | boolean): TPiHoleBlockingStatus {
return this.normalizeStatus(overrideArg ?? rawDataArg.v5Summary?.status ?? rawDataArg.v6Blocking?.blocking);
}
private static versionFromRaw(rawDataArg: IPiHoleRawData): TPiHoleApiVersion | undefined {
if (rawDataArg.v6Summary || rawDataArg.v6Blocking || rawDataArg.v6Versions) return 6;
if (rawDataArg.v5Summary || rawDataArg.v5Versions) return 5;
return undefined;
}
private static command(snapshotArg: IPiHoleSnapshot, serviceArg: 'enable' | 'disable' | 'refresh', requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined): IPiHoleClientCommand | { error: string } {
if (serviceArg === 'refresh') {
return {
type: 'refresh',
service: requestArg.service,
target: requestArg.target,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
apiVersion: snapshotArg.apiVersion,
};
}
const durationSeconds = serviceArg === 'disable' ? this.durationSeconds(requestArg.data?.duration) : undefined;
if (durationSeconds === null) {
return { error: 'Pi-hole disable requires data.duration as seconds or HH:MM:SS when provided.' };
}
const apiVersion = snapshotArg.apiVersion || 6;
const enabled = serviceArg === 'enable';
return this.cleanAttributes({
type: serviceArg,
service: requestArg.service,
method: apiVersion === 6 ? 'POST' : 'GET',
path: apiVersion === 6 ? '/api/dns/blocking' : `/${snapshotArg.location || piHoleDefaultLocation}/api.php`,
query: apiVersion === 5 ? enabled ? { enable: 'True' } : { disable: durationSeconds ?? true } : undefined,
payload: apiVersion === 6 ? { blocking: enabled, timer: enabled ? null : durationSeconds ?? null } : undefined,
target: requestArg.target,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
apiVersion,
enabled,
durationSeconds,
requiresAuth: true,
}) as IPiHoleClientCommand;
}
private static targetSwitch(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): { entity?: IIntegrationEntity } | { error: string } {
const entity = this.findTargetEntity(snapshotArg, requestArg);
if (entity?.attributes?.piHoleSwitch === 'blocking') {
return { entity };
}
if (requestArg.target.deviceId && requestArg.target.deviceId === this.deviceId(snapshotArg)) {
return { entity };
}
return { error: 'Pi-hole switch service calls require the Pi-hole switch entity or device target.' };
}
private static findTargetEntity(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
if (!requestArg.target.entityId) {
return undefined;
}
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId);
}
private static statisticDescriptions(snapshotArg: IPiHoleSnapshot): IPiHoleStatDescription[] {
return snapshotArg.apiVersion === 6 ? v6StatisticDescriptions : v5StatisticDescriptions;
}
private static statisticValue(statisticsArg: IPiHoleStatistics, keyArg: keyof IPiHoleStatistics, precisionArg = 2): number | null {
const value = statisticsArg[keyArg];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const factor = 10 ** precisionArg;
return Math.round(value * factor) / factor;
}
private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, availableArg: boolean, attributesArg: Record<string, unknown> = {}): IIntegrationEntity {
return {
id: `${platformArg}.${this.slug(nameArg)}`,
uniqueId: `${piHoleDomain}_${uniqueIdArg}`,
integrationDomain: piHoleDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static statusState(statusArg: TPiHoleBlockingStatus): 'on' | 'off' | 'unknown' {
if (statusArg === 'enabled') return 'on';
if (statusArg === 'disabled') return 'off';
return 'unknown';
}
private static normalizeStatus(valueArg: unknown): TPiHoleBlockingStatus {
if (valueArg === true) return 'enabled';
if (valueArg === false) return 'disabled';
const value = typeof valueArg === 'string' ? valueArg.toLowerCase() : '';
if (value === 'enabled' || value === 'enable' || value === 'true') return 'enabled';
if (value === 'disabled' || value === 'disable' || value === 'false') return 'disabled';
return 'unknown';
}
private static completeStatistics(valueArg: Partial<IPiHoleStatistics>): IPiHoleStatistics {
return {
adsBlocked: this.numberOrNull(valueArg.adsBlocked),
adsPercentage: this.numberOrNull(valueArg.adsPercentage),
clientsSeen: this.numberOrNull(valueArg.clientsSeen),
dnsQueries: this.numberOrNull(valueArg.dnsQueries),
domainsBlocked: this.numberOrNull(valueArg.domainsBlocked),
queriesCached: this.numberOrNull(valueArg.queriesCached),
queriesForwarded: this.numberOrNull(valueArg.queriesForwarded),
uniqueClients: this.numberOrNull(valueArg.uniqueClients),
uniqueDomains: this.numberOrNull(valueArg.uniqueDomains),
};
}
private static completeVersions(valueArg: Partial<IPiHoleVersions>): IPiHoleVersions {
return {
core: { ...(valueArg.core || {}) },
web: { ...(valueArg.web || {}) },
ftl: { ...(valueArg.ftl || {}) },
};
}
private static updateAvailable(componentArg: { local?: { hash?: string; version?: string }; remote?: { hash?: string; version?: string } } | undefined): boolean | undefined {
if (!componentArg) return undefined;
const localHash = this.stringValue(componentArg.local?.hash);
const remoteHash = this.stringValue(componentArg.remote?.hash);
if (localHash && remoteHash) return localHash !== remoteHash;
const localVersion = this.stringValue(componentArg.local?.version);
const remoteVersion = this.stringValue(componentArg.remote?.version);
return localVersion && remoteVersion ? localVersion !== remoteVersion : undefined;
}
private static durationSeconds(valueArg: unknown): number | undefined | null {
if (valueArg === undefined || valueArg === null || valueArg === '') {
return undefined;
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0) {
return Math.round(valueArg);
}
if (typeof valueArg !== 'string') {
return null;
}
const trimmed = valueArg.trim();
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
return Math.round(Number(trimmed));
}
const parts = trimmed.split(':').map((partArg) => Number(partArg));
if (parts.length < 2 || parts.length > 3 || parts.some((partArg) => !Number.isInteger(partArg) || partArg < 0)) {
return null;
}
const [hours, minutes, seconds] = parts.length === 3 ? parts : [0, parts[0], parts[1]];
if (minutes > 59 || seconds > 59) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
private static deviceName(snapshotArg: IPiHoleSnapshot): string {
return snapshotArg.name || piHoleDefaultName;
}
private static uniqueBase(snapshotArg: IPiHoleSnapshot): string {
return this.slug(snapshotArg.uniqueId || snapshotArg.host && `${snapshotArg.host}_${snapshotArg.port || ''}` || this.deviceName(snapshotArg));
}
private static defaultPort(sslArg: boolean | undefined): number {
return sslArg ? 443 : piHoleDefaultPort;
}
private static recordValue(valueArg: unknown): Record<string, unknown> | undefined {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
private static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return valueArg;
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Number(valueArg);
return undefined;
}
private static numberOrNull(valueArg: unknown): number | null {
return this.numberValue(valueArg) ?? null;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
return typeof valueArg === 'boolean' ? valueArg : undefined;
}
private static cleanAttributes<T extends Record<string, unknown>>(attributesArg: T): T {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T;
}
}
+223 -2
View File
@@ -1,4 +1,225 @@
export interface IHomeAssistantPiHoleConfig {
// TODO: replace with the TypeScript-native config for pi_hole.
import type { IServiceCallResult } from '../../core/types.js';
export const piHoleDomain = 'pi_hole';
export const piHoleDefaultName = 'Pi-hole';
export const piHoleDefaultLocation = 'admin';
export const piHoleDefaultPort = 80;
export const piHoleDefaultTimeoutMs = 5000;
export type TPiHoleApiVersion = 5 | 6;
export type TPiHoleSnapshotSource = 'snapshot' | 'http' | 'manual' | 'runtime';
export type TPiHoleBlockingStatus = 'enabled' | 'disabled' | 'unknown';
export type TPiHoleHttpMethod = 'GET' | 'POST';
export type TPiHoleCommandType = 'enable' | 'disable' | 'refresh';
export type TPiHoleJsonValue = string | number | boolean | null | TPiHoleJsonValue[] | {
[key: string]: TPiHoleJsonValue | undefined;
};
export interface IPiHoleStatistics {
adsBlocked: number | null;
adsPercentage: number | null;
clientsSeen: number | null;
dnsQueries: number | null;
domainsBlocked: number | null;
queriesCached: number | null;
queriesForwarded: number | null;
uniqueClients: number | null;
uniqueDomains: number | null;
}
export interface IPiHoleComponentVersion {
current?: string;
latest?: string;
updateAvailable?: boolean;
}
export interface IPiHoleVersions {
core: IPiHoleComponentVersion;
web: IPiHoleComponentVersion;
ftl: IPiHoleComponentVersion;
}
export interface IPiHoleV5Summary {
status?: string;
ads_blocked_today?: number;
ads_percentage_today?: number;
clients_ever_seen?: number;
dns_queries_today?: number;
domains_being_blocked?: number;
queries_cached?: number;
queries_forwarded?: number;
unique_clients?: number;
unique_domains?: number;
error?: TPiHoleJsonValue;
[key: string]: TPiHoleJsonValue | undefined;
}
export interface IPiHoleV5Versions {
FTL_current?: string;
FTL_latest?: string;
FTL_update?: boolean;
core_current?: string;
core_latest?: string;
core_update?: boolean;
web_current?: string;
web_latest?: string;
web_update?: boolean;
[key: string]: TPiHoleJsonValue | undefined;
}
export interface IPiHoleV6Summary {
queries?: Record<string, TPiHoleJsonValue | undefined>;
clients?: Record<string, TPiHoleJsonValue | undefined>;
gravity?: Record<string, TPiHoleJsonValue | undefined>;
[key: string]: TPiHoleJsonValue | Record<string, TPiHoleJsonValue | undefined> | undefined;
}
export interface IPiHoleV6BlockingStatus {
blocking?: string | boolean;
timer?: number | null;
[key: string]: TPiHoleJsonValue | undefined;
}
export interface IPiHoleV6VersionSide {
version?: string;
hash?: string;
branch?: string;
[key: string]: TPiHoleJsonValue | undefined;
}
export interface IPiHoleV6ComponentVersion {
local?: IPiHoleV6VersionSide;
remote?: IPiHoleV6VersionSide;
[key: string]: TPiHoleJsonValue | IPiHoleV6VersionSide | undefined;
}
export interface IPiHoleV6InfoVersionResponse {
version?: {
core?: IPiHoleV6ComponentVersion;
web?: IPiHoleV6ComponentVersion;
ftl?: IPiHoleV6ComponentVersion;
[key: string]: TPiHoleJsonValue | IPiHoleV6ComponentVersion | undefined;
};
[key: string]: TPiHoleJsonValue | IPiHoleV6InfoVersionResponse['version'] | undefined;
}
export interface IPiHoleRawData {
v5Summary?: IPiHoleV5Summary;
v5Versions?: IPiHoleV5Versions;
v6Summary?: IPiHoleV6Summary;
v6Blocking?: IPiHoleV6BlockingStatus;
v6Versions?: IPiHoleV6InfoVersionResponse;
[key: string]: unknown;
}
export interface IPiHoleSnapshot {
online: boolean;
apiVersion?: TPiHoleApiVersion;
status: TPiHoleBlockingStatus;
statistics: IPiHoleStatistics;
versions: IPiHoleVersions;
raw?: IPiHoleRawData;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
location?: string;
name?: string;
uniqueId?: string;
updatedAt?: string;
source?: TPiHoleSnapshotSource;
error?: string;
}
export interface IPiHoleClientCommand {
type: TPiHoleCommandType;
service: string;
method?: TPiHoleHttpMethod;
path?: string;
query?: Record<string, string | number | boolean | undefined>;
payload?: Record<string, unknown>;
target?: {
entityId?: string;
deviceId?: string;
};
entityId?: string;
deviceId?: string;
uniqueId?: string;
apiVersion?: TPiHoleApiVersion;
enabled?: boolean;
durationSeconds?: number;
requiresAuth?: boolean;
}
export interface IPiHoleCommandResult extends IServiceCallResult {}
export type TPiHoleCommandExecutor = (
commandArg: IPiHoleClientCommand
) => Promise<IPiHoleCommandResult | unknown> | IPiHoleCommandResult | unknown;
export interface IPiHoleConfig {
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
location?: string;
apiKey?: string;
password?: string;
name?: string;
uniqueId?: string;
apiVersion?: TPiHoleApiVersion;
timeoutMs?: number;
snapshot?: IPiHoleSnapshot;
rawData?: IPiHoleRawData;
v5Summary?: IPiHoleV5Summary;
v5Versions?: IPiHoleV5Versions;
v6Summary?: IPiHoleV6Summary;
v6Blocking?: IPiHoleV6BlockingStatus;
v6Versions?: IPiHoleV6InfoVersionResponse;
status?: string | boolean;
statistics?: Partial<IPiHoleStatistics>;
versions?: Partial<IPiHoleVersions>;
online?: boolean;
commandExecutor?: TPiHoleCommandExecutor;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IPiHoleManualEntry {
id?: string;
host?: string;
port?: number;
url?: string;
ssl?: boolean;
verifySsl?: boolean;
location?: string;
apiKey?: string;
password?: string;
apiVersion?: TPiHoleApiVersion;
name?: string;
manufacturer?: string;
model?: string;
integrationDomain?: string;
snapshot?: IPiHoleSnapshot;
rawData?: IPiHoleRawData;
status?: string | boolean;
statistics?: Partial<IPiHoleStatistics>;
versions?: Partial<IPiHoleVersions>;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IPiHoleHttpCandidateRecord {
url?: string;
location?: string;
host?: string;
port?: number;
ssl?: boolean;
name?: string;
manufacturer?: string;
model?: string;
headers?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantPiHoleConfig extends IPiHoleConfig {}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './squeezebox.classes.integration.js';
export * from './squeezebox.classes.client.js';
export * from './squeezebox.classes.configflow.js';
export * from './squeezebox.discovery.js';
export * from './squeezebox.mapper.js';
export * from './squeezebox.types.js';
@@ -0,0 +1,839 @@
import * as plugins from '../../plugins.js';
import type {
ISqueezeboxAlarm,
ISqueezeboxCliResponse,
ISqueezeboxCommandRequest,
ISqueezeboxConfig,
ISqueezeboxFavorite,
ISqueezeboxJsonRpcRequest,
ISqueezeboxJsonRpcResponse,
ISqueezeboxPlayer,
ISqueezeboxServerInfo,
ISqueezeboxSnapshot,
ISqueezeboxSyncGroup,
ISqueezeboxTrack,
TSqueezeboxSnapshotSource,
} from './squeezebox.types.js';
import { squeezeboxDefaultBrowseLimit, squeezeboxDefaultCliPort, squeezeboxDefaultHttpPort, squeezeboxDefaultTimeoutMs, squeezeboxDefaultVolumeStep } from './squeezebox.types.js';
export class SqueezeboxCommandError extends Error {
constructor(public readonly command: string[], messageArg: string) {
super(`Squeezebox command ${command.join(' ')} failed: ${messageArg}`);
this.name = 'SqueezeboxCommandError';
}
}
export class SqueezeboxClient {
private nextId = 1;
private currentSnapshot?: ISqueezeboxSnapshot;
private restorePoint?: ISqueezeboxSnapshot;
constructor(private readonly config: ISqueezeboxConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<ISqueezeboxSnapshot> {
if (this.currentSnapshot) {
return this.normalizeSnapshot(this.cloneSnapshot(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
}
if (!this.config.host && !this.config.commandExecutor) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'Squeezebox refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
}
try {
return await this.fetchSnapshot();
} catch (errorArg) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
}
}
public async validateConnection(): Promise<ISqueezeboxSnapshot> {
const snapshot = await this.fetchSnapshot();
if (!snapshot.server.uuid && !snapshot.server.id) {
throw new Error('Lyrion Music Server did not provide a unique identifier.');
}
return snapshot;
}
public async execute(requestArg: ISqueezeboxCommandRequest): Promise<unknown> {
if (requestArg.command === 'play') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['play']);
}
if (requestArg.command === 'pause') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['pause', '1']);
}
if (requestArg.command === 'play_pause') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['pause']);
}
if (requestArg.command === 'stop') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['stop']);
}
if (requestArg.command === 'next_track') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '+1']);
}
if (requestArg.command === 'previous_track') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '-1']);
}
if (requestArg.command === 'seek') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['time', String(this.requiredNumber(requestArg.position, 'Squeezebox seek requires position.'))]);
}
if (requestArg.command === 'set_power') {
if (typeof requestArg.powered !== 'boolean') {
throw new Error('Squeezebox set_power requires powered.');
}
return this.playerQuery(this.requiredPlayerId(requestArg), ['power', requestArg.powered ? '1' : '0']);
}
if (requestArg.command === 'set_volume') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', String(this.volumePercent(requestArg))]);
}
if (requestArg.command === 'volume_up') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `+${this.volumeStep(requestArg.step)}`]);
}
if (requestArg.command === 'volume_down') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `-${this.volumeStep(requestArg.step)}`]);
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('Squeezebox mute requires muted.');
}
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'muting', requestArg.muted ? '1' : '0']);
}
if (requestArg.command === 'select_source') {
return this.selectSource(this.requiredPlayerId(requestArg), this.requiredString(requestArg.source, 'Squeezebox select_source requires source.'));
}
if (requestArg.command === 'play_media') {
return this.playMedia(this.requiredPlayerId(requestArg), this.requiredString(requestArg.mediaId, 'Squeezebox play_media requires mediaId.'), requestArg.enqueue);
}
if (requestArg.command === 'sync') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', this.requiredString(requestArg.targetPlayerId, 'Squeezebox sync requires targetPlayerId.')]);
}
if (requestArg.command === 'unsync') {
return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', '-']);
}
if (requestArg.command === 'raw_query') {
const params = requestArg.parameters || [];
if (!params.length || params.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
throw new Error('Squeezebox raw_query requires string, number, or boolean parameters.');
}
return this.playerQuery(requestArg.playerId, params.map((itemArg) => String(itemArg)));
}
throw new Error(`Unsupported Squeezebox command: ${requestArg.command}`);
}
public async query(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
return this.playerQuery(playerIdArg, commandArg);
}
public async snapshot(): Promise<ISqueezeboxSnapshot> {
this.restorePoint = await this.getSnapshot();
return this.cloneSnapshot(this.restorePoint);
}
public async restore(snapshotArg = this.restorePoint): Promise<void> {
if (!snapshotArg) {
throw new Error('Squeezebox restore requires a prior snapshot.');
}
for (const player of snapshotArg.players) {
if (typeof player.volume === 'number') {
await this.execute({ command: 'set_volume', playerId: player.playerId, volume: player.volume });
}
if (typeof player.muting === 'boolean') {
await this.execute({ command: 'mute', playerId: player.playerId, muted: player.muting });
}
if (typeof player.power === 'boolean') {
await this.execute({ command: 'set_power', playerId: player.playerId, powered: player.power });
}
if (player.mode === 'play') {
await this.execute({ command: 'play', playerId: player.playerId });
} else if (player.mode === 'pause') {
await this.execute({ command: 'pause', playerId: player.playerId });
} else if (player.mode === 'stop') {
await this.execute({ command: 'stop', playerId: player.playerId });
}
}
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(): Promise<ISqueezeboxSnapshot> {
const serverStatus = await this.playerQuery(undefined, ['serverstatus', '-', '-', 'prefs:libraryname']);
const players = await this.playersFromStatus(serverStatus);
const [favorites, syncGroups] = await Promise.all([
this.fetchFavorites().catch(() => []),
this.fetchSyncGroups(players).catch(() => this.syncGroupsFromPlayers(players)),
]);
const source = this.config.commandExecutor ? 'executor' : this.config.transport === 'cli' ? 'cli' : 'jsonrpc';
return this.normalizeSnapshot({
server: this.serverFromStatus(serverStatus),
players,
favorites,
syncGroups,
online: true,
updatedAt: new Date().toISOString(),
source,
raw: { serverStatus },
}, source);
}
private async playersFromStatus(serverStatusArg: Record<string, unknown>): Promise<ISqueezeboxPlayer[]> {
const loop = arrayRecords(valueForKeys(serverStatusArg, ['players_loop', 'player_loop', 'players']));
const basePlayers = loop.length ? loop.map((itemArg) => this.playerFromData(itemArg)) : await this.fetchPlayers();
const refreshed = await Promise.all(basePlayers.map(async (playerArg) => ({
...playerArg,
...(await this.fetchPlayerStatus(playerArg.playerId).catch(() => undefined)),
})));
return refreshed.map((playerArg) => this.normalizePlayer(playerArg));
}
private async fetchPlayers(): Promise<ISqueezeboxPlayer[]> {
const response = await this.playerQuery(undefined, ['players', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
const loop = arrayRecords(valueForKeys(response, ['players_loop', 'player_loop', 'players']));
return loop.map((itemArg) => this.playerFromData(itemArg));
}
private async fetchPlayerStatus(playerIdArg: string): Promise<Partial<ISqueezeboxPlayer>> {
const response = await this.playerQuery(playerIdArg, ['status', '-', '1', 'tags:adKlJytxN']);
return this.playerFromData({ ...response, playerid: playerIdArg });
}
private async fetchFavorites(): Promise<ISqueezeboxFavorite[]> {
const response = await this.playerQuery(undefined, ['favorites', 'items', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
const loop = arrayRecords(valueForKeys(response, ['loop_loop', 'favorites_loop', 'items_loop', 'items', 'favorites']));
return loop.map((itemArg, indexArg) => this.favoriteFromData(itemArg, indexArg));
}
private async fetchSyncGroups(playersArg: ISqueezeboxPlayer[]): Promise<ISqueezeboxSyncGroup[]> {
const response = await this.playerQuery(undefined, ['syncgroups', '?']);
const loop = arrayRecords(valueForKeys(response, ['syncgroups_loop', 'sync_groups', 'groups']));
const groups = loop.map((itemArg, indexArg) => this.syncGroupFromData(itemArg, indexArg));
return groups.length ? groups : this.syncGroupsFromPlayers(playersArg);
}
private async selectSource(playerIdArg: string, sourceArg: string): Promise<unknown> {
const snapshot = await this.getSnapshot();
const favorite = (snapshot.favorites || []).find((favoriteArg) => favoriteArg.name === sourceArg || favoriteArg.id === sourceArg || favoriteArg.itemId === sourceArg || favoriteArg.url === sourceArg);
if (favorite?.url) {
return this.playerQuery(playerIdArg, ['playlist', 'play', favorite.url]);
}
if (favorite?.itemId) {
return this.playerQuery(playerIdArg, ['favorites', 'playlist', 'play', `item_id:${favorite.itemId}`]);
}
if (sourceLike(sourceArg) || isUrl(sourceArg)) {
return this.playerQuery(playerIdArg, ['playlist', 'play', sourceArg]);
}
throw new Error(`Unknown Squeezebox source: ${sourceArg}`);
}
private async playMedia(playerIdArg: string, mediaIdArg: string, enqueueArg: 'play' | 'add' | 'next' = 'play'): Promise<unknown> {
const command = enqueueArg === 'add' ? 'add' : enqueueArg === 'next' ? 'insert' : 'play';
return this.playerQuery(playerIdArg, ['playlist', command, mediaIdArg]);
}
private async playerQuery(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
if (!commandArg.length || commandArg.some((itemArg) => !itemArg)) {
throw new Error('Squeezebox command parameters must be non-empty strings.');
}
const transport = this.config.transport || 'jsonrpc';
if (transport === 'snapshot' || this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
throw new Error('Squeezebox command transport requires config.host or commandExecutor. Static snapshots are read-only.');
}
if (transport === 'cli') {
return this.cliResponseToRecord(await this.requestCli(playerIdArg, commandArg));
}
return this.requestJsonRpc(playerIdArg, commandArg);
}
private async requestJsonRpc(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
const host = this.config.host;
const port = this.config.port || squeezeboxDefaultHttpPort;
const endpoint = host ? `${this.config.https ? 'https' : 'http'}://${formatHost(host)}:${port}/jsonrpc.js` : undefined;
const body: ISqueezeboxJsonRpcRequest = {
id: this.nextId++,
method: 'slim.request',
params: [playerIdArg || '', commandArg],
};
if (this.config.commandExecutor) {
return this.executorResultToRecord(await this.config.commandExecutor.execute({
transport: 'jsonrpc',
host,
port,
endpoint,
playerId: playerIdArg,
command: commandArg,
body,
}), commandArg);
}
if (!host || !endpoint) {
throw new Error('Squeezebox HTTP JSON-RPC requires config.host or commandExecutor.');
}
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs || squeezeboxDefaultTimeoutMs);
try {
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/json',
};
if (this.config.username || this.config.password) {
headers.authorization = `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`).toString('base64')}`;
}
const response = await globalThis.fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`Squeezebox JSON-RPC failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
if (!text.trim()) {
throw new Error('Squeezebox JSON-RPC returned an empty response.');
}
return this.jsonRpcResponseToRecord(JSON.parse(text) as ISqueezeboxJsonRpcResponse, commandArg);
} finally {
globalThis.clearTimeout(timeout);
}
}
private async requestCli(playerIdArg: string | undefined, commandArg: string[]): Promise<ISqueezeboxCliResponse> {
const host = this.config.host;
const port = this.config.cliPort || this.config.port || squeezeboxDefaultCliPort;
const cliLine = this.cliLine(playerIdArg, commandArg);
if (this.config.commandExecutor) {
return this.executorResultToCliResponse(await this.config.commandExecutor.execute({
transport: 'cli',
host,
port,
playerId: playerIdArg,
command: commandArg,
cliLine,
}), playerIdArg, commandArg);
}
if (!host) {
throw new Error('Squeezebox CLI command requires config.host or commandExecutor.');
}
const timeoutMs = this.config.timeoutMs || squeezeboxDefaultTimeoutMs;
return new Promise<ISqueezeboxCliResponse>((resolve, reject) => {
let buffer = '';
let settled = false;
let authenticated = !this.config.username && !this.config.password;
let commandSent = false;
const socket = plugins.net.createConnection({ host, port });
const finish = (errorArg?: Error, responseArg?: ISqueezeboxCliResponse) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
if (errorArg) {
reject(errorArg);
return;
}
resolve(responseArg as ISqueezeboxCliResponse);
};
const writeLine = (lineArg: string) => socket.write(`${lineArg}\n`);
const handleLine = (lineArg: string) => {
if (!lineArg.trim()) {
return;
}
if (!authenticated) {
authenticated = true;
commandSent = true;
writeLine(cliLine);
return;
}
if (!commandSent) {
return;
}
finish(undefined, this.parseCliLine(lineArg, playerIdArg, commandArg));
};
socket.setEncoding('utf8');
socket.setTimeout(timeoutMs, () => finish(new Error(`Squeezebox CLI command timed out after ${timeoutMs}ms.`)));
socket.on('connect', () => {
if (this.config.username || this.config.password) {
writeLine(this.cliLine(undefined, ['login', this.config.username || '', this.config.password || '']));
} else {
commandSent = true;
writeLine(cliLine);
}
});
socket.on('error', (errorArg) => finish(errorArg));
socket.on('close', () => finish(new Error('Squeezebox CLI connection closed before a response was received.')));
socket.on('data', (chunkArg) => {
buffer += chunkArg;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || '';
lines.forEach(handleLine);
});
});
}
private serverFromStatus(statusArg: Record<string, unknown>): ISqueezeboxServerInfo {
const host = this.config.host;
const name = this.config.name || stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname', 'name', 'server_name'])) || host || 'Lyrion Music Server';
const uuid = stringValue(valueForKeys(statusArg, ['uuid', 'server_uuid']));
return {
id: this.config.serverId || uuid || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : name),
uuid,
mac: stringValue(valueForKeys(statusArg, ['mac', 'server_mac'])),
name,
libraryName: stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname'])),
host,
port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
version: stringValue(valueForKeys(statusArg, ['version', 'server_version'])),
manufacturer: 'Lyrion',
model: 'Lyrion Music Server',
playerCount: numberValue(valueForKeys(statusArg, ['player count', 'player_count', 'players', 'playercount'])),
otherPlayerCount: numberValue(valueForKeys(statusArg, ['other player count', 'other_player_count'])),
rescan: booleanValue(valueForKeys(statusArg, ['rescan'])),
needsRestart: booleanValue(valueForKeys(statusArg, ['needsrestart', 'needs_restart'])),
stats: {
totalAlbums: numberValue(valueForKeys(statusArg, ['info total albums', 'info_total_albums', 'albums'])),
totalArtists: numberValue(valueForKeys(statusArg, ['info total artists', 'info_total_artists', 'artists'])),
totalDuration: numberValue(valueForKeys(statusArg, ['info total duration', 'info_total_duration', 'duration'])),
totalGenres: numberValue(valueForKeys(statusArg, ['info total genres', 'info_total_genres', 'genres'])),
totalSongs: numberValue(valueForKeys(statusArg, ['info total songs', 'info_total_songs', 'songs'])),
lastScan: stringValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])) || numberValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])),
},
raw: statusArg,
};
}
private playerFromData(dataArg: Record<string, unknown>): ISqueezeboxPlayer {
const playerId = stringValue(valueForKeys(dataArg, ['playerid', 'player_id', 'id', 'playerId'])) || 'unknown';
const playlist = this.playlistFromData(dataArg);
return this.normalizePlayer({
playerId,
uuid: stringValue(valueForKeys(dataArg, ['uuid', 'player_uuid'])),
name: stringValue(valueForKeys(dataArg, ['name', 'player_name'])) || playerId,
model: stringValue(valueForKeys(dataArg, ['model', 'modelname', 'player_model'])) || 'Squeezebox Player',
modelType: stringValue(valueForKeys(dataArg, ['model_type', 'modelType', 'displaytype'])),
manufacturer: stringValue(valueForKeys(dataArg, ['manufacturer'])) || 'Logitech',
creator: stringValue(valueForKeys(dataArg, ['creator'])),
firmware: stringValue(valueForKeys(dataArg, ['firmware', 'firmware_version', 'player_version'])),
ipAddress: cleanIp(stringValue(valueForKeys(dataArg, ['ip', 'ipAddress', 'player_ip']))),
connected: booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
power: booleanValue(valueForKeys(dataArg, ['power'])),
mode: stringValue(valueForKeys(dataArg, ['mode', 'playmode'])) as ISqueezeboxPlayer['mode'],
volume: numberValue(valueForKeys(dataArg, ['volume', 'mixer volume'])),
muting: booleanValue(valueForKeys(dataArg, ['muting', 'mixer muting', 'muted'])),
repeat: this.repeatFromValue(valueForKeys(dataArg, ['repeat', 'playlist repeat'])) as ISqueezeboxPlayer['repeat'],
shuffle: this.shuffleFromValue(valueForKeys(dataArg, ['shuffle', 'playlist shuffle'])) as ISqueezeboxPlayer['shuffle'],
time: numberValue(valueForKeys(dataArg, ['time', 'elapsed'])),
duration: numberValue(valueForKeys(dataArg, ['duration'])),
title: stringValue(valueForKeys(dataArg, ['title', 'track'])),
remoteTitle: stringValue(valueForKeys(dataArg, ['remote_title', 'remoteTitle'])),
artist: stringValue(valueForKeys(dataArg, ['artist'])),
album: stringValue(valueForKeys(dataArg, ['album'])),
url: stringValue(valueForKeys(dataArg, ['url', 'current_url'])),
imageUrl: stringValue(valueForKeys(dataArg, ['image_url', 'artwork_url', 'coverart'])),
playlist,
currentIndex: numberValue(valueForKeys(dataArg, ['playlist_cur_index', 'current_index', 'currentIndex'])),
alarms: this.alarmsFromData(dataArg),
alarmsEnabled: booleanValue(valueForKeys(dataArg, ['alarms_enabled', 'alarmsEnabled'])),
alarmNext: stringValue(valueForKeys(dataArg, ['alarm_next', 'alarmNext'])),
syncGroup: stringArray(valueForKeys(dataArg, ['sync_group', 'syncGroup', 'syncgroup'])).filter((idArg) => idArg !== playerId),
source: stringValue(valueForKeys(dataArg, ['source'])),
available: booleanValue(valueForKeys(dataArg, ['available'])) ?? booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
raw: dataArg,
});
}
private normalizePlayer(playerArg: ISqueezeboxPlayer | Partial<ISqueezeboxPlayer>): ISqueezeboxPlayer {
const playerId = playerArg.playerId || 'unknown';
const connected = playerArg.connected ?? playerArg.available ?? true;
return {
...playerArg,
playerId,
name: playerArg.name || playerId,
model: playerArg.model || 'Squeezebox Player',
connected,
available: playerArg.available ?? connected,
power: playerArg.power ?? true,
mode: playerArg.mode || 'unknown',
syncGroup: playerArg.syncGroup || [],
} as ISqueezeboxPlayer;
}
private favoriteFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxFavorite {
const itemId = stringValue(valueForKeys(dataArg, ['item_id', 'itemId', 'id']));
const url = stringValue(valueForKeys(dataArg, ['url', 'playlist_url', 'play_url']));
const name = stringValue(valueForKeys(dataArg, ['name', 'title', 'text'])) || itemId || url || `Favorite ${indexArg + 1}`;
return {
id: itemId || url || String(indexArg + 1),
name,
type: stringValue(valueForKeys(dataArg, ['type', 'isaudio'])),
url,
itemId,
imageUrl: stringValue(valueForKeys(dataArg, ['image', 'image_url', 'icon'])),
playable: booleanValue(valueForKeys(dataArg, ['playable', 'isaudio'])) ?? true,
raw: dataArg,
};
}
private syncGroupFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxSyncGroup {
const playerIds = stringArray(valueForKeys(dataArg, ['players', 'playerids', 'members', 'sync_members', 'playerIds']));
const leader = stringValue(valueForKeys(dataArg, ['leader', 'master', 'leaderPlayerId'])) || playerIds[0];
return {
id: stringValue(valueForKeys(dataArg, ['id', 'sync_group_id'])) || `sync_${indexArg + 1}`,
name: stringValue(valueForKeys(dataArg, ['name'])),
playerIds,
leaderPlayerId: leader,
raw: dataArg,
};
}
private syncGroupsFromPlayers(playersArg: ISqueezeboxPlayer[]): ISqueezeboxSyncGroup[] {
const groups = new Map<string, ISqueezeboxSyncGroup>();
for (const player of playersArg) {
const playerIds = [player.playerId, ...(player.syncGroup || [])].filter(Boolean).sort();
if (playerIds.length < 2) {
continue;
}
const key = playerIds.join(',');
groups.set(key, { id: `sync_${slug(key)}`, playerIds, leaderPlayerId: playerIds[0] });
}
return [...groups.values()];
}
private playlistFromData(dataArg: Record<string, unknown>): ISqueezeboxTrack[] | undefined {
const loop = arrayRecords(valueForKeys(dataArg, ['playlist_loop', 'playlist']));
if (!loop.length) {
return undefined;
}
return loop.map((trackArg) => ({
id: valueForKeys(trackArg, ['id', 'track_id']) as string | number | undefined,
url: stringValue(valueForKeys(trackArg, ['url'])),
title: stringValue(valueForKeys(trackArg, ['title', 'track'])),
artist: stringValue(valueForKeys(trackArg, ['artist'])),
album: stringValue(valueForKeys(trackArg, ['album'])),
duration: numberValue(valueForKeys(trackArg, ['duration'])),
remoteTitle: stringValue(valueForKeys(trackArg, ['remote_title'])),
imageUrl: stringValue(valueForKeys(trackArg, ['image_url', 'coverart'])),
raw: trackArg,
}));
}
private alarmsFromData(dataArg: Record<string, unknown>): ISqueezeboxAlarm[] | undefined {
const loop = arrayRecords(valueForKeys(dataArg, ['alarms', 'alarms_loop']));
if (!loop.length) {
return undefined;
}
return loop.map((alarmArg, indexArg) => ({
id: stringValue(valueForKeys(alarmArg, ['id', 'alarm_id'])) || String(indexArg + 1),
enabled: booleanValue(valueForKeys(alarmArg, ['enabled'])),
time: stringValue(valueForKeys(alarmArg, ['time'])),
repeat: booleanValue(valueForKeys(alarmArg, ['repeat'])),
scheduledToday: booleanValue(valueForKeys(alarmArg, ['scheduled_today', 'scheduledToday'])),
daysOfWeek: numberArray(valueForKeys(alarmArg, ['dow', 'daysOfWeek'])),
volume: numberValue(valueForKeys(alarmArg, ['volume'])),
url: stringValue(valueForKeys(alarmArg, ['url'])),
raw: alarmArg,
}));
}
private normalizeSnapshot(snapshotArg: ISqueezeboxSnapshot, sourceArg: TSqueezeboxSnapshotSource): ISqueezeboxSnapshot {
const server = {
...snapshotArg.server,
id: snapshotArg.server.id || snapshotArg.server.uuid || this.config.serverId || this.config.host || snapshotArg.server.name || 'squeezebox',
name: snapshotArg.server.name || snapshotArg.server.libraryName || this.config.name || this.config.host || 'Lyrion Music Server',
host: snapshotArg.server.host || this.config.host,
port: snapshotArg.server.port || (this.config.host ? this.config.port || squeezeboxDefaultHttpPort : this.config.port),
manufacturer: snapshotArg.server.manufacturer || 'Lyrion',
model: snapshotArg.server.model || 'Lyrion Music Server',
playerCount: snapshotArg.server.playerCount ?? snapshotArg.players.length,
};
const players = (snapshotArg.players || []).map((playerArg) => this.normalizePlayer(playerArg));
return {
...snapshotArg,
server,
players,
favorites: snapshotArg.favorites || [],
syncGroups: snapshotArg.syncGroups || this.syncGroupsFromPlayers(players),
online: snapshotArg.online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
};
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): ISqueezeboxSnapshot {
const host = this.config.host;
return {
server: {
id: this.config.serverId || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : undefined) || this.config.name || 'squeezebox',
name: this.config.name || host || 'Lyrion Music Server',
host,
port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
manufacturer: 'Lyrion',
model: 'Lyrion Music Server',
},
players: [],
favorites: [],
syncGroups: [],
online: onlineArg,
updatedAt: new Date().toISOString(),
source: 'runtime',
error: errorArg,
};
}
private jsonRpcResponseToRecord(responseArg: ISqueezeboxJsonRpcResponse | Record<string, unknown>, commandArg: string[]): Record<string, unknown> {
if ('error' in responseArg && responseArg.error) {
const error = responseArg.error as { message?: string } | string;
throw new SqueezeboxCommandError(commandArg, typeof error === 'string' ? error : error.message || JSON.stringify(error));
}
if ('result' in responseArg) {
return recordValue(responseArg.result);
}
return recordValue(responseArg);
}
private executorResultToRecord(resultArg: unknown, commandArg: string[]): Record<string, unknown> {
if (this.isCliResponse(resultArg)) {
return resultArg.result;
}
if (this.isJsonRpcResponse(resultArg)) {
return this.jsonRpcResponseToRecord(resultArg, commandArg);
}
return recordValue(resultArg);
}
private executorResultToCliResponse(resultArg: unknown, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
if (this.isCliResponse(resultArg)) {
return resultArg;
}
if (typeof resultArg === 'string') {
return this.parseCliLine(resultArg, playerIdArg, commandArg);
}
return {
playerId: playerIdArg,
command: commandArg,
rawLine: '',
tokens: [],
result: this.executorResultToRecord(resultArg, commandArg),
};
}
private parseCliLine(lineArg: string, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
const tokens = lineArg.trim().split(/\s+/).map((tokenArg) => decodeURIComponentSafe(tokenArg));
const result: Record<string, unknown> = {};
for (const token of tokens) {
const separator = token.indexOf(':');
if (separator > 0) {
addRecordValue(result, token.slice(0, separator), token.slice(separator + 1));
}
}
if (!Object.keys(result).length && tokens.length > commandArg.length) {
result.value = tokens[tokens.length - 1];
}
return { playerId: playerIdArg, command: commandArg, rawLine: lineArg, tokens, result };
}
private cliResponseToRecord(responseArg: ISqueezeboxCliResponse): Record<string, unknown> {
return responseArg.result;
}
private cliLine(playerIdArg: string | undefined, commandArg: string[]): string {
return [playerIdArg, ...commandArg].filter((itemArg): itemArg is string => Boolean(itemArg)).map((itemArg) => encodeCliValue(itemArg)).join(' ');
}
private isJsonRpcResponse(valueArg: unknown): valueArg is ISqueezeboxJsonRpcResponse {
return Boolean(valueArg && typeof valueArg === 'object' && ('result' in valueArg || 'error' in valueArg || (valueArg as { method?: unknown }).method === 'slim.request'));
}
private isCliResponse(valueArg: unknown): valueArg is ISqueezeboxCliResponse {
return Boolean(valueArg && typeof valueArg === 'object' && Array.isArray((valueArg as ISqueezeboxCliResponse).command) && (valueArg as ISqueezeboxCliResponse).result);
}
private requiredPlayerId(requestArg: ISqueezeboxCommandRequest): string {
return this.requiredString(requestArg.playerId, 'Squeezebox command requires playerId.');
}
private requiredString(valueArg: unknown, errorArg: string): string {
if (typeof valueArg !== 'string' || !valueArg) {
throw new Error(errorArg);
}
return valueArg;
}
private requiredNumber(valueArg: unknown, errorArg: string): number {
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
throw new Error(errorArg);
}
return valueArg;
}
private volumePercent(requestArg: ISqueezeboxCommandRequest): number {
const value = requestArg.volumeLevel ?? requestArg.volume;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('Squeezebox set_volume requires volumeLevel or volume.');
}
return Math.max(0, Math.min(100, Math.round(value <= 1 ? value * 100 : value)));
}
private volumeStep(valueArg: number | undefined): number {
const step = typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : this.config.volumeStep || squeezeboxDefaultVolumeStep;
return Math.max(1, Math.min(100, Math.round(step <= 1 ? step * 100 : step)));
}
private repeatFromValue(valueArg: unknown): string | undefined {
if (valueArg === 2 || valueArg === '2' || valueArg === 'playlist') {
return 'playlist';
}
if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
return 'song';
}
if (valueArg !== undefined) {
return 'none';
}
return undefined;
}
private shuffleFromValue(valueArg: unknown): string | undefined {
if (valueArg === 2 || valueArg === '2' || valueArg === 'album') {
return 'album';
}
if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
return 'song';
}
if (valueArg !== undefined) {
return 'none';
}
return undefined;
}
private cloneSnapshot(snapshotArg: ISqueezeboxSnapshot): ISqueezeboxSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as ISqueezeboxSnapshot;
}
}
const valueForKeys = (recordArg: Record<string, unknown> | undefined, keysArg: string[]): unknown => {
if (!recordArg) {
return undefined;
}
const lowerEntries = Object.entries(recordArg).map(([key, value]) => [key.toLowerCase(), value] as const);
for (const key of keysArg) {
const direct = recordArg[key];
if (direct !== undefined) {
return direct;
}
const match = lowerEntries.find(([entryKey]) => entryKey === key.toLowerCase());
if (match) {
return match[1];
}
}
return undefined;
};
const arrayRecords = (valueArg: unknown): Record<string, unknown>[] => {
if (!Array.isArray(valueArg)) {
return [];
}
return valueArg.filter((itemArg): itemArg is Record<string, unknown> => Boolean(itemArg && typeof itemArg === 'object' && !Array.isArray(itemArg)));
};
const recordValue = (valueArg: unknown): Record<string, unknown> => {
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return valueArg === undefined ? {} : { value: valueArg };
};
const addRecordValue = (recordArg: Record<string, unknown>, keyArg: string, valueArg: unknown): void => {
const existing = recordArg[keyArg];
if (existing === undefined) {
recordArg[keyArg] = valueArg;
} else if (Array.isArray(existing)) {
existing.push(valueArg);
} else {
recordArg[keyArg] = [existing, valueArg];
}
};
const stringValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string' && valueArg) {
return valueArg;
}
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;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
if (typeof valueArg === 'string') {
const lower = valueArg.toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(lower)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(lower)) {
return false;
}
}
return undefined;
};
const stringArray = (valueArg: unknown): string[] => {
if (Array.isArray(valueArg)) {
return valueArg.map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg));
}
if (typeof valueArg === 'string') {
return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
}
return [];
};
const numberArray = (valueArg: unknown): number[] | undefined => {
const values = Array.isArray(valueArg) ? valueArg : typeof valueArg === 'string' ? valueArg.split(',') : [];
const numbers = values.map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => typeof itemArg === 'number');
return numbers.length ? numbers : undefined;
};
const cleanIp = (valueArg: string | undefined): string | undefined => valueArg?.split(':')[0] || undefined;
const sourceLike = (valueArg: string): boolean => /^(source|wavin|spotify|loop):/i.test(valueArg);
const isUrl = (valueArg: string): boolean => {
try {
const url = new URL(valueArg);
return Boolean(url.protocol && url.host);
} catch {
return false;
}
};
const encodeCliValue = (valueArg: string): string => encodeURIComponent(valueArg).replace(/%3A/gi, ':');
const decodeURIComponentSafe = (valueArg: string): string => {
try {
return decodeURIComponent(valueArg);
} catch {
return valueArg;
}
};
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'squeezebox';
@@ -0,0 +1,67 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { ISqueezeboxConfig } from './squeezebox.types.js';
import { squeezeboxDefaultBrowseLimit, squeezeboxDefaultHttpPort, squeezeboxDefaultTimeoutMs, squeezeboxDefaultVolumeStep } from './squeezebox.types.js';
export class SqueezeboxConfigFlow implements IConfigFlow<ISqueezeboxConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISqueezeboxConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Lyrion Music Server',
description: 'Configure a local Logitech/Lyrion Media Server HTTP JSON-RPC endpoint.',
fields: [
{ name: 'host', label: 'LMS host', type: 'text', required: true },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'https', label: 'Use HTTPS', type: 'boolean' },
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'browseLimit', label: 'Browse limit', type: 'number' },
{ name: 'volumeStep', label: 'Volume step percent', type: 'number' },
],
submit: async (valuesArg) => {
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
if (!host) {
return { kind: 'error', title: 'Squeezebox setup failed', error: 'Lyrion Music Server host is required.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || squeezeboxDefaultHttpPort;
return {
kind: 'done',
title: 'Squeezebox configured',
config: {
host,
port,
https: this.booleanValue(valuesArg.https),
username: this.stringValue(valuesArg.username),
password: this.stringValue(valuesArg.password),
name: this.stringValue(valuesArg.name) || candidateArg.name,
serverId: candidateArg.id || `${host}:${port}`,
timeoutMs: squeezeboxDefaultTimeoutMs,
browseLimit: this.numberValue(valuesArg.browseLimit) || squeezeboxDefaultBrowseLimit,
volumeStep: this.numberValue(valuesArg.volumeStep) || squeezeboxDefaultVolumeStep,
transport: 'jsonrpc',
},
};
},
};
}
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;
}
private booleanValue(valueArg: unknown): boolean | undefined {
return typeof valueArg === 'boolean' ? valueArg : undefined;
}
}
@@ -1,29 +1,247 @@
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 { SqueezeboxClient } from './squeezebox.classes.client.js';
import { SqueezeboxConfigFlow } from './squeezebox.classes.configflow.js';
import { createSqueezeboxDiscoveryDescriptor } from './squeezebox.discovery.js';
import { SqueezeboxMapper } from './squeezebox.mapper.js';
import type { ISqueezeboxConfig, ISqueezeboxSnapshot } from './squeezebox.types.js';
export class HomeAssistantSqueezeboxIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "squeezebox",
displayName: "Squeezebox (Lyrion Music Server)",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/squeezebox",
"upstreamDomain": "squeezebox",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "silver",
"requirements": [
"pysqueezebox==0.14.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@rajlaud",
"@pssc",
"@peteS-UK"
]
},
});
export class SqueezeboxIntegration extends BaseIntegration<ISqueezeboxConfig> {
public readonly domain = 'squeezebox';
public readonly displayName = 'Squeezebox (Lyrion Music Server)';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createSqueezeboxDiscoveryDescriptor();
public readonly configFlow = new SqueezeboxConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/squeezebox',
upstreamDomain: 'squeezebox',
integrationType: 'hub',
iotClass: 'local_polling',
qualityScale: 'silver',
requirements: ['pysqueezebox==0.14.0'],
dependencies: [],
afterDependencies: [],
codeowners: ['@rajlaud', '@pssc', '@peteS-UK'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/squeezebox',
};
public async setup(configArg: ISqueezeboxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SqueezeboxRuntime(new SqueezeboxClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantSqueezeboxIntegration extends SqueezeboxIntegration {}
class SqueezeboxRuntime implements IIntegrationRuntime {
public domain = 'squeezebox';
constructor(private readonly client: SqueezeboxClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return SqueezeboxMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return SqueezeboxMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'squeezebox') {
return await this.callSqueezeboxService(requestArg);
}
return { success: false, error: `Unsupported Squeezebox service domain: ${requestArg.domain}` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'media_play' || requestArg.service === 'play') {
return { success: true, data: await this.client.execute({ command: 'play', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
return { success: true, data: await this.client.execute({ command: 'pause', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_play_pause' || requestArg.service === 'play_pause') {
return { success: true, data: await this.client.execute({ command: 'play_pause', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
return { success: true, data: await this.client.execute({ command: 'stop', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track' || requestArg.service === 'next') {
return { success: true, data: await this.client.execute({ command: 'next_track', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track' || requestArg.service === 'previous') {
return { success: true, data: await this.client.execute({ command: 'previous_track', playerId: await this.playerIdFromRequest(requestArg) }) };
}
if (requestArg.service === 'media_seek' || requestArg.service === 'seek') {
return { success: true, data: await this.client.execute({ command: 'seek', playerId: await this.playerIdFromRequest(requestArg), position: this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position') }) };
}
if (requestArg.service === 'turn_on') {
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: true }) };
}
if (requestArg.service === 'turn_off') {
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: false }) };
}
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
return { success: true, data: await this.client.execute({ command: 'set_volume', playerId: await this.playerIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') }) };
}
if (requestArg.service === 'volume_up') {
return { success: true, data: await this.client.execute({ command: 'volume_up', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
}
if (requestArg.service === 'volume_down') {
return { success: true, data: await this.client.execute({ command: 'volume_down', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
}
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
return { success: true, data: await this.client.execute({ command: 'mute', playerId: await this.playerIdFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') }) };
}
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
return { success: true, data: await this.client.execute({ command: 'select_source', playerId: await this.playerIdFromRequest(requestArg), source: this.stringData(requestArg, 'source') }) };
}
if (requestArg.service === 'play_media') {
return { success: true, data: await this.client.execute({ command: 'play_media', playerId: await this.playerIdFromRequest(requestArg), mediaId: this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'), mediaType: this.stringData(requestArg, 'media_content_type'), enqueue: this.enqueueData(requestArg) }) };
}
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
return { success: true, data: await this.joinPlayers(requestArg) };
}
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return { success: true, data: await this.client.execute({ command: 'unsync', playerId: await this.playerIdFromRequest(requestArg) }) };
}
return { success: false, error: `Unsupported Squeezebox media_player service: ${requestArg.service}` };
}
private async callSqueezeboxService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'snapshot') {
return { success: true, data: await this.client.snapshot() };
}
if (requestArg.service === 'restore') {
await this.client.restore(requestArg.data?.snapshot as ISqueezeboxSnapshot | undefined);
return { success: true };
}
if (requestArg.service === 'call_method' || requestArg.service === 'call_query' || requestArg.service === 'query' || requestArg.service === 'command') {
const command = this.stringData(requestArg, 'command');
if (!command) {
throw new Error('Squeezebox raw command service requires data.command.');
}
const parameters = this.parameterArray(requestArg.data?.parameters ?? requestArg.data?.args);
const playerId = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || await this.optionalPlayerIdFromRequest(requestArg);
const data = await this.client.query(playerId, [command, ...parameters.map((itemArg) => String(itemArg))]);
return { success: true, data };
}
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'media_stop' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume_mute' || requestArg.service === 'mute' || requestArg.service === 'select_source' || requestArg.service === 'source') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported Squeezebox service: ${requestArg.service}` };
}
private async joinPlayers(requestArg: IServiceCallRequest): Promise<unknown[]> {
const leaderId = await this.playerIdFromRequest(requestArg);
const memberIds = await this.joinMemberIdsFromRequest(requestArg);
const results: unknown[] = [];
for (const playerId of memberIds.filter((playerIdArg) => playerIdArg !== leaderId)) {
results.push(await this.client.execute({ command: 'sync', playerId: leaderId, targetPlayerId: playerId }));
}
return results;
}
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<string> {
const playerId = await this.optionalPlayerIdFromRequest(requestArg);
if (playerId) {
return playerId;
}
throw new Error('Squeezebox service call requires data.player_id or a target Squeezebox media_player entity.');
}
private async optionalPlayerIdFromRequest(requestArg: IServiceCallRequest): Promise<string | undefined> {
const direct = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || this.stringData(requestArg, 'player');
if (direct) {
return direct;
}
const snapshot = await this.client.getSnapshot();
if (requestArg.target.entityId) {
const entityPlayerId = SqueezeboxMapper.entityPlayerId(snapshot, requestArg.target.entityId);
if (entityPlayerId) {
return entityPlayerId;
}
}
if (requestArg.target.deviceId) {
const player = snapshot.players.find((playerArg) => SqueezeboxMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
if (player) {
return player.playerId;
}
}
return snapshot.players.length === 1 ? snapshot.players[0].playerId : undefined;
}
private async joinMemberIdsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
const direct = this.stringArrayData(requestArg, 'player_ids') || this.stringArrayData(requestArg, 'playerIds');
if (direct?.length) {
return direct;
}
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers') || this.stringArrayData(requestArg, 'sync_members');
if (!members?.length) {
throw new Error('Squeezebox join service requires data.group_members or data.player_ids.');
}
const snapshot = await this.client.getSnapshot();
return members.map((memberArg) => SqueezeboxMapper.entityPlayerId(snapshot, memberArg) || memberArg);
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string') {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
}
private parameterArray(valueArg: unknown): Array<string | number | boolean> {
if (valueArg === undefined) {
return [];
}
const values = Array.isArray(valueArg) ? valueArg : [valueArg];
if (values.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
throw new Error('Squeezebox raw command parameters must be strings, numbers, or booleans.');
}
return values as Array<string | number | boolean>;
}
private enqueueData(requestArg: IServiceCallRequest): 'play' | 'add' | 'next' | undefined {
const enqueue = this.stringData(requestArg, 'enqueue') || this.stringData(requestArg, 'media_enqueue');
if (enqueue === 'add' || enqueue === 'next' || enqueue === 'play') {
return enqueue;
}
return undefined;
}
}
@@ -0,0 +1,203 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { ISqueezeboxDhcpRecord, ISqueezeboxManualEntry, ISqueezeboxMdnsRecord } from './squeezebox.types.js';
import { squeezeboxDefaultHttpPort } from './squeezebox.types.js';
const squeezeboxDomain = 'squeezebox';
const lmsNames = ['squeezebox', 'lyrion', 'logitech media server', 'lms', 'slimserver'];
const lmsMdnsTypes = new Set([
'_squeezebox._tcp',
'_squeezebox-jsonrpc._tcp',
'_squeezebox-server._tcp',
'_lms._tcp',
'_slimserver._tcp',
]);
export class SqueezeboxMdnsMatcher implements IDiscoveryMatcher<ISqueezeboxMdnsRecord> {
public id = 'squeezebox-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize local Lyrion/Logitech Media Server mDNS advertisements.';
public async matches(recordArg: ISqueezeboxMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const properties = { ...recordArg.txt, ...recordArg.properties };
const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(properties, 'name')) || 'Lyrion Music Server';
const haystack = `${name} ${type} ${valueForKey(properties, 'model') || ''} ${valueForKey(properties, 'server') || ''}`.toLowerCase();
const serviceMatch = lmsMdnsTypes.has(type);
const nameMatch = lmsNames.some((needleArg) => haystack.includes(needleArg));
if (!serviceMatch && !nameMatch) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not an LMS/Squeezebox service.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
const port = recordArg.port || numberString(valueForKey(properties, 'port')) || squeezeboxDefaultHttpPort;
const id = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'mac') || (host ? `${host}:${port}` : name);
return {
matched: true,
confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium',
reason: serviceMatch ? `mDNS service ${type} is an LMS service.` : 'mDNS metadata contains LMS/Squeezebox hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: squeezeboxDomain,
id,
host,
port,
name,
manufacturer: 'Lyrion',
model: 'Lyrion Music Server',
metadata: {
mdnsType: type,
txt: properties,
uuid: valueForKey(properties, 'uuid'),
},
},
metadata: { mdnsType: type },
};
}
}
export class SqueezeboxDhcpMatcher implements IDiscoveryMatcher<ISqueezeboxDhcpRecord> {
public id = 'squeezebox-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize Squeezebox player DHCP hints that can start LMS setup.';
public async matches(recordArg: ISqueezeboxDhcpRecord): Promise<IDiscoveryMatch> {
const hostname = recordArg.hostname || recordArg.name || '';
const mac = recordArg.macaddress || recordArg.macAddress || '';
const matched = hostname.toLowerCase().startsWith('squeezebox') || normalizeMac(mac).startsWith('000420');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP record is not a known Squeezebox player hint.' };
}
const host = recordArg.host || recordArg.ipAddress;
const id = normalizeMac(mac) || host || hostname;
return {
matched: true,
confidence: normalizeMac(mac).startsWith('000420') ? 'high' : 'medium',
reason: 'DHCP record matches the Home Assistant Squeezebox player discovery hints.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: squeezeboxDomain,
id,
host,
port: squeezeboxDefaultHttpPort,
name: hostname || 'Squeezebox player',
manufacturer: 'Logitech',
model: 'Squeezebox Player',
macAddress: mac || undefined,
metadata: {
...recordArg.metadata,
playerDiscovery: true,
},
},
};
}
}
export class SqueezeboxManualMatcher implements IDiscoveryMatcher<ISqueezeboxManualEntry> {
public id = 'squeezebox-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Lyrion Music Server setup entries.';
public async matches(inputArg: ISqueezeboxManualEntry): Promise<IDiscoveryMatch> {
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.squeezebox || inputArg.metadata?.lms || lmsNames.some((needleArg) => haystack.includes(needleArg)));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LMS/Squeezebox setup hints.' };
}
const port = inputArg.port || squeezeboxDefaultHttpPort;
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Lyrion Music Server setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: squeezeboxDomain,
id,
host: inputArg.host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Lyrion',
model: inputArg.model || 'Lyrion Music Server',
metadata: {
...inputArg.metadata,
cliPort: inputArg.cliPort,
https: inputArg.https,
username: inputArg.username ? true : undefined,
password: inputArg.password ? true : undefined,
},
},
};
}
}
export class SqueezeboxCandidateValidator implements IDiscoveryValidator {
public id = 'squeezebox-candidate-validator';
public description = 'Validate LMS/Squeezebox discovery candidates have local setup metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
const matched = candidateArg.integrationDomain === squeezeboxDomain
|| Boolean(metadata.squeezebox || metadata.lms || metadata.playerDiscovery)
|| lmsMdnsTypes.has(mdnsType)
|| lmsNames.some((needleArg) => haystack.includes(needleArg));
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has LMS/Squeezebox metadata.' : 'Candidate is not LMS/Squeezebox.',
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || squeezeboxDefaultHttpPort}` : undefined),
candidate: matched ? { ...candidateArg, port: candidateArg.port || squeezeboxDefaultHttpPort } : undefined,
};
}
}
export const createSqueezeboxDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: squeezeboxDomain, displayName: 'Squeezebox (Lyrion Music Server)' })
.addMatcher(new SqueezeboxMdnsMatcher())
.addMatcher(new SqueezeboxDhcpMatcher())
.addMatcher(new SqueezeboxManualMatcher())
.addValidator(new SqueezeboxCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg
?.replace(/\._squeezebox(?:-jsonrpc|-server)?\._tcp\.local\.?$/i, '')
.replace(/\._lms\._tcp\.local\.?$/i, '')
.replace(/\._slimserver\._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 numberString = (valueArg: string | undefined): number | undefined => {
if (!valueArg) {
return undefined;
}
const value = Number(valueArg);
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
};
const normalizeMac = (valueArg: string | undefined): string => (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');
@@ -0,0 +1,380 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { ISqueezeboxFavorite, ISqueezeboxPlayer, ISqueezeboxServerInfo, ISqueezeboxSnapshot, ISqueezeboxSyncGroup, ISqueezeboxTrack } from './squeezebox.types.js';
export class SqueezeboxMapper {
public static toDevices(snapshotArg: ISqueezeboxSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.serverDeviceId(snapshotArg),
integrationDomain: 'squeezebox',
name: this.serverName(snapshotArg.server),
protocol: 'http',
manufacturer: snapshotArg.server.manufacturer || 'Lyrion',
model: snapshotArg.server.model || 'Lyrion Music Server',
online: snapshotArg.online,
features: [
{ id: 'players', capability: 'sensor', name: 'Players', readable: true, writable: false },
{ id: 'favorites', capability: 'media', name: 'Favorites', readable: true, writable: false },
{ id: 'sync_groups', capability: 'media', name: 'Sync groups', readable: true, writable: true },
{ id: 'library_songs', capability: 'sensor', name: 'Library songs', readable: true, writable: false },
{ id: 'rescan', capability: 'sensor', name: 'Library rescan', readable: true, writable: false },
],
state: [
{ featureId: 'players', value: snapshotArg.server.playerCount ?? snapshotArg.players.length, updatedAt },
{ featureId: 'favorites', value: snapshotArg.favorites?.length || 0, updatedAt },
{ featureId: 'sync_groups', value: this.syncGroups(snapshotArg).length, updatedAt },
{ featureId: 'library_songs', value: snapshotArg.server.stats?.totalSongs ?? null, updatedAt },
{ featureId: 'rescan', value: snapshotArg.server.rescan ?? null, updatedAt },
],
metadata: {
uuid: snapshotArg.server.uuid,
mac: snapshotArg.server.mac,
version: snapshotArg.server.version,
host: snapshotArg.server.host,
port: snapshotArg.server.port,
source: snapshotArg.source,
},
}];
for (const player of snapshotArg.players) {
devices.push({
id: this.playerDeviceId(player),
integrationDomain: 'squeezebox',
name: player.name,
protocol: 'http',
manufacturer: player.manufacturer || player.creator || 'Logitech',
model: player.model || 'Squeezebox Player',
online: this.playerAvailable(player),
features: [
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'power', capability: 'switch', name: 'Power', 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: 'sync_group', capability: 'media', name: 'Sync group', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
],
state: [
{ featureId: 'playback', value: this.mediaState(player), updatedAt },
{ featureId: 'power', value: player.power ?? null, updatedAt },
{ featureId: 'volume', value: this.volumePercent(player) ?? null, updatedAt },
{ featureId: 'muted', value: player.muting ?? null, updatedAt },
{ featureId: 'source', value: this.currentSource(snapshotArg, player) || null, updatedAt },
{ featureId: 'sync_group', value: this.groupForPlayer(snapshotArg, player)?.id || null, updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(player) || null, updatedAt },
],
metadata: {
playerId: player.playerId,
uuid: player.uuid,
firmware: player.firmware,
ipAddress: player.ipAddress,
connected: player.connected,
modelType: player.modelType,
viaDeviceId: this.serverDeviceId(snapshotArg),
},
});
}
return devices;
}
public static toEntities(snapshotArg: ISqueezeboxSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const sourceList = this.sourceList(snapshotArg);
for (const player of snapshotArg.players) {
const base = this.playerEntityBase(player);
const group = this.groupForPlayer(snapshotArg, player);
const available = this.playerAvailable(player);
entities.push({
id: this.playerEntityId(player),
uniqueId: `squeezebox_${this.slug(player.playerId)}`,
integrationDomain: 'squeezebox',
deviceId: this.playerDeviceId(player),
platform: 'media_player',
name: player.name,
state: this.mediaState(player),
attributes: {
deviceClass: 'speaker',
playerId: player.playerId,
uuid: player.uuid,
model: player.model,
modelType: player.modelType,
firmware: player.firmware,
ipAddress: player.ipAddress,
connected: player.connected,
power: player.power,
volumeLevel: this.volumeLevel(player),
volume: this.volumePercent(player),
isVolumeMuted: player.muting,
source: this.currentSource(snapshotArg, player),
sourceList,
repeat: this.repeatMode(player),
shuffle: player.shuffle === 'song',
shuffleMode: player.shuffle,
mediaContentId: player.url,
mediaContentType: player.playlist && player.playlist.length > 1 ? 'playlist' : 'music',
mediaDuration: player.duration,
mediaPosition: player.time,
mediaImageUrl: player.imageUrl,
mediaTitle: player.title,
mediaChannel: player.remoteTitle,
mediaArtist: player.artist,
mediaAlbumName: player.album,
currentIndex: player.currentIndex,
playlist: player.playlist,
syncGroupId: group?.id,
groupMembers: this.groupMembers(snapshotArg, player),
alarmsEnabled: player.alarmsEnabled,
alarmNext: player.alarmNext,
},
available,
});
entities.push({
id: `sensor.${base}_squeezebox_media`,
uniqueId: `squeezebox_${this.slug(player.playerId)}_media`,
integrationDomain: 'squeezebox',
deviceId: this.playerDeviceId(player),
platform: 'sensor',
name: `${player.name} Squeezebox Media`,
state: this.mediaTitle(player) || 'None',
attributes: {
playerId: player.playerId,
url: player.url,
title: player.title,
remoteTitle: player.remoteTitle,
artist: player.artist,
album: player.album,
playlist: player.playlist,
},
available,
});
if (player.alarms?.length) {
entities.push({
id: `sensor.${base}_squeezebox_alarms`,
uniqueId: `squeezebox_${this.slug(player.playerId)}_alarms`,
integrationDomain: 'squeezebox',
deviceId: this.playerDeviceId(player),
platform: 'sensor',
name: `${player.name} Squeezebox Alarms`,
state: player.alarms.length,
attributes: { playerId: player.playerId, alarms: player.alarms },
available,
});
}
}
entities.push({
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_favorites`,
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_favorites`,
integrationDomain: 'squeezebox',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.serverName(snapshotArg.server)} Favorites`,
state: snapshotArg.favorites?.length || 0,
attributes: {
favorites: snapshotArg.favorites || [],
sourceList,
},
available: snapshotArg.online,
});
entities.push({
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_sync_groups`,
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_sync_groups`,
integrationDomain: 'squeezebox',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.serverName(snapshotArg.server)} Sync Groups`,
state: this.syncGroups(snapshotArg).length,
attributes: {
syncGroups: this.syncGroups(snapshotArg).map((groupArg) => ({
...groupArg,
members: groupArg.playerIds.map((playerIdArg) => {
const player = snapshotArg.players.find((itemArg) => itemArg.playerId === playerIdArg);
return player ? this.playerEntityId(player) : undefined;
}).filter((valueArg): valueArg is string => Boolean(valueArg)),
})),
},
available: snapshotArg.online,
});
entities.push({
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_server_status`,
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_server_status`,
integrationDomain: 'squeezebox',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.serverName(snapshotArg.server)} Server Status`,
state: snapshotArg.online ? 'online' : 'offline',
attributes: {
version: snapshotArg.server.version,
playerCount: snapshotArg.server.playerCount ?? snapshotArg.players.length,
otherPlayerCount: snapshotArg.server.otherPlayerCount,
stats: snapshotArg.server.stats,
rescan: snapshotArg.server.rescan,
needsRestart: snapshotArg.server.needsRestart,
error: snapshotArg.error,
},
available: true,
});
if (snapshotArg.server.rescan !== undefined) {
entities.push({
id: `binary_sensor.${this.slug(this.serverName(snapshotArg.server))}_library_rescan`,
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_library_rescan`,
integrationDomain: 'squeezebox',
deviceId: this.serverDeviceId(snapshotArg),
platform: 'binary_sensor',
name: `${this.serverName(snapshotArg.server)} Library Rescan`,
state: snapshotArg.server.rescan ? 'on' : 'off',
attributes: {},
available: snapshotArg.online,
});
}
return entities;
}
public static entityPlayerId(snapshotArg: ISqueezeboxSnapshot, entityIdArg: string): string | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
const playerId = entity?.attributes?.playerId;
return typeof playerId === 'string' ? playerId : undefined;
}
public static playerEntityId(playerArg: ISqueezeboxPlayer): string {
return `media_player.${this.playerEntityBase(playerArg)}`;
}
public static playerDeviceId(playerArg: ISqueezeboxPlayer): string {
return `squeezebox.player.${this.slug(playerArg.playerId)}`;
}
public static serverDeviceId(snapshotArg: ISqueezeboxSnapshot): string {
return `squeezebox.server.${this.serverUniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'squeezebox').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'squeezebox';
}
private static playerAvailable(playerArg: ISqueezeboxPlayer): boolean {
return playerArg.available !== false && playerArg.connected !== false;
}
private static mediaState(playerArg: ISqueezeboxPlayer): string {
if (!this.playerAvailable(playerArg) || playerArg.power === false) {
return 'off';
}
if (playerArg.mode === 'play') {
return 'playing';
}
if (playerArg.mode === 'pause') {
return 'paused';
}
if (playerArg.mode === 'stop') {
return 'idle';
}
return playerArg.mode || 'unknown';
}
private static repeatMode(playerArg: ISqueezeboxPlayer): 'off' | 'one' | 'all' | undefined {
if (!playerArg.repeat) {
return undefined;
}
if (playerArg.repeat === 'song') {
return 'one';
}
if (playerArg.repeat === 'playlist') {
return 'all';
}
return 'off';
}
private static mediaTitle(playerArg: ISqueezeboxPlayer): string | undefined {
return playerArg.title || playerArg.remoteTitle || playerArg.playlist?.[playerArg.currentIndex || 0]?.title;
}
private static volumePercent(playerArg: ISqueezeboxPlayer): number | undefined {
return typeof playerArg.volume === 'number' ? Math.max(0, Math.min(100, Math.round(playerArg.volume))) : undefined;
}
private static volumeLevel(playerArg: ISqueezeboxPlayer): number | undefined {
const volume = this.volumePercent(playerArg);
return typeof volume === 'number' ? volume / 100 : undefined;
}
private static currentSource(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): string | undefined {
if (playerArg.source) {
return playerArg.source;
}
if (playerArg.url) {
const favorite = (snapshotArg.favorites || []).find((favoriteArg) => favoriteArg.url === playerArg.url || favoriteArg.itemId === playerArg.url);
if (favorite) {
return favorite.name;
}
if (sourceLike(playerArg.url)) {
return playerArg.url;
}
}
return undefined;
}
private static sourceList(snapshotArg: ISqueezeboxSnapshot): string[] {
const values = [
...(snapshotArg.favorites || []).map((favoriteArg) => favoriteArg.name),
...snapshotArg.players.map((playerArg) => sourceLike(playerArg.url) ? playerArg.url : undefined),
].filter((valueArg): valueArg is string => Boolean(valueArg));
return [...new Set(values)];
}
private static groupForPlayer(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): ISqueezeboxSyncGroup | undefined {
return this.syncGroups(snapshotArg).find((groupArg) => groupArg.playerIds.includes(playerArg.playerId));
}
private static groupMembers(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): string[] | undefined {
const group = this.groupForPlayer(snapshotArg, playerArg);
if (!group) {
return playerArg.syncGroup?.length ? playerArg.syncGroup.map((playerIdArg) => this.playerById(snapshotArg, playerIdArg)).filter((valueArg): valueArg is ISqueezeboxPlayer => Boolean(valueArg)).map((itemArg) => this.playerEntityId(itemArg)) : undefined;
}
return group.playerIds.map((playerIdArg) => this.playerById(snapshotArg, playerIdArg)).filter((valueArg): valueArg is ISqueezeboxPlayer => Boolean(valueArg)).map((itemArg) => this.playerEntityId(itemArg));
}
private static playerById(snapshotArg: ISqueezeboxSnapshot, playerIdArg: string): ISqueezeboxPlayer | undefined {
return snapshotArg.players.find((playerArg) => playerArg.playerId === playerIdArg);
}
private static syncGroups(snapshotArg: ISqueezeboxSnapshot): ISqueezeboxSyncGroup[] {
if (snapshotArg.syncGroups?.length) {
return snapshotArg.syncGroups;
}
const groups = new Map<string, ISqueezeboxSyncGroup>();
for (const player of snapshotArg.players) {
const ids = [player.playerId, ...(player.syncGroup || [])].filter(Boolean).sort();
if (ids.length < 2) {
continue;
}
const key = ids.join(',');
if (!groups.has(key)) {
groups.set(key, { id: `sync_${this.slug(key)}`, playerIds: ids, leaderPlayerId: ids[0] });
}
}
return [...groups.values()];
}
private static playerEntityBase(playerArg: ISqueezeboxPlayer): string {
return this.slug(playerArg.name || playerArg.playerId);
}
private static serverUniqueBase(snapshotArg: ISqueezeboxSnapshot): string {
return this.slug(snapshotArg.server.uuid || snapshotArg.server.id || snapshotArg.server.host || this.serverName(snapshotArg.server));
}
private static serverName(serverArg: ISqueezeboxServerInfo): string {
return serverArg.name || serverArg.libraryName || serverArg.host || 'Lyrion Music Server';
}
}
const sourceLike = (valueArg: string | undefined): valueArg is string => Boolean(valueArg && /^(source|wavin|spotify|loop):/i.test(valueArg));
+261 -3
View File
@@ -1,4 +1,262 @@
export interface IHomeAssistantSqueezeboxConfig {
// TODO: replace with the TypeScript-native config for squeezebox.
[key: string]: unknown;
export const squeezeboxDefaultHttpPort = 9000;
export const squeezeboxDefaultCliPort = 9090;
export const squeezeboxDefaultTimeoutMs = 5000;
export const squeezeboxDefaultBrowseLimit = 1000;
export const squeezeboxDefaultVolumeStep = 5;
export type TSqueezeboxTransport = 'jsonrpc' | 'cli' | 'snapshot';
export type TSqueezeboxSnapshotSource = 'snapshot' | 'jsonrpc' | 'cli' | 'executor' | 'runtime' | 'manual';
export type TSqueezeboxPlaybackMode = 'play' | 'pause' | 'stop' | 'unknown' | (string & {});
export type TSqueezeboxRepeatMode = 'none' | 'song' | 'playlist' | (string & {});
export type TSqueezeboxShuffleMode = 'none' | 'song' | 'album' | (string & {});
export type TSqueezeboxMediaCommand =
| 'play'
| 'pause'
| 'play_pause'
| 'stop'
| 'next_track'
| 'previous_track'
| 'seek'
| 'set_power'
| 'set_volume'
| 'volume_up'
| 'volume_down'
| 'mute'
| 'select_source'
| 'play_media'
| 'sync'
| 'unsync'
| 'raw_query';
export interface ISqueezeboxConfig {
host?: string;
port?: number;
cliPort?: number;
username?: string;
password?: string;
https?: boolean;
name?: string;
serverId?: string;
timeoutMs?: number;
browseLimit?: number;
volumeStep?: number;
transport?: TSqueezeboxTransport;
snapshot?: ISqueezeboxSnapshot;
commandExecutor?: ISqueezeboxCommandExecutor;
}
export interface IHomeAssistantSqueezeboxConfig extends ISqueezeboxConfig {}
export interface ISqueezeboxCommandExecutor {
execute(requestArg: ISqueezeboxRawCommandRequest): Promise<ISqueezeboxJsonRpcResponse | ISqueezeboxCliResponse | Record<string, unknown> | unknown>;
}
export interface ISqueezeboxRawCommandRequest {
transport: Exclude<TSqueezeboxTransport, 'snapshot'>;
host?: string;
port: number;
endpoint?: string;
playerId?: string;
command: string[];
body?: ISqueezeboxJsonRpcRequest;
cliLine?: string;
}
export interface ISqueezeboxJsonRpcRequest {
id: number | string;
method: 'slim.request';
params: [string | 0, string[]];
}
export interface ISqueezeboxJsonRpcError {
code?: number;
message?: string;
data?: unknown;
}
export interface ISqueezeboxJsonRpcResponse<TResult = Record<string, unknown>> {
id?: number | string;
method?: string;
params?: unknown[];
result?: TResult;
error?: ISqueezeboxJsonRpcError | string;
}
export interface ISqueezeboxCliResponse {
playerId?: string;
command: string[];
rawLine: string;
tokens: string[];
result: Record<string, unknown>;
}
export interface ISqueezeboxCommandRequest {
command: TSqueezeboxMediaCommand;
playerId?: string;
targetPlayerId?: string;
playerIds?: string[];
volumeLevel?: number;
volume?: number;
step?: number;
muted?: boolean;
powered?: boolean;
source?: string;
mediaId?: string;
mediaType?: string;
enqueue?: 'play' | 'add' | 'next';
position?: number;
parameters?: Array<string | number | boolean>;
}
export interface ISqueezeboxServerInfo {
id?: string;
uuid?: string;
mac?: string;
name?: string;
libraryName?: string;
host?: string;
port?: number;
version?: string;
manufacturer?: string;
model?: string;
playerCount?: number;
otherPlayerCount?: number;
rescan?: boolean;
needsRestart?: boolean;
stats?: {
totalAlbums?: number;
totalArtists?: number;
totalDuration?: number;
totalGenres?: number;
totalSongs?: number;
lastScan?: string | number;
};
raw?: Record<string, unknown>;
}
export interface ISqueezeboxTrack {
id?: string | number;
url?: string;
title?: string;
artist?: string;
album?: string;
duration?: number;
remoteTitle?: string;
imageUrl?: string;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxAlarm {
id: string;
enabled?: boolean;
time?: string;
repeat?: boolean;
scheduledToday?: boolean;
daysOfWeek?: number[];
volume?: number;
url?: string;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxPlayer {
playerId: string;
uuid?: string;
name: string;
model?: string;
modelType?: string;
manufacturer?: string;
creator?: string;
firmware?: string;
ipAddress?: string;
connected?: boolean;
power?: boolean;
mode?: TSqueezeboxPlaybackMode;
volume?: number;
muting?: boolean;
repeat?: TSqueezeboxRepeatMode;
shuffle?: TSqueezeboxShuffleMode;
time?: number;
duration?: number;
title?: string;
remoteTitle?: string;
artist?: string;
album?: string;
url?: string;
imageUrl?: string;
playlist?: ISqueezeboxTrack[];
currentIndex?: number;
alarms?: ISqueezeboxAlarm[];
alarmsEnabled?: boolean;
alarmNext?: string;
syncGroup?: string[];
source?: string;
available?: boolean;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxFavorite {
id: string;
name: string;
type?: string;
url?: string;
itemId?: string;
imageUrl?: string;
playable?: boolean;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxSyncGroup {
id: string;
name?: string;
playerIds: string[];
leaderPlayerId?: string;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxSnapshot {
server: ISqueezeboxServerInfo;
players: ISqueezeboxPlayer[];
favorites?: ISqueezeboxFavorite[];
syncGroups?: ISqueezeboxSyncGroup[];
online: boolean;
updatedAt?: string;
source?: TSqueezeboxSnapshotSource;
error?: string;
raw?: Record<string, unknown>;
}
export interface ISqueezeboxMdnsRecord {
name?: string;
type?: string;
serviceType?: string;
host?: string;
hostname?: string;
port?: number;
addresses?: string[];
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface ISqueezeboxDhcpRecord {
hostname?: string;
macaddress?: string;
macAddress?: string;
ipAddress?: string;
host?: string;
name?: string;
metadata?: Record<string, unknown>;
}
export interface ISqueezeboxManualEntry {
host?: string;
port?: number;
cliPort?: number;
id?: string;
name?: string;
username?: string;
password?: string;
https?: boolean;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './synology_dsm.classes.integration.js';
export * from './synology_dsm.classes.client.js';
export * from './synology_dsm.classes.configflow.js';
export * from './synology_dsm.discovery.js';
export * from './synology_dsm.mapper.js';
export * from './synology_dsm.types.js';
@@ -0,0 +1,139 @@
import type { ISynologyDsmCommand, ISynologyDsmCommandResult, ISynologyDsmConfig, ISynologyDsmEvent, ISynologyDsmSnapshot } from './synology_dsm.types.js';
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
type TSynologyDsmEventHandler = (eventArg: ISynologyDsmEvent) => void;
export class SynologyDsmClient {
private currentSnapshot?: ISynologyDsmSnapshot;
private readonly eventHandlers = new Set<TSynologyDsmEventHandler>();
constructor(private readonly config: ISynologyDsmConfig) {}
public async getSnapshot(): Promise<ISynologyDsmSnapshot> {
if (this.config.nativeClient) {
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.snapshotProvider) {
const provided = await this.config.snapshotProvider();
if (provided) {
this.currentSnapshot = this.normalizeSnapshot(provided, 'provider');
return this.cloneSnapshot(this.currentSnapshot);
}
}
if (!this.currentSnapshot) {
this.currentSnapshot = SynologyDsmMapper.toSnapshot(this.config, this.config.connected ?? this.config.online ?? this.hasManualData());
if (!this.hasManualData()) {
this.currentSnapshot = {
...this.currentSnapshot,
connected: false,
error: this.config.host
? 'Synology DSM live HTTP API access is not implemented by this dependency-free TypeScript port. Provide nativeClient, snapshotProvider, or snapshot/manual data.'
: 'Synology DSM setup requires a NAS host plus nativeClient/snapshotProvider, or snapshot/manual data.',
};
}
}
return this.cloneSnapshot(this.currentSnapshot);
}
public onEvent(handlerArg: TSynologyDsmEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<ISynologyDsmCommandResult> {
try {
this.currentSnapshot = undefined;
const snapshot = await this.getSnapshot();
const success = snapshot.connected || this.hasManualData() || Boolean(this.config.nativeClient || this.config.snapshotProvider);
this.emit({ type: success ? 'snapshot_refreshed' : 'refresh_failed', snapshot, error: success ? undefined : snapshot.error, timestamp: Date.now() });
return success ? { success: true, data: snapshot } : { success: false, error: snapshot.error, data: snapshot };
} catch (errorArg) {
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
const snapshot = SynologyDsmMapper.toSnapshot({ ...this.config, snapshot: this.currentSnapshot, online: false }, false);
this.currentSnapshot = { ...snapshot, error };
this.emit({ type: 'refresh_failed', snapshot: this.currentSnapshot, error, timestamp: Date.now() });
return { success: false, error, data: this.cloneSnapshot(this.currentSnapshot) };
}
}
public async sendCommand(commandArg: ISynologyDsmCommand): Promise<ISynologyDsmCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
if (!executor) {
const result: ISynologyDsmCommandResult = {
success: false,
error: this.unsupportedCommandMessage(commandArg),
data: { command: commandArg },
};
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
try {
const result = this.commandResult(await executor(commandArg), commandArg);
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
} catch (errorArg) {
const result: ISynologyDsmCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
return result;
}
}
public async destroy(): Promise<void> {
await this.config.nativeClient?.destroy?.();
this.eventHandlers.clear();
}
private normalizeSnapshot(snapshotArg: ISynologyDsmSnapshot, sourceArg: ISynologyDsmSnapshot['source']): ISynologyDsmSnapshot {
const normalized = SynologyDsmMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
return { ...normalized, source: snapshotArg.source || sourceArg };
}
private hasManualData(): boolean {
return Boolean(
this.config.snapshot
|| this.config.system
|| this.config.information
|| this.config.utilization
|| this.config.storage
|| this.config.network
|| this.config.volumes?.length
|| this.config.disks?.length
|| this.config.cameras?.length
|| this.config.switches
|| this.config.update
|| this.config.security
);
}
private commandResult(resultArg: unknown, commandArg: ISynologyDsmCommand): ISynologyDsmCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is ISynologyDsmCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private unsupportedCommandMessage(commandArg: ISynologyDsmCommand): string {
const action = commandArg.action.replace(/_/g, ' ');
return `Synology DSM live ${action} command execution is not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live DSM system, Surveillance Station switch, or camera actions.`;
}
private emit(eventArg: ISynologyDsmEvent): void {
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot<T extends ISynologyDsmSnapshot>(snapshotArg: T): T {
return JSON.parse(JSON.stringify(snapshotArg)) as T;
}
}
@@ -0,0 +1,141 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
import type { ISynologyDsmConfig, ISynologyDsmSnapshot } from './synology_dsm.types.js';
import { synologyDsmDefaultSnapshotQuality, synologyDsmDefaultSsl, synologyDsmDefaultTimeoutMs, synologyDsmDefaultVerifySsl } from './synology_dsm.types.js';
export class SynologyDsmConfigFlow implements IConfigFlow<ISynologyDsmConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISynologyDsmConfig>> {
void contextArg;
const metadata = candidateArg.metadata || {};
const ssl = this.booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
return {
kind: 'form',
title: 'Connect Synology DSM',
description: 'Provide the local DSM endpoint. Snapshot/manual data and injected native clients are supported directly; live DSM API success is not assumed without a native client or snapshot provider.',
fields: [
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
{ name: 'port', label: `Port (${candidateArg.port || SynologyDsmMapper.defaultPort(ssl)})`, type: 'number' },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'username', label: 'Username', type: 'text' },
{ name: 'password', label: 'Password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'serial', label: 'Serial', type: 'text' },
{ name: 'snapshotQuality', label: 'Surveillance Station snapshot quality', type: 'select', options: [
{ label: 'Low', value: '0' },
{ label: 'Balanced', value: '1' },
{ label: 'High', value: '2' },
] },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<ISynologyDsmConfig>> {
const metadata = candidateArg.metadata || {};
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || metadata.snapshot);
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid Synology DSM snapshot', error: snapshot.message };
}
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.system.host;
if (!host && !snapshot) {
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Synology DSM setup requires a host or snapshot JSON.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
if (!this.validPort(port)) {
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Synology DSM port must be between 1 and 65535.' };
}
const username = this.stringValue(valuesArg.username) || this.stringValue(metadata.username);
const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password);
if (password && !username) {
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Username is required when a password is provided.' };
}
const config: ISynologyDsmConfig = {
host,
port,
ssl,
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? snapshot?.system.verifySsl ?? synologyDsmDefaultVerifySsl,
username,
password,
timeoutMs: synologyDsmDefaultTimeoutMs,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.system.name || snapshot?.system.hostname || host,
serial: this.stringValue(valuesArg.serial) || candidateArg.serialNumber || snapshot?.system.serial,
uniqueId: candidateArg.id || snapshot?.system.serial || candidateArg.serialNumber || candidateArg.macAddress || (host ? `${host}:${port}` : undefined),
macAddress: candidateArg.macAddress,
macs: snapshot?.system.macs,
snapshotQuality: this.numberValue(valuesArg.snapshotQuality) ?? synologyDsmDefaultSnapshotQuality,
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: metadata,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
};
return {
kind: 'done',
title: 'Synology DSM configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): ISynologyDsmSnapshot | undefined | Error {
if (valueArg && typeof valueArg === 'object') {
return valueArg as ISynologyDsmSnapshot;
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as ISynologyDsmSnapshot;
if (!parsed || typeof parsed !== 'object' || !parsed.system) {
return new Error('Snapshot JSON must include a system object.');
}
return parsed;
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
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 booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
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 undefined;
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
@@ -1,29 +1,109 @@
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 { SynologyDsmClient } from './synology_dsm.classes.client.js';
import { SynologyDsmConfigFlow } from './synology_dsm.classes.configflow.js';
import { createSynologyDsmDiscoveryDescriptor } from './synology_dsm.discovery.js';
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
import type { ISynologyDsmConfig } from './synology_dsm.types.js';
import { synologyDsmDomain } from './synology_dsm.types.js';
export class HomeAssistantSynologyDsmIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "synology_dsm",
displayName: "Synology DSM",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/synology_dsm",
"upstreamDomain": "synology_dsm",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"py-synologydsm-api==2.7.3"
],
"dependencies": [
"http"
],
"afterDependencies": [],
"codeowners": [
"@Quentame",
"@mib1185"
]
},
});
export class SynologyDsmIntegration extends BaseIntegration<ISynologyDsmConfig> {
public readonly domain = synologyDsmDomain;
public readonly displayName = 'Synology DSM';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createSynologyDsmDiscoveryDescriptor();
public readonly configFlow = new SynologyDsmConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/synology_dsm',
upstreamDomain: synologyDsmDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['py-synologydsm-api==2.7.3'],
dependencies: ['http'],
afterDependencies: [] as string[],
codeowners: ['@Quentame', '@mib1185'],
documentation: 'https://www.home-assistant.io/integrations/synology_dsm',
configFlow: true,
runtime: {
mode: 'native TypeScript snapshot/provider Synology DSM mapping',
platforms: ['binary_sensor', 'button', 'camera-metadata', 'sensor', 'switch', 'update'],
services: ['snapshot', 'status', 'refresh', 'reboot', 'shutdown', 'set_home_mode', 'camera.enable_motion_detection', 'camera.disable_motion_detection'],
},
discovery: {
manual: true,
ssdp: 'Synology Basic:1 SSDP advertisements from the Home Assistant manifest are recognized.',
zeroconf: 'Synology _http._tcp.local zeroconf advertisements with vendor metadata are recognized.',
http: 'Manual/local DSM HTTP endpoint candidates are recognized; no active LAN scan is performed.',
},
localApi: {
implemented: [
'manual/local NAS candidates and config flow',
'snapshot/native-client mapping for DSM system, utilization, network, storage, volume, disk, security, Surveillance Station camera, home mode, and DSM update data',
'safe command modeling for reboot, shutdown, Surveillance Station home mode, and camera motion detection actions represented by the snapshot',
],
explicitUnsupported: [
'homeassistant_compat shims',
'fake Synology DSM HTTP/API login, polling, or command success without nativeClient, snapshotProvider, or commandExecutor injection',
'full py-synologydsm-api live protocol parity in dependency-free TypeScript',
'native camera image entity streaming because the current integration entity platform model has no camera entity type',
],
},
};
public async setup(configArg: ISynologyDsmConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SynologyDsmRuntime(new SynologyDsmClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantSynologyDsmIntegration extends SynologyDsmIntegration {}
class SynologyDsmRuntime implements IIntegrationRuntime {
public domain = synologyDsmDomain;
constructor(private readonly client: SynologyDsmClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return SynologyDsmMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return SynologyDsmMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg({
type: eventArg.type === 'command_failed' || eventArg.type === 'refresh_failed' ? 'error' : 'state_changed',
integrationDomain: synologyDsmDomain,
deviceId: eventArg.deviceId,
entityId: eventArg.entityId,
data: eventArg,
timestamp: eventArg.timestamp || Date.now(),
}));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === synologyDsmDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === synologyDsmDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = SynologyDsmMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Synology DSM service mapping: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,371 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
import type { ISynologyDsmHttpDiscoveryRecord, ISynologyDsmManualDiscoveryRecord, ISynologyDsmMdnsDiscoveryRecord, ISynologyDsmSnapshot, ISynologyDsmSsdpDiscoveryRecord } from './synology_dsm.types.js';
import { synologyDsmDefaultSsl, synologyDsmDomain } from './synology_dsm.types.js';
const synologyTextHints = ['synology', 'diskstation', 'rackstation', 'dsm', 'surveillance station'];
const synologyMdnsType = '_http._tcp.local.';
const synologySsdpDeviceType = 'urn:schemas-upnp-org:device:Basic:1';
export class SynologyDsmManualMatcher implements IDiscoveryMatcher<ISynologyDsmManualDiscoveryRecord> {
public id = 'synology-dsm-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Synology DSM local NAS setup entries and snapshot-only records.';
public async matches(inputArg: ISynologyDsmManualDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const parsedUrl = parseUrl(inputArg.url);
const snapshot = inputArg.snapshot || metadata.snapshot as ISynologyDsmSnapshot | undefined;
const host = inputArg.host || parsedUrl?.host || snapshot?.system.host;
const text = textValue(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.system.name, snapshot?.system.model);
const matched = inputArg.integrationDomain === synologyDsmDomain
|| metadata.synologyDsm === true
|| Boolean(snapshot)
|| Boolean(host)
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Synology DSM setup hints.' };
}
const ssl = booleanValue(inputArg.ssl) ?? parsedUrl?.ssl ?? booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
const port = inputArg.port || parsedUrl?.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
const serial = inputArg.serialNumber || snapshot?.system.serial;
const mac = SynologyDsmMapper.normalizeMac(inputArg.macAddress || snapshot?.system.macs?.[0]);
const id = inputArg.id || serial || mac || snapshot?.system.id || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: snapshot || serial || mac ? 'certain' : host ? 'high' : 'medium',
reason: snapshot ? 'Manual entry includes a Synology DSM snapshot.' : 'Manual entry can start Synology DSM setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: synologyDsmDomain,
id,
host,
port,
name: inputArg.name || snapshot?.system.name || snapshot?.system.hostname || host || 'Synology DSM',
manufacturer: inputArg.manufacturer || 'Synology',
model: inputArg.model || snapshot?.system.model || 'DSM NAS',
serialNumber: serial,
macAddress: mac,
metadata: {
...metadata,
synologyDsm: true,
ssl,
verifySsl: inputArg.verifySsl ?? metadata.verifySsl,
username: inputArg.username ?? metadata.username,
password: inputArg.password ?? metadata.password,
snapshot,
hasSnapshot: Boolean(snapshot),
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
},
metadata: { ssl, hasSnapshot: Boolean(snapshot), upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
};
}
}
export class SynologyDsmHttpMatcher implements IDiscoveryMatcher<ISynologyDsmHttpDiscoveryRecord> {
public id = 'synology-dsm-http-match';
public source = 'http' as const;
public description = 'Recognize local HTTP candidates that look like DSM web/API endpoints.';
public async matches(inputArg: ISynologyDsmHttpDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const url = inputArg.url || inputArg.location;
const parsedUrl = parseUrl(url);
const headers = normalizeKeys(inputArg.headers || {});
const host = inputArg.host || parsedUrl?.host;
const port = inputArg.port || parsedUrl?.port;
const text = textValue(url, inputArg.name, inputArg.manufacturer, inputArg.model, headers.server, headers['x-powered-by'], metadata.name, metadata.manufacturer, metadata.model);
const matched = inputArg.ssl !== undefined
|| metadata.synologyDsm === true
|| port === 5000
|| port === 5001
|| Boolean(parsedUrl?.path?.includes('/webapi/'))
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Synology DSM endpoint.' };
}
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanValue(metadata.ssl) ?? (port === 5000 ? false : synologyDsmDefaultSsl);
const resolvedPort = port || SynologyDsmMapper.defaultPort(ssl);
const id = host ? `${host}:${resolvedPort}` : undefined;
return {
matched: true,
confidence: host && (parsedUrl?.path?.includes('/webapi/') || port === 5000 || port === 5001) ? 'high' : host ? 'medium' : 'low',
reason: 'HTTP candidate has Synology DSM endpoint hints.',
normalizedDeviceId: id,
candidate: {
source: 'http',
integrationDomain: synologyDsmDomain,
id,
host,
port: resolvedPort,
name: inputArg.name || 'Synology DSM',
manufacturer: inputArg.manufacturer || 'Synology',
model: inputArg.model || 'DSM NAS',
metadata: {
...metadata,
synologyDsm: true,
ssl,
url,
headers,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
};
}
}
export class SynologyDsmSsdpMatcher implements IDiscoveryMatcher<ISynologyDsmSsdpDiscoveryRecord> {
public id = 'synology-dsm-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Home Assistant supported Synology SSDP advertisements.';
public async matches(inputArg: ISynologyDsmSsdpDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const upnp = inputArg.upnp || {};
const location = stringValue(inputArg.ssdpLocation || inputArg.ssdp_location || inputArg.location || metadata.ssdpLocation || metadata.location);
const host = inputArg.host || hostFromUrl(location);
const st = stringValue(inputArg.st || metadata.st || metadata.ssdpSt);
const friendlyName = firstString(upnp.friendlyName, upnp.FriendlyName, upnp.friendly_name, upnp['upnp:ATTR_UPNP_FRIENDLY_NAME'], metadata.friendlyName, inputArg.name);
const modelName = firstString(upnp.modelName, upnp.ModelName, upnp.model_name, metadata.modelName, inputArg.model);
const manufacturerName = firstString(upnp.manufacturer, upnp.Manufacturer, metadata.manufacturer, inputArg.manufacturer);
const serial = firstString(upnp.serialNumber, upnp.SerialNumber, upnp.serial, upnp['upnp:ATTR_UPNP_SERIAL'], metadata.serialNumber, inputArg.serialNumber);
const text = textValue(inputArg.manufacturer, inputArg.model, inputArg.name, friendlyName, modelName, manufacturerName, st);
const matched = inputArg.manufacturer === 'Synology'
|| manufacturerName?.toLowerCase() === 'synology'
|| metadata.synologyDsm === true
|| st === synologySsdpDeviceType
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
if (!matched || !host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Synology SSDP advertisement lacks a usable host.' : 'SSDP advertisement is not Synology DSM.',
};
}
const ssl = booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
const port = inputArg.port || portFromUrl(location) || SynologyDsmMapper.defaultPort(ssl);
const mac = SynologyDsmMapper.normalizeMac(serial);
const id = inputArg.serialNumber || mac || inputArg.usn || `${host}:${port}`;
return {
matched: true,
confidence: manufacturerName?.toLowerCase() === 'synology' || serial ? 'certain' : 'high',
reason: 'SSDP advertisement matches Synology DSM support from Home Assistant.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: synologyDsmDomain,
id,
host,
port,
name: inputArg.name || friendlyName?.split('(', 1)[0]?.trim() || modelName || 'Synology DSM',
manufacturer: inputArg.manufacturer || manufacturerName || 'Synology',
model: inputArg.model || modelName || 'DSM NAS',
serialNumber: inputArg.serialNumber || serial,
macAddress: mac,
metadata: {
...metadata,
synologyDsm: true,
ssl,
ssdpSt: st,
ssdpLocation: location,
upnp,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
};
}
}
export class SynologyDsmMdnsMatcher implements IDiscoveryMatcher<ISynologyDsmMdnsDiscoveryRecord> {
public id = 'synology-dsm-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Home Assistant supported Synology zeroconf/mDNS HTTP advertisements.';
public async matches(inputArg: ISynologyDsmMdnsDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const properties = { ...(inputArg.properties || {}), ...(inputArg.txt || {}) };
const type = stringValue(inputArg.type || inputArg.serviceType || metadata.serviceType);
const vendor = stringValue(properties.vendor || metadata.vendor);
const macs = stringValue(properties.mac_address || properties.macAddress || metadata.macAddress)?.split('|').map((valueArg) => SynologyDsmMapper.normalizeMac(valueArg)).filter((valueArg): valueArg is string => Boolean(valueArg)) || [];
const text = textValue(inputArg.name, inputArg.fullname, inputArg.manufacturer, inputArg.model, vendor, metadata.name, metadata.manufacturer, metadata.model);
const matched = metadata.synologyDsm === true
|| vendor?.toLowerCase().startsWith('synology')
|| type === synologyMdnsType && synologyTextHints.some((hintArg) => text.includes(hintArg));
if (!matched || !inputArg.host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Synology mDNS advertisement lacks a usable host.' : 'mDNS advertisement is not Synology DSM.',
};
}
const ssl = booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
const port = inputArg.port || SynologyDsmMapper.defaultPort(ssl);
const name = inputArg.name?.replace(/\._http\._tcp\.local\.?$/i, '') || inputArg.fullname?.replace(/\._http\._tcp\.local\.?$/i, '') || 'Synology DSM';
const id = inputArg.serialNumber || macs[0] || `${inputArg.host}:${port}`;
return {
matched: true,
confidence: macs.length ? 'certain' : 'high',
reason: 'mDNS/zeroconf advertisement contains Synology vendor metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: synologyDsmDomain,
id,
host: inputArg.host,
port,
name,
manufacturer: inputArg.manufacturer || 'Synology',
model: inputArg.model || 'DSM NAS',
serialNumber: inputArg.serialNumber,
macAddress: inputArg.macAddress || macs[0],
metadata: {
...metadata,
synologyDsm: true,
ssl,
vendor,
macs,
properties,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
};
}
}
export class SynologyDsmCandidateValidator implements IDiscoveryValidator {
public id = 'synology-dsm-candidate-validator';
public description = 'Validate Synology DSM candidates have a local host or snapshot plus Synology identity metadata.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as ISynologyDsmSnapshot | undefined;
const text = textValue(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.vendor, metadata.ssdpSt);
const matched = candidateArg.integrationDomain === synologyDsmDomain
|| metadata.synologyDsm === true
|| Boolean(snapshot)
|| candidateArg.port === 5000
|| candidateArg.port === 5001
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
const hasSource = Boolean(candidateArg.host || snapshot);
if (!matched || !hasSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Synology DSM candidate lacks host or snapshot information.' : 'Candidate is not Synology DSM.',
};
}
const ssl = booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
const port = candidateArg.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
const mac = SynologyDsmMapper.normalizeMac(candidateArg.macAddress || snapshot?.system.macs?.[0]);
const normalizedDeviceId = candidateArg.id || snapshot?.system.serial || candidateArg.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.system.id);
return {
matched: true,
confidence: snapshot || candidateArg.serialNumber || mac ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Synology DSM metadata and a usable local source.',
normalizedDeviceId,
candidate: {
...candidateArg,
id: candidateArg.id || normalizedDeviceId,
integrationDomain: synologyDsmDomain,
port,
macAddress: mac || candidateArg.macAddress,
metadata: {
...metadata,
synologyDsm: true,
ssl,
upstreamSupportsSsdp: true,
upstreamSupportsZeroconf: true,
liveHttpImplemented: false,
},
},
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
};
}
}
export const createSynologyDsmDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: synologyDsmDomain, displayName: 'Synology DSM' })
.addMatcher(new SynologyDsmManualMatcher())
.addMatcher(new SynologyDsmHttpMatcher())
.addMatcher(new SynologyDsmSsdpMatcher())
.addMatcher(new SynologyDsmMdnsMatcher())
.addValidator(new SynologyDsmCandidateValidator());
};
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; path: string } | undefined => {
if (!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 hostFromUrl = (valueArg: string | undefined): string | undefined => parseUrl(valueArg)?.host;
const portFromUrl = (valueArg: string | undefined): number | undefined => parseUrl(valueArg)?.port;
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 firstString = (...valuesArg: unknown[]): string | undefined => valuesArg.find((valueArg): valueArg is string => typeof valueArg === 'string' && Boolean(valueArg.trim()))?.trim();
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
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 undefined;
};
const textValue = (...valuesArg: unknown[]): string => valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,319 @@
export interface IHomeAssistantSynologyDsmConfig {
// TODO: replace with the TypeScript-native config for synology_dsm.
export const synologyDsmDomain = 'synology_dsm';
export const synologyDsmDefaultPort = 5000;
export const synologyDsmDefaultSslPort = 5001;
export const synologyDsmDefaultSsl = true;
export const synologyDsmDefaultVerifySsl = false;
export const synologyDsmDefaultTimeoutMs = 60000;
export const synologyDsmDefaultSnapshotQuality = 1;
export type TSynologyDsmSnapshotSource = 'manual' | 'snapshot' | 'provider' | 'runtime';
export type TSynologyDsmCommandAction =
| 'reboot'
| 'shutdown'
| 'set_home_mode'
| 'enable_camera_motion_detection'
| 'disable_camera_motion_detection';
export type TSynologyDsmCommandType = 'system.action' | 'switch.set' | 'camera.action';
export type TSynologyDsmActionTarget = 'system' | 'switch' | 'camera';
export interface ISynologyDsmConfig {
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
username?: string;
password?: string;
timeoutMs?: number;
name?: string;
uniqueId?: string;
serial?: string;
macs?: string[];
macAddress?: string;
snapshotQuality?: number;
snapshot?: ISynologyDsmSnapshot;
snapshotProvider?: () => Promise<ISynologyDsmSnapshot | undefined>;
nativeClient?: ISynologyDsmNativeClient;
commandExecutor?: (commandArg: ISynologyDsmCommand) => Promise<ISynologyDsmCommandResult | unknown>;
online?: boolean;
connected?: boolean;
system?: Partial<ISynologyDsmSystemInfo> & Record<string, unknown>;
information?: Record<string, unknown>;
utilization?: ISynologyDsmUtilizationInfo & Record<string, unknown>;
storage?: Partial<ISynologyDsmStorageInfo> & Record<string, unknown>;
volumes?: Array<ISynologyDsmVolumeInfo | Record<string, unknown> | string>;
disks?: Array<ISynologyDsmDiskInfo | Record<string, unknown> | string>;
network?: ISynologyDsmNetworkInfo & Record<string, unknown>;
cameras?: Array<ISynologyDsmCameraInfo | Record<string, unknown>>;
surveillance?: Record<string, unknown>;
update?: ISynologyDsmUpdateInfo & Record<string, unknown>;
switches?: Array<ISynologyDsmSwitchInfo | Record<string, unknown>> | Record<string, unknown>;
security?: ISynologyDsmSecurityInfo & Record<string, unknown>;
actions?: ISynologyDsmActionDescriptor[];
events?: ISynologyDsmEvent[];
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantSynologyDsmConfig extends ISynologyDsmConfig {}
export interface ISynologyDsmNativeClient {
getSnapshot(): Promise<ISynologyDsmSnapshot>;
executeCommand?(commandArg: ISynologyDsmCommand): Promise<ISynologyDsmCommandResult | unknown>;
destroy?(): Promise<void>;
}
export interface ISynologyDsmSnapshot {
connected: boolean;
source?: TSynologyDsmSnapshotSource;
updatedAt?: string;
system: ISynologyDsmSystemInfo;
utilization: ISynologyDsmUtilizationInfo;
storage: ISynologyDsmStorageInfo;
network: ISynologyDsmNetworkInfo;
cameras: ISynologyDsmCameraInfo[];
update?: ISynologyDsmUpdateInfo;
switches: ISynologyDsmSwitchInfo[];
security?: ISynologyDsmSecurityInfo;
actions: ISynologyDsmActionDescriptor[];
events?: ISynologyDsmEvent[];
error?: string;
metadata?: Record<string, unknown>;
}
export interface ISynologyDsmSystemInfo {
id?: string;
serial?: string;
name?: string;
hostname?: string;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
manufacturer?: string;
model?: string;
version?: string;
versionString?: string;
temperature?: number;
uptimeSeconds?: number;
macs?: string[];
[key: string]: unknown;
}
export interface ISynologyDsmUtilizationInfo {
cpuOtherLoad?: number;
cpuUserLoad?: number;
cpuSystemLoad?: number;
cpuTotalLoad?: number;
cpu1MinLoad?: number;
cpu5MinLoad?: number;
cpu15MinLoad?: number;
memoryRealUsage?: number;
memorySize?: number;
memoryCached?: number;
memoryAvailableSwap?: number;
memoryAvailableReal?: number;
memoryTotalSwap?: number;
memoryTotalReal?: number;
networkUp?: number;
networkDown?: number;
[key: string]: unknown;
}
export interface ISynologyDsmStorageInfo {
volumes: ISynologyDsmVolumeInfo[];
disks: ISynologyDsmDiskInfo[];
[key: string]: unknown;
}
export interface ISynologyDsmVolumeInfo {
id: string;
name?: string;
status?: string;
sizeTotal?: number;
sizeUsed?: number;
percentageUsed?: number;
diskTempAvg?: number;
diskTempMax?: number;
deviceType?: string;
raidType?: string;
[key: string]: unknown;
}
export interface ISynologyDsmDiskInfo {
id: string;
name?: string;
vendor?: string;
model?: string;
firmware?: string;
diskType?: string;
status?: string;
smartStatus?: string;
temperature?: number;
exceedBadSectorThreshold?: boolean;
belowRemainLifeThreshold?: boolean;
[key: string]: unknown;
}
export interface ISynologyDsmNetworkInfo {
hostname?: string;
macs?: string[];
uploadRate?: number;
downloadRate?: number;
interfaces?: ISynologyDsmNetworkInterfaceInfo[];
[key: string]: unknown;
}
export interface ISynologyDsmNetworkInterfaceInfo {
id?: string;
name?: string;
mac?: string;
ipAddress?: string;
ipv6Address?: string;
connected?: boolean;
speedMbps?: number;
[key: string]: unknown;
}
export interface ISynologyDsmCameraInfo {
id: string;
name?: string;
model?: string;
enabled?: boolean;
recording?: boolean;
motionDetectionEnabled?: boolean;
rtsp?: string;
snapshotUrl?: string;
[key: string]: unknown;
}
export interface ISynologyDsmUpdateInfo {
installedVersion?: string;
latestVersion?: string;
updateAvailable?: boolean;
releaseUrl?: string;
availableVersionDetails?: Record<string, unknown>;
[key: string]: unknown;
}
export interface ISynologyDsmSwitchInfo {
key: string;
name?: string;
enabled: boolean;
type?: 'home_mode' | string;
[key: string]: unknown;
}
export interface ISynologyDsmSecurityInfo {
status?: string;
statusByCheck?: Record<string, string>;
[key: string]: unknown;
}
export interface ISynologyDsmActionDescriptor {
target: TSynologyDsmActionTarget;
action: TSynologyDsmCommandAction;
entityId?: string;
deviceId?: string;
cameraId?: string;
switchKey?: string;
[key: string]: unknown;
}
export interface ISynologyDsmCommand {
type: TSynologyDsmCommandType;
service: string;
action: TSynologyDsmCommandAction;
target: {
entityId?: string;
deviceId?: string;
};
serial?: string;
deviceId?: string;
entityId?: string;
cameraId?: string;
switchKey?: string;
payload?: Record<string, unknown>;
snapshotSource?: TSynologyDsmSnapshotSource;
}
export interface ISynologyDsmCommandResult {
success: boolean;
error?: string;
data?: unknown;
}
export interface ISynologyDsmEvent {
type: string;
snapshot?: ISynologyDsmSnapshot;
command?: ISynologyDsmCommand;
data?: unknown;
error?: string;
deviceId?: string;
entityId?: string;
timestamp?: number;
}
export interface ISynologyDsmManualDiscoveryRecord {
integrationDomain?: string;
host?: string;
port?: number;
url?: string;
ssl?: boolean;
verifySsl?: boolean;
username?: string;
password?: string;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
snapshot?: ISynologyDsmSnapshot;
metadata?: Record<string, unknown>;
}
export interface ISynologyDsmHttpDiscoveryRecord {
url?: string;
location?: string;
host?: string;
port?: number;
ssl?: boolean;
name?: string;
manufacturer?: string;
model?: string;
headers?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export interface ISynologyDsmSsdpDiscoveryRecord {
st?: string;
usn?: string;
ssdpLocation?: string;
ssdp_location?: string;
location?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
upnp?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface ISynologyDsmMdnsDiscoveryRecord {
type?: string;
serviceType?: string;
fullname?: string;
name?: string;
host?: string;
port?: number;
properties?: Record<string, unknown>;
txt?: Record<string, unknown>;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}