Add native local media service integrations

This commit is contained in:
2026-05-07 17:23:04 +00:00
parent b51d3fef2a
commit b8f7b9236f
70 changed files with 11517 additions and 170 deletions
+12
View File
@@ -4,7 +4,9 @@ export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/index.js';
import { AdguardIntegration } from './integrations/adguard/index.js';
import { AgentDvrIntegration } from './integrations/agent_dvr/index.js';
import { AirgradientIntegration } from './integrations/airgradient/index.js';
import { AirosIntegration } from './integrations/airos/index.js';
import { AmcrestIntegration } from './integrations/amcrest/index.js';
import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js';
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
@@ -22,8 +24,10 @@ import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BrotherIntegration } from './integrations/brother/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js';
import { ChannelsIntegration } from './integrations/channels/index.js';
import { DaikinIntegration } from './integrations/daikin/index.js';
import { DeconzIntegration } from './integrations/deconz/index.js';
import { DelugeIntegration } from './integrations/deluge/index.js';
import { DenonIntegration } from './integrations/denon/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
@@ -35,10 +39,12 @@ import { DoorbirdIntegration } from './integrations/doorbird/index.js';
import { DsmrIntegration } from './integrations/dsmr/index.js';
import { DunehdIntegration } from './integrations/dunehd/index.js';
import { ElgatoIntegration } from './integrations/elgato/index.js';
import { EmbyIntegration } from './integrations/emby/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js';
import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js';
import { FoscamIntegration } from './integrations/foscam/index.js';
import { FrontierSiliconIntegration } from './integrations/frontier_silicon/index.js';
import { FroniusIntegration } from './integrations/fronius/index.js';
import { FullyKioskIntegration } from './integrations/fully_kiosk/index.js';
import { FritzIntegration } from './integrations/fritz/index.js';
import { GlancesIntegration } from './integrations/glances/index.js';
@@ -102,7 +108,9 @@ import { IntegrationRegistry } from './core/index.js';
export const integrations = [
new AdguardIntegration(),
new AgentDvrIntegration(),
new AirgradientIntegration(),
new AirosIntegration(),
new AmcrestIntegration(),
new AndroidIpWebcamIntegration(),
new AndroidtvIntegration(),
@@ -120,8 +128,10 @@ export const integrations = [
new BrotherIntegration(),
new BroadlinkIntegration(),
new CastIntegration(),
new ChannelsIntegration(),
new DaikinIntegration(),
new DeconzIntegration(),
new DelugeIntegration(),
new DenonIntegration(),
new DenonavrIntegration(),
new DevoloHomeNetworkIntegration(),
@@ -133,10 +143,12 @@ export const integrations = [
new DsmrIntegration(),
new DunehdIntegration(),
new ElgatoIntegration(),
new EmbyIntegration(),
new EsphomeIntegration(),
new ForkedDaapdIntegration(),
new FoscamIntegration(),
new FrontierSiliconIntegration(),
new FroniusIntegration(),
new FullyKioskIntegration(),
new FritzIntegration(),
new GlancesIntegration(),
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,657 @@
import type {
IAgentDvrClientLike,
IAgentDvrCommandRequest,
IAgentDvrCommandResult,
IAgentDvrConfig,
IAgentDvrDeviceData,
IAgentDvrDeviceSnapshot,
IAgentDvrLocation,
IAgentDvrProfile,
IAgentDvrRawDevice,
IAgentDvrServerSnapshot,
IAgentDvrSnapshot,
IAgentDvrSnapshotImage,
TAgentDvrProtocol,
} from './agent_dvr.types.js';
import {
agentDvrCameraTypeId,
agentDvrDefaultPort,
agentDvrDefaultSnapshotTimeoutMs,
agentDvrDefaultTimeoutMs,
} from './agent_dvr.types.js';
export class AgentDvrApiError extends Error {}
export class AgentDvrApiConnectionError extends AgentDvrApiError {}
export class AgentDvrApiCommandError extends AgentDvrApiError {
constructor(public readonly command: IAgentDvrCommandRequest, messageArg: string) {
super(messageArg);
this.name = 'AgentDvrApiCommandError';
}
}
export class AgentDvrClient {
private currentSnapshot?: IAgentDvrSnapshot;
constructor(private readonly config: IAgentDvrConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IAgentDvrSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasLiveTarget()) {
try {
this.currentSnapshot = await this.fetchLiveSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.snapshotFromConfig(this.config.connected ?? false, undefined, this.hasManualData() ? 'manual' : 'offline');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IAgentDvrCommandResult> {
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
return { success: this.currentSnapshot.connected && !this.currentSnapshot.error, transmitted: false, data: { snapshot: this.cloneSnapshot(this.currentSnapshot), source: 'client' } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
this.currentSnapshot = this.offlineSnapshot(error);
return { success: false, transmitted: false, error, data: { snapshot: this.cloneSnapshot(this.currentSnapshot) } };
}
}
if (!this.hasLiveTarget()) {
const snapshot = await this.getSnapshot();
return {
success: false,
transmitted: false,
snapshot,
error: 'Agent DVR refresh requires a configured local HTTP endpoint or injected client.',
data: { snapshot },
} as IAgentDvrCommandResult & { snapshot: IAgentDvrSnapshot };
}
try {
this.currentSnapshot = await this.fetchLiveSnapshot();
return { success: true, transmitted: true, data: { snapshot: this.cloneSnapshot(this.currentSnapshot), source: 'http' } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
this.currentSnapshot = this.offlineSnapshot(error);
return { success: false, transmitted: true, error, data: { snapshot: this.cloneSnapshot(this.currentSnapshot) } };
}
}
public async validateConnection(): Promise<IAgentDvrSnapshot> {
if (!this.hasLiveTarget()) {
throw new AgentDvrApiConnectionError('Agent DVR validation requires a local HTTP endpoint.');
}
return this.fetchLiveSnapshot();
}
public async getSnapshotImage(deviceArg?: IAgentDvrDeviceSnapshot, sizeArg?: string): Promise<IAgentDvrSnapshotImage> {
if (!this.hasLiveTarget()) {
throw new AgentDvrApiConnectionError('Agent DVR image snapshots require a configured local HTTP endpoint.');
}
const oid = deviceArg?.objectId;
const response = await this.request('grab.jpg', this.cleanParams({ oid, size: sizeArg }), this.config.snapshotTimeoutMs || agentDvrDefaultSnapshotTimeoutMs);
if (!response.ok) {
throw new AgentDvrApiConnectionError(`Agent DVR grab.jpg returned HTTP ${response.status}.`);
}
return {
contentType: response.headers.get('content-type') || 'image/jpeg',
data: new Uint8Array(await response.arrayBuffer()),
};
}
public async execute(commandArg: IAgentDvrCommandRequest): Promise<unknown> {
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client && !this.hasLiveTarget()) {
return this.executeWithClient(this.config.client, commandArg);
}
if (commandArg.type === 'refresh' || commandArg.type === 'status') {
const result = await this.refresh();
return commandArg.type === 'status' ? result.data : result;
}
if (commandArg.type === 'stream_source') {
return this.streamSource(commandArg);
}
if (!this.hasLiveTarget()) {
throw new AgentDvrApiConnectionError('Agent DVR commands require config.host, config.url, config.serverUrl, an injected client, or commandExecutor.');
}
if (commandArg.type === 'snapshot_image') {
if (commandArg.filename) {
throw new AgentDvrApiError('Agent DVR snapshot file writes are not implemented; request image data without data.filename.');
}
const snapshot = await this.getSnapshot();
const device = this.findDevice(snapshot, commandArg.objectId, commandArg.objectTypeId);
if ((commandArg.objectId !== undefined || commandArg.objectTypeId !== undefined) && !device) {
throw new AgentDvrApiCommandError(commandArg, 'Agent DVR snapshot target was not found.');
}
const image = await this.getSnapshotImage(device, commandArg.size);
return { ok: true, transmitted: true, contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
if (!commandArg.httpCommands?.length) {
throw new AgentDvrApiCommandError(commandArg, `Unsupported Agent DVR command: ${commandArg.type}`);
}
const responses: unknown[] = [];
for (const httpCommand of commandArg.httpCommands) {
const response = await this.getJson(httpCommand.path, httpCommand.params);
if (this.responseIsFailure(response)) {
throw new AgentDvrApiCommandError(commandArg, `Agent DVR command ${String(httpCommand.params.cmd)} reported failure.`);
}
responses.push(response);
}
this.patchCachedSnapshot(commandArg);
return { ok: true, success: true, transmitted: true, command: commandArg, responses };
}
public async destroy(): Promise<void> {
await this.config.client?.close?.();
}
private async fetchLiveSnapshot(): Promise<IAgentDvrSnapshot> {
const status = await this.commandJson('getStatus');
const [objects, profiles] = await Promise.all([
this.commandJson('getObjects').catch(() => undefined),
this.commandJson('getProfiles').catch(() => undefined),
]);
return this.snapshotFromLiveData(status, objects, profiles);
}
private snapshotFromLiveData(statusArg: Record<string, unknown>, objectsArg?: Record<string, unknown>, profilesArg?: Record<string, unknown>): IAgentDvrSnapshot {
const objectList = Array.isArray(objectsArg?.objectList) ? objectsArg.objectList as IAgentDvrRawDevice[] : [];
const locations = Array.isArray(objectsArg?.locations) ? objectsArg.locations as IAgentDvrLocation[] : this.config.locations || [];
const profiles = this.profilesFromRaw(profilesArg) || this.config.profiles || [];
const server = this.serverFromStatus(statusArg, true, locations, profiles);
return this.normalizeSnapshot({
connected: true,
server,
devices: objectList.map((deviceArg) => this.normalizeDevice(deviceArg, locations, true)),
locations,
profiles,
updatedAt: new Date().toISOString(),
source: 'http',
metadata: this.config.metadata,
}, 'http');
}
private async snapshotFromClient(clientArg: IAgentDvrClientLike): Promise<IAgentDvrSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (this.isSnapshot(result)) {
return this.normalizeSnapshot(result as IAgentDvrSnapshot, 'client');
}
if (result && typeof result === 'object') {
return this.normalizeSnapshot({
...this.snapshotFromConfig(true, undefined, 'client'),
...(result as Partial<IAgentDvrSnapshot>),
}, 'client');
}
}
const status = clientArg.getStatus ? await clientArg.getStatus() : this.config.status || {};
const rawObjects = clientArg.getObjects ? await clientArg.getObjects() : this.config.devices || [];
const rawProfiles = clientArg.getProfiles ? await clientArg.getProfiles() : this.config.profiles || [];
const objects = Array.isArray(rawObjects) ? { objectList: rawObjects, locations: this.config.locations || [] } : rawObjects;
const profiles = Array.isArray(rawProfiles) ? { profiles: rawProfiles } : rawProfiles;
return this.snapshotFromLiveData(status, objects as Record<string, unknown>, profiles as Record<string, unknown>);
}
private snapshotFromConfig(connectedArg: boolean, errorArg?: string, sourceArg: IAgentDvrSnapshot['source'] = 'manual'): IAgentDvrSnapshot {
const locations = this.config.locations || this.config.snapshot?.locations || [];
const profiles = this.config.profiles || this.config.snapshot?.profiles || [];
const status = this.config.status || this.config.snapshot?.server?.rawStatus || {};
const devices = this.config.devices || this.config.snapshot?.devices || [];
return this.normalizeSnapshot({
connected: connectedArg,
server: this.serverFromStatus(status, connectedArg, locations, profiles),
devices: devices.map((deviceArg) => this.normalizeDevice(deviceArg, locations, connectedArg)),
locations,
profiles,
updatedAt: new Date().toISOString(),
source: sourceArg,
error: errorArg,
metadata: this.config.metadata || this.config.snapshot?.metadata,
}, sourceArg);
}
private offlineSnapshot(errorArg: string): IAgentDvrSnapshot {
return this.snapshotFromConfig(false, errorArg, 'offline');
}
private normalizeSnapshot(snapshotArg: IAgentDvrSnapshot, sourceArg: IAgentDvrSnapshot['source'] = snapshotArg.source): IAgentDvrSnapshot {
const locations = snapshotArg.locations || snapshotArg.server.locations || this.config.locations || [];
const profiles = snapshotArg.profiles || this.config.profiles || [];
const connected = Boolean(snapshotArg.connected && snapshotArg.server.available !== false);
const server = {
...this.serverFromStatus(snapshotArg.server.rawStatus || this.config.status || {}, connected, locations, profiles),
...snapshotArg.server,
available: connected,
locations,
};
return {
...snapshotArg,
connected,
server,
devices: (snapshotArg.devices || []).map((deviceArg) => this.normalizeDevice(deviceArg, locations, connected)),
locations,
profiles,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: sourceArg,
};
}
private serverFromStatus(statusArg: Partial<IAgentDvrServerSnapshot> & Record<string, unknown>, connectedArg: boolean, locationsArg: IAgentDvrLocation[], profilesArg: IAgentDvrProfile[]): IAgentDvrServerSnapshot {
const endpoint = this.endpoint();
const name = stringValue(statusArg.name) || this.config.name || this.config.snapshot?.server.name || endpoint.host || 'Agent DVR';
const unique = stringValue(statusArg.unique) || this.config.uniqueId || this.config.snapshot?.server.unique || endpoint.host || 'agent-dvr';
const activeProfile = stringValue(statusArg.activeProfile) || this.activeProfile(profilesArg) || this.config.snapshot?.server.activeProfile || null;
return {
id: unique,
unique,
name,
host: endpoint.host,
port: endpoint.port,
protocol: endpoint.protocol,
url: endpoint.url,
serverUrl: endpoint.url,
version: stringValue(statusArg.version) || this.config.snapshot?.server.version,
remoteAccess: booleanValue(statusArg.remoteAccess) ?? this.config.snapshot?.server.remoteAccess,
deviceCount: numberValue(statusArg.devices) ?? numberValue(statusArg.deviceCount) ?? this.config.snapshot?.server.deviceCount,
armed: booleanValue(statusArg.armed) ?? this.config.snapshot?.server.armed ?? null,
activeProfile,
available: connectedArg,
locations: locationsArg,
rawStatus: statusArg,
metadata: {
...this.config.snapshot?.server.metadata,
...this.config.metadata,
},
};
}
private normalizeDevice(deviceArg: IAgentDvrDeviceSnapshot | IAgentDvrRawDevice, locationsArg: IAgentDvrLocation[], connectedArg: boolean): IAgentDvrDeviceSnapshot {
const raw = (this.isDeviceSnapshot(deviceArg) ? deviceArg.raw || deviceArg : deviceArg) as IAgentDvrRawDevice;
const rawData = { ...(raw.data || {}), ...(this.isDeviceSnapshot(deviceArg) ? deviceArg.data : {}) } as IAgentDvrDeviceData;
const objectId = numberValue(raw.id) ?? numberValue(raw.oid) ?? numberValue(this.isDeviceSnapshot(deviceArg) ? deviceArg.objectId : undefined) ?? 0;
const typeId = numberValue(raw.typeID) ?? numberValue(raw.typeId) ?? numberValue(raw.ot) ?? numberValue(this.isDeviceSnapshot(deviceArg) ? deviceArg.typeId : undefined) ?? agentDvrCameraTypeId;
const locationIndex = numberValue(raw.locationIndex) ?? (this.isDeviceSnapshot(deviceArg) ? deviceArg.locationIndex : undefined);
const location = this.locationName(locationsArg, locationIndex) || (this.isDeviceSnapshot(deviceArg) ? deviceArg.location : undefined);
const online = booleanValue(rawData.online) ?? connectedArg;
const deviceConnected = booleanValue(rawData.connected) ?? online;
const available = connectedArg && online && deviceConnected;
const width = numberValue(rawData.width) ?? 640;
const height = numberValue(rawData.height) ?? 480;
const streamWidth = numberValue(rawData.mjpegStreamWidth) ?? 640;
const streamHeight = numberValue(rawData.mjpegStreamHeight) ?? 480;
const urls = this.deviceUrls(objectId, typeId, streamWidth, streamHeight, this.isDeviceSnapshot(deviceArg) ? deviceArg.urls : undefined);
return {
id: raw.id ?? objectId,
objectId,
typeId,
name: stringValue(raw.name) || (this.isDeviceSnapshot(deviceArg) ? deviceArg.name : undefined) || `Agent DVR ${typeId === agentDvrCameraTypeId ? 'Camera' : 'Device'} ${objectId || ''}`.trim(),
location,
locationIndex,
data: {
...rawData,
online,
connected: deviceConnected,
recording: booleanValue(rawData.recording) ?? false,
alerted: booleanValue(rawData.alerted) ?? false,
detected: booleanValue(rawData.detected) ?? false,
alertsActive: booleanValue(rawData.alertsActive) ?? false,
detectorActive: booleanValue(rawData.detectorActive) ?? false,
ptzid: numberValue(rawData.ptzid) ?? -1,
width,
height,
mjpegStreamWidth: streamWidth,
mjpegStreamHeight: streamHeight,
},
urls,
available,
raw,
attributes: this.isDeviceSnapshot(deviceArg) ? deviceArg.attributes : undefined,
};
}
private deviceUrls(objectIdArg: number, typeIdArg: number, widthArg: number, heightArg: number, existingArg?: IAgentDvrDeviceSnapshot['urls']): IAgentDvrDeviceSnapshot['urls'] {
const base = this.safeBaseUrl();
if (!base || typeIdArg !== agentDvrCameraTypeId || !objectIdArg) {
return existingArg || {};
}
const size = `${widthArg}x${heightArg}`;
return {
...existingArg,
stillImageUrl: existingArg?.stillImageUrl || this.urlString('grab.jpg', { oid: objectIdArg, size }),
mjpegImageUrl: existingArg?.mjpegImageUrl || this.urlString('video.mjpg', { oid: objectIdArg, size }),
mp4Url: existingArg?.mp4Url || this.urlString('video.mp4', { oid: objectIdArg, size }),
webmUrl: existingArg?.webmUrl || this.urlString('video.webm', { oid: objectIdArg, size }),
streamSourceUrl: existingArg?.streamSourceUrl || this.urlString('video.mjpg', { oid: objectIdArg, size }),
};
}
private async executeWithClient(clientArg: IAgentDvrClientLike, commandArg: IAgentDvrCommandRequest): Promise<unknown> {
if (clientArg.execute) {
return clientArg.execute(commandArg);
}
if ((commandArg.type === 'refresh' || commandArg.type === 'status') && clientArg.getSnapshot) {
return clientArg.getSnapshot();
}
if (commandArg.type === 'snapshot_image' && clientArg.getSnapshotImage) {
const snapshot = await this.getSnapshot();
const image = await clientArg.getSnapshotImage(this.findDevice(snapshot, commandArg.objectId, commandArg.objectTypeId));
return { ok: true, transmitted: true, contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
throw new AgentDvrApiConnectionError('Agent DVR command is not available on the injected client and no local HTTP endpoint or commandExecutor is configured.');
}
private async streamSource(commandArg: IAgentDvrCommandRequest): Promise<Record<string, unknown>> {
const snapshot = await this.getSnapshot();
const device = this.findDevice(snapshot, commandArg.objectId, commandArg.objectTypeId);
if ((commandArg.objectId !== undefined || commandArg.objectTypeId !== undefined) && !device) {
throw new AgentDvrApiCommandError(commandArg, 'Agent DVR stream source target was not found.');
}
return {
objectId: device?.objectId,
objectTypeId: device?.typeId,
streamSourceUrl: device?.urls.streamSourceUrl,
mjpegImageUrl: device?.urls.mjpegImageUrl,
stillImageUrl: device?.urls.stillImageUrl,
verified: false,
};
}
private patchCachedSnapshot(commandArg: IAgentDvrCommandRequest): void {
if (!this.currentSnapshot) {
return;
}
if (commandArg.type === 'alarm_command' || commandArg.type === 'set_profile') {
const profile = commandArg.profile;
if (commandArg.command === 'disarm') {
this.currentSnapshot.server.armed = false;
} else if (commandArg.command === 'arm') {
this.currentSnapshot.server.armed = true;
}
if (profile) {
this.currentSnapshot.server.activeProfile = profile;
}
this.currentSnapshot.updatedAt = new Date().toISOString();
return;
}
const device = this.findDevice(this.currentSnapshot, commandArg.objectId, commandArg.objectTypeId);
if (!device) {
return;
}
if (commandArg.command === 'switchOn') {
device.data.online = true;
} else if (commandArg.command === 'switchOff') {
device.data.online = false;
} else if (commandArg.command === 'record') {
device.data.recording = true;
} else if (commandArg.command === 'recordStop') {
device.data.recording = false;
} else if (commandArg.command === 'alertOn') {
device.data.alertsActive = true;
} else if (commandArg.command === 'alertOff') {
device.data.alertsActive = false;
} else if (commandArg.command === 'detectorOn') {
device.data.detectorActive = true;
} else if (commandArg.command === 'detectorOff') {
device.data.detectorActive = false;
}
device.available = Boolean(this.currentSnapshot.connected && device.data.online && device.data.connected);
this.currentSnapshot.updatedAt = new Date().toISOString();
}
private async commandJson(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<Record<string, unknown>> {
return this.getJson('command.cgi', { cmd: commandArg, ...paramsArg });
}
private async getJson(pathArg: string, paramsArg: Record<string, string | number | boolean | undefined> = {}): Promise<Record<string, unknown>> {
const response = await this.request(pathArg, paramsArg, this.config.timeoutMs || agentDvrDefaultTimeoutMs);
if (!response.ok) {
throw new AgentDvrApiConnectionError(`Agent DVR HTTP ${response.status} for ${pathArg}.`);
}
const text = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().includes('application/json') && !text.trim().startsWith('{') && !text.trim().startsWith('[')) {
throw new AgentDvrApiError(`Unexpected Agent DVR response content type: ${contentType || 'unknown'}.`);
}
const json = JSON.parse(text) as unknown;
if (!json || typeof json !== 'object' || Array.isArray(json)) {
throw new AgentDvrApiError('Unexpected Agent DVR JSON response shape.');
}
const record = json as Record<string, unknown>;
if ('error' in record && record.error !== undefined && record.error !== null && record.error !== false && record.error !== '') {
throw new AgentDvrApiError(`Agent DVR API error: ${String(record.error)}`);
}
return record;
}
private async request(pathArg: string, paramsArg: Record<string, string | number | boolean | undefined> = {}, timeoutMsArg: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMsArg);
try {
return await fetch(this.urlString(pathArg, paramsArg), {
method: 'GET',
headers: {
Accept: 'application/json, image/*, text/plain, */*',
'User-Agent': 'SmarthomeExchangeAgentDVR/1.0',
},
signal: controller.signal,
});
} catch (errorArg) {
throw new AgentDvrApiConnectionError(this.errorMessage(errorArg));
} finally {
clearTimeout(timer);
}
}
private urlString(pathArg: string, paramsArg: Record<string, string | number | boolean | undefined> = {}): string {
const url = new URL(pathArg, this.baseUrl());
for (const [key, value] of Object.entries(paramsArg)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
private baseUrl(): string {
const endpoint = this.endpoint();
if (!endpoint.url) {
throw new AgentDvrApiConnectionError('Agent DVR local HTTP endpoint is not configured.');
}
return endpoint.url;
}
private safeBaseUrl(): string | undefined {
try {
return this.baseUrl();
} catch {
return undefined;
}
}
private endpoint(): { protocol: TAgentDvrProtocol; host?: string; port: number; url?: string } {
const url = safeUrl(this.config.serverUrl || this.config.url || this.config.host);
if (url) {
const protocol: TAgentDvrProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : this.config.port || (protocol === 'https' ? 443 : agentDvrDefaultPort);
url.protocol = `${protocol}:`;
url.port = String(port);
if (!url.pathname || url.pathname === '/') {
url.pathname = '/';
}
url.search = '';
url.hash = '';
return { protocol, host: url.hostname, port, url: url.toString() };
}
const protocol = this.config.protocol || 'http';
const port = this.config.port || agentDvrDefaultPort;
const host = this.config.host;
return { protocol, host, port, url: host ? `${protocol}://${host}:${port}/` : undefined };
}
private hasLiveTarget(): boolean {
const endpoint = this.endpoint();
return Boolean(endpoint.host && endpoint.url);
}
private hasManualData(): boolean {
return Boolean(this.config.status || this.config.devices?.length || this.config.name || this.config.uniqueId || this.config.snapshot);
}
private profilesFromRaw(profilesArg?: Record<string, unknown>): IAgentDvrProfile[] | undefined {
if (!profilesArg) {
return undefined;
}
if (Array.isArray(profilesArg.profiles)) {
return profilesArg.profiles as IAgentDvrProfile[];
}
return undefined;
}
private activeProfile(profilesArg: IAgentDvrProfile[]): string | null {
return profilesArg.find((profileArg) => profileArg.active)?.name || null;
}
private locationName(locationsArg: IAgentDvrLocation[], indexArg: number | undefined): string | undefined {
if (indexArg === undefined || indexArg < 0 || indexArg >= locationsArg.length) {
return undefined;
}
return locationsArg[indexArg]?.name;
}
private findDevice(snapshotArg: IAgentDvrSnapshot, objectIdArg: number | undefined, objectTypeIdArg: number | undefined): IAgentDvrDeviceSnapshot | undefined {
if (objectIdArg === undefined && objectTypeIdArg === undefined) {
return snapshotArg.devices.find((deviceArg) => deviceArg.typeId === agentDvrCameraTypeId);
}
return snapshotArg.devices.find((deviceArg) => {
if (objectIdArg !== undefined && deviceArg.objectId !== objectIdArg) {
return false;
}
if (objectTypeIdArg !== undefined && deviceArg.typeId !== objectTypeIdArg) {
return false;
}
return true;
});
}
private responseIsFailure(responseArg: Record<string, unknown>): boolean {
if (responseArg.success === false || responseArg.ok === false) {
return true;
}
const result = responseArg.result;
return typeof result === 'string' && result.toLowerCase() === 'error';
}
private cleanParams(paramsArg: Record<string, string | number | boolean | undefined>): Record<string, string | number | boolean> {
return Object.fromEntries(Object.entries(paramsArg).filter(([, valueArg]) => valueArg !== undefined)) as Record<string, string | number | boolean>;
}
private isSnapshot(valueArg: unknown): valueArg is IAgentDvrSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'server' in valueArg && 'devices' in valueArg);
}
private isDeviceSnapshot(valueArg: IAgentDvrDeviceSnapshot | IAgentDvrRawDevice): valueArg is IAgentDvrDeviceSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'objectId' in valueArg && 'typeId' in valueArg && 'urls' in valueArg);
}
private cloneSnapshot(snapshotArg: IAgentDvrSnapshot): IAgentDvrSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IAgentDvrSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return url;
}
} catch {
// Try again below with an explicit local HTTP scheme.
}
try {
return new URL(`http://${valueArg}`);
} catch {
return undefined;
}
};
const stringValue = (valueArg: unknown): string | undefined => {
if (typeof valueArg === 'string' && valueArg.trim()) {
return valueArg.trim();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return String(valueArg);
}
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 === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
return undefined;
};
@@ -0,0 +1,154 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { AgentDvrApiConnectionError, AgentDvrClient } from './agent_dvr.classes.client.js';
import { agentDvrDefaultConfigFromCandidate } from './agent_dvr.discovery.js';
import type { IAgentDvrConfig, IAgentDvrSnapshot, TAgentDvrProtocol } from './agent_dvr.types.js';
import { agentDvrDefaultPort, agentDvrDefaultTimeoutMs } from './agent_dvr.types.js';
export class AgentDvrConfigFlow implements IConfigFlow<IAgentDvrConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAgentDvrConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Agent DVR',
description: 'Configure the local Agent DVR HTTP API endpoint. The default Agent DVR port is 8090.',
fields: [
{ name: 'url', label: 'Base URL', type: 'text' },
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'validateConnection', label: 'Validate connection now', type: 'boolean' },
{ name: 'snapshotJson', label: 'Offline snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => {
const snapshot = this.snapshotValue(valuesArg.snapshotJson) || this.snapshotMetadata(candidateArg);
const defaults = agentDvrDefaultConfigFromCandidate(candidateArg);
const endpoint = this.endpoint(
this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'serverUrl') || this.stringMetadata(candidateArg, 'url') || defaults.url,
this.stringValue(valuesArg.host) || candidateArg.host || defaults.host,
this.numberValue(valuesArg.port) || candidateArg.port || defaults.port,
this.protocolMetadata(candidateArg) || defaults.protocol
);
if (!endpoint.host && !snapshot) {
return { kind: 'error', error: 'Agent DVR requires a base URL, host, or offline snapshot JSON.' };
}
const config: IAgentDvrConfig = {
protocol: endpoint.protocol,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
serverUrl: endpoint.url,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.server.name || endpoint.host,
uniqueId: candidateArg.id || candidateArg.serialNumber || snapshot?.server.unique || endpoint.host,
timeoutMs: agentDvrDefaultTimeoutMs,
snapshot,
metadata: {
discoverySource: candidateArg.source,
},
};
if (this.booleanValue(valuesArg.validateConnection)) {
try {
const liveSnapshot = await new AgentDvrClient(config).validateConnection();
config.name = liveSnapshot.server.name || config.name;
config.uniqueId = liveSnapshot.server.unique || config.uniqueId;
} catch (errorArg) {
if (errorArg instanceof AgentDvrApiConnectionError) {
return { kind: 'error', error: 'Could not connect to the Agent DVR server.' };
}
return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
return {
kind: 'done',
title: 'Agent DVR configured',
config,
};
},
};
}
private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TAgentDvrProtocol | undefined): { protocol: TAgentDvrProtocol; host?: string; port: number; url?: string } {
const url = safeUrl(urlArg || hostArg);
if (url) {
const protocol: TAgentDvrProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : portArg || (protocol === 'https' ? 443 : agentDvrDefaultPort);
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}/` };
}
const protocol = protocolArg || 'http';
const port = portArg || agentDvrDefaultPort;
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 booleanValue(valueArg: unknown): boolean {
return valueArg === true || valueArg === 'true' || valueArg === '1' || valueArg === 'on';
}
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): TAgentDvrProtocol | undefined {
const protocol = candidateArg.metadata?.protocol;
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
}
private snapshotMetadata(candidateArg: IDiscoveryCandidate): IAgentDvrSnapshot | undefined {
const snapshot = candidateArg.metadata?.snapshot;
return this.isSnapshot(snapshot) ? snapshot : undefined;
}
private snapshotValue(valueArg: unknown): IAgentDvrSnapshot | undefined {
const value = this.stringValue(valueArg);
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value) as unknown;
return this.isSnapshot(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
private isSnapshot(valueArg: unknown): valueArg is IAgentDvrSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'server' in valueArg && 'devices' in valueArg);
}
}
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return url;
}
} catch {
// Try again below with an explicit local HTTP scheme.
}
try {
return new URL(`http://${valueArg}`);
} catch {
return undefined;
}
};
@@ -1,26 +1,114 @@
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 { AgentDvrClient } from './agent_dvr.classes.client.js';
import { AgentDvrConfigFlow } from './agent_dvr.classes.configflow.js';
import { createAgentDvrDiscoveryDescriptor } from './agent_dvr.discovery.js';
import { AgentDvrMapper } from './agent_dvr.mapper.js';
import type { IAgentDvrConfig } from './agent_dvr.types.js';
import { agentDvrDomain } from './agent_dvr.types.js';
export class HomeAssistantAgentDvrIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "agent_dvr",
displayName: "Agent DVR",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/agent_dvr",
"upstreamDomain": "agent_dvr",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"agent-py==0.0.24"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@ispysoftware"
]
},
});
export class AgentDvrIntegration extends BaseIntegration<IAgentDvrConfig> {
public readonly domain = agentDvrDomain;
public readonly displayName = 'Agent DVR';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAgentDvrDiscoveryDescriptor();
public readonly configFlow = new AgentDvrConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/agent_dvr',
upstreamDomain: agentDvrDomain,
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['agent-py==0.0.24'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@ispysoftware'],
documentation: 'https://www.home-assistant.io/integrations/agent_dvr',
configFlow: true,
nativePort: {
manualLocalDiscovery: true,
mdnsMetadataMatching: true,
ssdpMetadataMatching: true,
localHttpApi: true,
cameraSnapshotMapping: true,
cameraMjpegUrlMapping: true,
liveEventSubscription: false,
videoProxying: false,
},
localApi: {
implemented: [
'manual/local host config flow using the Agent DVR HTTP API default port 8090',
'command.cgi getStatus, getObjects, getObject-shaped snapshot normalization where the local API exposes it',
'camera still image and MJPEG URL mapping through grab.jpg and video.mjpg',
'Agent DVR camera services for alerts, recording, snapshots, camera on/off, and motion detector commands through command.cgi',
'alarm panel arm/disarm/profile commands represented by the Home Assistant integration',
],
explicitUnsupported: [
'Agent DVR cloud or remote portal APIs',
'live video proxying/transcoding and live event subscriptions',
'claiming command success without a configured local HTTP endpoint, injected client, or command executor',
],
},
};
public async setup(configArg: IAgentDvrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new AgentDvrRuntime(new AgentDvrClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAgentDvrIntegration extends AgentDvrIntegration {}
class AgentDvrRuntime implements IIntegrationRuntime {
public domain = agentDvrDomain;
constructor(private readonly client: AgentDvrClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AgentDvrMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AgentDvrMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
void handlerArg;
throw new Error('Agent DVR live event subscription is not implemented in this TypeScript port; use polling or explicit refreshes.');
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
const snapshot = await this.client.getSnapshot();
const command = AgentDvrMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Agent DVR service or target: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
const success = this.successFromResult(data);
return { success, error: success ? undefined : 'Agent DVR command reported failure.', data };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private successFromResult(valueArg: unknown): boolean {
if (valueArg && typeof valueArg === 'object') {
const record = valueArg as Record<string, unknown>;
if (typeof record.success === 'boolean') {
return record.success;
}
if (typeof record.ok === 'boolean') {
return record.ok;
}
}
return true;
}
}
@@ -0,0 +1,284 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IAgentDvrManualEntry, IAgentDvrMdnsRecord, IAgentDvrSsdpRecord, TAgentDvrProtocol } from './agent_dvr.types.js';
import { agentDvrDefaultPort, agentDvrDomain } from './agent_dvr.types.js';
export class AgentDvrManualMatcher implements IDiscoveryMatcher<IAgentDvrManualEntry> {
public id = 'agent-dvr-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Agent DVR local server host, URL, or snapshot entries.';
public async matches(inputArg: IAgentDvrManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromManual(inputArg);
const snapshot = inputArg.snapshot || (inputArg.metadata?.snapshot as IAgentDvrManualEntry['snapshot'] | undefined);
const hint = hasAgentDvrHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.agentDvr || inputArg.metadata?.ispy);
if (!endpoint.host && !snapshot && !hint) {
return { matched: false, confidence: 'low', reason: 'Manual Agent DVR entry requires host, url, snapshot, or Agent DVR metadata.' };
}
const normalizedDeviceId = inputArg.uniqueId || inputArg.id || snapshot?.server.unique || endpoint.host || snapshot?.server.name;
return {
matched: true,
confidence: snapshot ? 'certain' : endpoint.host ? 'high' : 'medium',
reason: snapshot ? 'Manual entry contains an Agent DVR snapshot.' : endpoint.host ? 'Manual entry contains a local Agent DVR endpoint.' : 'Manual entry contains Agent DVR metadata.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: agentDvrDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.port,
name: inputArg.name || snapshot?.server.name || endpoint.host,
manufacturer: inputArg.manufacturer || 'iSpyConnect',
model: inputArg.model || 'Agent DVR',
serialNumber: inputArg.serialNumber || snapshot?.server.unique,
macAddress: normalizeMac(inputArg.macAddress) || undefined,
metadata: {
...inputArg.metadata,
agentDvr: true,
protocol: endpoint.protocol,
url: endpoint.url,
serverUrl: endpoint.url,
snapshot,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export class AgentDvrMdnsMatcher implements IDiscoveryMatcher<IAgentDvrMdnsRecord> {
public id = 'agent-dvr-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize local Agent DVR mDNS records by name, service type, and TXT metadata.';
public async matches(recordArg: IAgentDvrMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const properties = { ...recordArg.txt, ...recordArg.properties };
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'vendor');
const model = valueForKey(properties, 'model') || valueForKey(properties, 'product');
const serial = valueForKey(properties, 'serial') || valueForKey(properties, 'unique') || valueForKey(properties, 'id');
const name = cleanMdnsName(recordArg.name || recordArg.hostname);
const matched = hasAgentDvrHint(name, recordArg.type, manufacturer, model, valueForKey(properties, 'server')) || Boolean(valueForKey(properties, 'agentDvr') || valueForKey(properties, 'ispy'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Agent DVR hints.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
return {
matched: true,
confidence: 'medium',
reason: 'mDNS record contains Agent DVR metadata.',
normalizedDeviceId: serial || host || name,
candidate: {
source: 'mdns',
integrationDomain: agentDvrDomain,
id: serial || host || name,
host,
port: recordArg.port || agentDvrDefaultPort,
name: name || undefined,
manufacturer: manufacturer || 'iSpyConnect',
model: model || 'Agent DVR',
serialNumber: serial,
metadata: {
agentDvr: true,
mdnsName: recordArg.name,
mdnsType: recordArg.type,
txt: properties,
protocol: 'http' satisfies TAgentDvrProtocol,
discoveryProtocol: 'mdns',
},
},
};
}
}
export class AgentDvrSsdpMatcher implements IDiscoveryMatcher<IAgentDvrSsdpRecord> {
public id = 'agent-dvr-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize local Agent DVR SSDP records by UPnP manufacturer and friendly name metadata.';
public async matches(recordArg: IAgentDvrSsdpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const upnp = { ...recordArg.headers, ...recordArg.upnp };
const manufacturer = recordArg.manufacturer || valueForKey(upnp, 'manufacturer') || valueForKey(upnp, 'vendor') || '';
const model = valueForKey(upnp, 'modelName') || valueForKey(upnp, 'modelNumber') || valueForKey(upnp, 'model');
const friendlyName = valueForKey(upnp, 'friendlyName') || valueForKey(upnp, 'upnp:friendlyName');
const location = recordArg.location || valueForKey(upnp, 'location') || valueForKey(upnp, 'presentationURL') || valueForKey(upnp, 'presentation_url');
const url = safeUrl(location);
const serial = valueForKey(upnp, 'serialNumber') || valueForKey(upnp, 'unique') || recordArg.usn;
const matched = hasAgentDvrHint(friendlyName, manufacturer, model, recordArg.server, recordArg.st);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not published by Agent DVR.' };
}
const protocol: TAgentDvrProtocol = url?.protocol === 'https:' ? 'https' : 'http';
return {
matched: true,
confidence: 'medium',
reason: 'SSDP record contains Agent DVR metadata.',
normalizedDeviceId: serial || url?.hostname || friendlyName,
candidate: {
source: 'ssdp',
integrationDomain: agentDvrDomain,
id: serial || url?.hostname || friendlyName,
host: url?.hostname,
port: url?.port ? Number(url.port) : agentDvrDefaultPort,
name: friendlyName,
manufacturer: manufacturer || 'iSpyConnect',
model: model || 'Agent DVR',
serialNumber: serial,
metadata: {
agentDvr: true,
protocol,
location,
ssdp: upnp,
discoveryProtocol: 'ssdp',
},
},
};
}
}
export class AgentDvrCandidateValidator implements IDiscoveryValidator {
public id = 'agent-dvr-candidate-validator';
public description = 'Validate that a candidate can be configured as a local Agent DVR server.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== agentDvrDomain) {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Agent DVR.` };
}
const endpoint = endpointFromCandidate(candidateArg);
const snapshot = candidateArg.metadata?.snapshot;
const hasHint = candidateArg.integrationDomain === agentDvrDomain
|| candidateArg.source === 'manual'
|| hasAgentDvrHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model)
|| Boolean(candidateArg.metadata?.agentDvr || candidateArg.metadata?.ispy || snapshot);
if (!hasHint || (!endpoint.host && !snapshot)) {
return { matched: false, confidence: 'low', reason: 'Agent DVR candidates require a host plus manual or Agent DVR metadata.' };
}
if (endpoint.host && (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535)) {
return { matched: false, confidence: 'low', reason: 'Agent DVR candidate has an invalid port.' };
}
return {
matched: true,
confidence: candidateArg.source === 'manual' ? 'high' : 'medium',
reason: 'Candidate has enough local Agent DVR metadata to start configuration.',
normalizedDeviceId: candidateArg.id || candidateArg.serialNumber || endpoint.host,
candidate: {
...candidateArg,
integrationDomain: agentDvrDomain,
id: candidateArg.id || candidateArg.serialNumber || endpoint.host,
host: endpoint.host,
port: endpoint.port,
manufacturer: candidateArg.manufacturer || 'iSpyConnect',
model: candidateArg.model || 'Agent DVR',
metadata: {
...candidateArg.metadata,
agentDvr: true,
protocol: endpoint.protocol,
url: endpoint.url,
serverUrl: endpoint.url,
},
},
metadata: {
manualSupported: candidateArg.source === 'manual',
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export const createAgentDvrDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: agentDvrDomain, displayName: 'Agent DVR' })
.addMatcher(new AgentDvrManualMatcher())
.addMatcher(new AgentDvrMdnsMatcher())
.addMatcher(new AgentDvrSsdpMatcher())
.addValidator(new AgentDvrCandidateValidator());
};
export const agentDvrDefaultConfigFromCandidate = (candidateArg: IDiscoveryCandidate) => {
const endpoint = endpointFromCandidate(candidateArg);
return {
protocol: endpoint.protocol,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
serverUrl: endpoint.url,
};
};
const endpointFromManual = (inputArg: IAgentDvrManualEntry): { protocol: TAgentDvrProtocol; host?: string; port: number; url?: string } => {
const url = safeUrl(inputArg.serverUrl || inputArg.url || inputArg.host);
if (url) {
const protocol: TAgentDvrProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : inputArg.port || (protocol === 'https' ? 443 : agentDvrDefaultPort);
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}/` };
}
const protocol = inputArg.protocol || 'http';
const port = inputArg.port || agentDvrDefaultPort;
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}/` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TAgentDvrProtocol; host?: string; port: number; url?: string } => {
const metadataUrl = typeof candidateArg.metadata?.serverUrl === 'string' ? candidateArg.metadata.serverUrl : typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
const metadataProtocol: TAgentDvrProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
const url = safeUrl(metadataUrl || candidateArg.host);
if (url) {
const protocol: TAgentDvrProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : candidateArg.port || (protocol === 'https' ? 443 : agentDvrDefaultPort);
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}/` };
}
const port = candidateArg.port || agentDvrDefaultPort;
return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}/` : metadataUrl };
};
const hasAgentDvrHint = (...valuesArgs: Array<string | undefined>): boolean => {
const haystack = valuesArgs.filter(Boolean).join(' ').toLowerCase();
return haystack.includes('agent dvr') || haystack.includes('agentdvr') || haystack.includes('ispyconnect') || haystack.includes('ispy');
};
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 cleanMdnsName = (valueArg: string | undefined): string => {
return valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || '';
};
const normalizeMac = (valueArg: string | undefined): string => {
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
return cleaned.length === 12 ? cleaned : '';
};
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(valueArg);
if (url.protocol === 'http:' || url.protocol === 'https:') {
return url;
}
} catch {
// Try again below with an explicit local HTTP scheme.
}
try {
return new URL(`http://${valueArg}`);
} catch {
return undefined;
}
};
@@ -0,0 +1,463 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IAgentDvrCommandRequest,
IAgentDvrDeviceSnapshot,
IAgentDvrHttpCommand,
IAgentDvrSnapshot,
TAgentDvrAlarmProfile,
TAgentDvrAlarmState,
TAgentDvrCameraCommandName,
} from './agent_dvr.types.js';
import { agentDvrAlarmProfiles, agentDvrCameraTypeId, agentDvrDomain } from './agent_dvr.types.js';
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
type TAgentDvrSwitchKey = 'enabled' | 'alerts' | 'detector' | 'recording';
export class AgentDvrMapper {
public static toDevices(snapshotArg: IAgentDvrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.serverDeviceId(snapshotArg),
integrationDomain: agentDvrDomain,
name: snapshotArg.server.name || 'Agent DVR',
protocol: 'http',
manufacturer: 'iSpyConnect',
model: 'Agent DVR',
online: snapshotArg.connected,
features: [
{ id: 'alarm', capability: 'sensor', name: 'Alarm', readable: true, writable: true },
{ id: 'active_profile', capability: 'sensor', name: 'Active profile', readable: true, writable: true },
{ id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false },
],
state: [
{ featureId: 'alarm', value: this.alarmState(snapshotArg), updatedAt },
{ featureId: 'active_profile', value: snapshotArg.server.activeProfile || null, updatedAt },
{ featureId: 'device_count', value: snapshotArg.server.deviceCount ?? snapshotArg.devices.length, updatedAt },
],
metadata: this.cleanAttributes({
host: snapshotArg.server.host,
port: snapshotArg.server.port,
protocol: snapshotArg.server.protocol,
serverUrl: snapshotArg.server.serverUrl,
unique: snapshotArg.server.unique,
version: snapshotArg.server.version,
remoteAccess: snapshotArg.server.remoteAccess,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
for (const camera of this.cameras(snapshotArg)) {
devices.push({
id: this.cameraDeviceId(snapshotArg, camera),
integrationDomain: agentDvrDomain,
name: camera.name,
protocol: 'http',
manufacturer: 'Agent',
model: 'Camera',
online: snapshotArg.connected && camera.available,
features: [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
{ id: 'camera', capability: 'camera', name: camera.name, readable: true, writable: true },
{ id: 'enabled', capability: 'switch', name: 'Enabled', readable: true, writable: true },
{ id: 'recording', capability: 'switch', name: 'Recording', readable: true, writable: true },
{ id: 'alerts', capability: 'switch', name: 'Alerts', readable: true, writable: true },
{ id: 'detector', capability: 'switch', name: 'Motion detector', readable: true, writable: true },
],
state: [
{ featureId: 'connectivity', value: camera.available ? 'online' : 'offline', updatedAt },
{ featureId: 'camera', value: this.deviceStateValue({ stillImageUrl: camera.urls.stillImageUrl || null, mjpegImageUrl: camera.urls.mjpegImageUrl || null }), updatedAt },
{ featureId: 'enabled', value: Boolean(camera.data.online), updatedAt },
{ featureId: 'recording', value: Boolean(camera.data.recording), updatedAt },
{ featureId: 'alerts', value: Boolean(camera.data.alertsActive), updatedAt },
{ featureId: 'detector', value: Boolean(camera.data.detectorActive), updatedAt },
],
metadata: this.cleanAttributes({
objectId: camera.objectId,
objectTypeId: camera.typeId,
location: camera.location,
width: camera.data.width,
height: camera.data.height,
hasPtz: this.hasPtz(camera),
source: snapshotArg.source,
}),
});
}
return devices;
}
public static toEntities(snapshotArg: IAgentDvrSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
const serverDeviceId = this.serverDeviceId(snapshotArg);
entities.push(this.entity('alarm_control_panel' as TEntityPlatform, `${snapshotArg.server.name || 'Agent DVR'} Alarm Panel`, serverDeviceId, `${agentDvrDomain}_${this.serverUniqueBase(snapshotArg)}_alarm`, this.alarmState(snapshotArg), usedIds, {
supportedFeatures: ['arm_home', 'arm_away', 'arm_night'],
codeArmRequired: false,
activeProfile: snapshotArg.server.activeProfile,
armed: snapshotArg.server.armed,
serviceMappings: {
disarm: 'alarm_control_panel.alarm_disarm',
armAway: 'alarm_control_panel.alarm_arm_away',
armHome: 'alarm_control_panel.alarm_arm_home',
armNight: 'alarm_control_panel.alarm_arm_night',
},
}, snapshotArg.connected));
entities.push(this.entity('sensor', `${snapshotArg.server.name || 'Agent DVR'} Active Profile`, serverDeviceId, `${agentDvrDomain}_${this.serverUniqueBase(snapshotArg)}_active_profile`, snapshotArg.server.activeProfile || 'unknown', usedIds, { deviceClass: 'enum' }, snapshotArg.connected));
entities.push(this.entity('sensor', `${snapshotArg.server.name || 'Agent DVR'} Device Count`, serverDeviceId, `${agentDvrDomain}_${this.serverUniqueBase(snapshotArg)}_device_count`, snapshotArg.server.deviceCount ?? snapshotArg.devices.length, usedIds, {}, snapshotArg.connected));
for (const camera of this.cameras(snapshotArg)) {
const deviceId = this.cameraDeviceId(snapshotArg, camera);
const cameraBase = this.cameraUniqueBase(snapshotArg, camera);
entities.push(this.entity('camera' as TEntityPlatform, camera.name, deviceId, `${agentDvrDomain}_${cameraBase}_camera`, camera.available ? 'idle' : 'unavailable', usedIds, {
objectId: camera.objectId,
objectTypeId: camera.typeId,
stillImageUrl: camera.urls.stillImageUrl,
snapshotUrl: camera.urls.stillImageUrl,
mjpegImageUrl: camera.urls.mjpegImageUrl,
streamSourceUrl: camera.urls.streamSourceUrl || camera.urls.mjpegImageUrl,
mp4Url: camera.urls.mp4Url,
webmUrl: camera.urls.webmUrl,
supportedFeatures: ['on_off', 'snapshot', 'stream', 'recording', 'alerts', 'motion_detection'],
editable: false,
enabled: Boolean(camera.data.online),
connected: Boolean(camera.data.connected),
detected: Boolean(camera.data.detected),
alerted: Boolean(camera.data.alerted),
hasPtz: this.hasPtz(camera),
alertsEnabled: Boolean(camera.data.alertsActive),
motionDetectionEnabled: Boolean(camera.data.detectorActive),
recording: Boolean(camera.data.recording),
location: camera.location,
serviceMappings: {
enableAlerts: `${agentDvrDomain}.enable_alerts`,
disableAlerts: `${agentDvrDomain}.disable_alerts`,
startRecording: `${agentDvrDomain}.start_recording`,
stopRecording: `${agentDvrDomain}.stop_recording`,
snapshot: `${agentDvrDomain}.snapshot`,
snapshotImage: 'camera.snapshot',
},
...camera.attributes,
}, snapshotArg.connected && camera.available));
for (const key of ['enabled', 'alerts', 'detector', 'recording'] as TAgentDvrSwitchKey[]) {
entities.push(this.cameraSwitchEntity(snapshotArg, camera, key, usedIds));
}
entities.push(this.entity('binary_sensor', `${camera.name} Connected`, deviceId, `${agentDvrDomain}_${cameraBase}_connected`, camera.data.connected ? 'on' : 'off', usedIds, { objectId: camera.objectId, objectTypeId: camera.typeId, deviceClass: 'connectivity' }, snapshotArg.connected));
entities.push(this.entity('binary_sensor', `${camera.name} Detected`, deviceId, `${agentDvrDomain}_${cameraBase}_detected`, camera.data.detected ? 'on' : 'off', usedIds, { objectId: camera.objectId, objectTypeId: camera.typeId }, snapshotArg.connected && camera.available));
entities.push(this.entity('binary_sensor', `${camera.name} Alerted`, deviceId, `${agentDvrDomain}_${cameraBase}_alerted`, camera.data.alerted ? 'on' : 'off', usedIds, { objectId: camera.objectId, objectTypeId: camera.typeId }, snapshotArg.connected && camera.available));
}
return entities;
}
public static commandForService(snapshotArg: IAgentDvrSnapshot, requestArg: IServiceCallRequest): IAgentDvrCommandRequest | undefined {
if (requestArg.domain === agentDvrDomain && (requestArg.service === 'refresh' || requestArg.service === 'status')) {
return { type: requestArg.service === 'refresh' ? 'refresh' : 'status', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
const alarmCommand = this.alarmCommand(requestArg);
if (alarmCommand) {
return alarmCommand;
}
if (requestArg.domain === agentDvrDomain && requestArg.service === 'set_profile') {
const profile = this.profileValue(requestArg.data?.profile ?? requestArg.data?.name);
return profile ? this.profileCommand(profile, requestArg) : undefined;
}
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
const switchCommand = this.switchCommand(snapshotArg, requestArg);
if (switchCommand) {
return switchCommand;
}
}
const camera = this.findCamera(snapshotArg, requestArg);
if (!camera) {
return undefined;
}
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
return { type: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, objectId: camera.objectId, objectTypeId: camera.typeId };
}
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
return { type: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, objectId: camera.objectId, objectTypeId: camera.typeId, filename: this.stringValue(requestArg.data?.filename), size: this.stringValue(requestArg.data?.size) };
}
if (requestArg.domain === 'camera' && requestArg.service === 'turn_on') {
return this.cameraCommand(camera, 'switchOn', requestArg);
}
if (requestArg.domain === 'camera' && requestArg.service === 'turn_off') {
return this.cameraCommand(camera, 'switchOff', requestArg);
}
if (requestArg.domain === 'camera' && requestArg.service === 'enable_motion_detection') {
return this.cameraCommand(camera, 'detectorOn', requestArg);
}
if (requestArg.domain === 'camera' && requestArg.service === 'disable_motion_detection') {
return this.cameraCommand(camera, 'detectorOff', requestArg);
}
if (requestArg.domain === agentDvrDomain) {
if (requestArg.service === 'enable_alerts') {
return this.cameraCommand(camera, 'alertOn', requestArg);
}
if (requestArg.service === 'disable_alerts') {
return this.cameraCommand(camera, 'alertOff', requestArg);
}
if (requestArg.service === 'start_recording') {
return this.cameraCommand(camera, 'record', requestArg);
}
if (requestArg.service === 'stop_recording') {
return this.cameraCommand(camera, 'recordStop', requestArg);
}
if (requestArg.service === 'snapshot') {
return this.cameraCommand(camera, 'snapshot', requestArg);
}
if (requestArg.service === 'snapshot_image') {
return { type: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, objectId: camera.objectId, objectTypeId: camera.typeId, filename: this.stringValue(requestArg.data?.filename), size: this.stringValue(requestArg.data?.size) };
}
}
return undefined;
}
public static alarmState(snapshotArg: IAgentDvrSnapshot): TAgentDvrAlarmState {
if (snapshotArg.server.armed === false) {
return 'disarmed';
}
if (snapshotArg.server.armed === true) {
const profile = String(snapshotArg.server.activeProfile || '').toLowerCase();
if (profile === 'home') {
return 'armed_home';
}
if (profile === 'night') {
return 'armed_night';
}
if (profile === 'away' || !profile) {
return 'armed_away';
}
return 'armed_custom_bypass';
}
return 'unknown';
}
public static serverDeviceId(snapshotArg: IAgentDvrSnapshot): string {
return `${agentDvrDomain}.server.${this.serverUniqueBase(snapshotArg)}`;
}
public static cameraDeviceId(snapshotArg: IAgentDvrSnapshot, cameraArg: IAgentDvrDeviceSnapshot): string {
return `${agentDvrDomain}.camera.${this.cameraUniqueBase(snapshotArg, cameraArg)}`;
}
private static cameraSwitchEntity(snapshotArg: IAgentDvrSnapshot, cameraArg: IAgentDvrDeviceSnapshot, keyArg: TAgentDvrSwitchKey, usedIdsArg: Map<string, number>): IIntegrationEntity {
const state = this.switchState(cameraArg, keyArg);
return this.entity('switch', `${cameraArg.name} ${this.switchName(keyArg)}`, this.cameraDeviceId(snapshotArg, cameraArg), `${agentDvrDomain}_${this.cameraUniqueBase(snapshotArg, cameraArg)}_${keyArg}`, state ? 'on' : 'off', usedIdsArg, {
key: keyArg,
objectId: cameraArg.objectId,
objectTypeId: cameraArg.typeId,
nativeType: `agent_dvr_${keyArg}`,
}, snapshotArg.connected && (keyArg === 'enabled' || cameraArg.available));
}
private static alarmCommand(requestArg: IServiceCallRequest): IAgentDvrCommandRequest | undefined {
const domainMatches = requestArg.domain === 'alarm_control_panel' || requestArg.domain === agentDvrDomain;
if (!domainMatches) {
return undefined;
}
if (requestArg.service === 'alarm_disarm' || requestArg.service === 'disarm') {
return { type: 'alarm_command', service: requestArg.service, target: requestArg.target, data: requestArg.data, command: 'disarm', httpCommands: [this.httpCommand('disarm')] };
}
const profile = requestArg.service === 'alarm_arm_home' || requestArg.service === 'arm_home'
? 'home'
: requestArg.service === 'alarm_arm_night' || requestArg.service === 'arm_night'
? 'night'
: requestArg.service === 'alarm_arm_away' || requestArg.service === 'arm_away' || requestArg.service === 'arm'
? 'away'
: undefined;
if (!profile) {
return undefined;
}
return {
type: 'alarm_command',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
command: 'arm',
profile,
httpCommands: [this.httpCommand('arm'), this.httpCommand('setProfileByName', { name: profile })],
};
}
private static profileCommand(profileArg: TAgentDvrAlarmProfile | string, requestArg: IServiceCallRequest): IAgentDvrCommandRequest {
return {
type: 'set_profile',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
command: 'setProfileByName',
profile: profileArg,
httpCommands: [this.httpCommand('setProfileByName', { name: profileArg })],
};
}
private static switchCommand(snapshotArg: IAgentDvrSnapshot, requestArg: IServiceCallRequest): IAgentDvrCommandRequest | undefined {
const switchInfo = this.findCameraSwitch(snapshotArg, requestArg);
if (!switchInfo) {
return undefined;
}
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !this.switchState(switchInfo.camera, switchInfo.key);
const commandByKey: Record<TAgentDvrSwitchKey, [TAgentDvrCameraCommandName, TAgentDvrCameraCommandName]> = {
enabled: ['switchOn', 'switchOff'],
alerts: ['alertOn', 'alertOff'],
detector: ['detectorOn', 'detectorOff'],
recording: ['record', 'recordStop'],
};
return this.cameraCommand(switchInfo.camera, enabled ? commandByKey[switchInfo.key][0] : commandByKey[switchInfo.key][1], requestArg);
}
private static cameraCommand(cameraArg: IAgentDvrDeviceSnapshot, commandArg: TAgentDvrCameraCommandName, requestArg: IServiceCallRequest): IAgentDvrCommandRequest {
return {
type: 'camera_command',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
objectId: cameraArg.objectId,
objectTypeId: cameraArg.typeId,
command: commandArg,
httpCommands: [this.httpCommand(commandArg, { oid: cameraArg.objectId, ot: cameraArg.typeId })],
};
}
private static httpCommand(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): IAgentDvrHttpCommand {
return { label: commandArg, path: 'command.cgi', params: { cmd: commandArg, ...paramsArg }, expect: 'json' };
}
private static findCamera(snapshotArg: IAgentDvrSnapshot, requestArg: IServiceCallRequest): IAgentDvrDeviceSnapshot | undefined {
const target = requestArg.target?.entityId || requestArg.target?.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.objectId ?? requestArg.data?.oid);
const cameras = this.cameras(snapshotArg);
if (!target) {
return cameras[0];
}
const entities = this.toEntities(snapshotArg);
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.deviceId === target);
const objectId = this.numberValue(entity?.attributes?.objectId ?? target);
return cameras.find((cameraArg) => cameraArg.objectId === objectId || this.cameraDeviceId(snapshotArg, cameraArg) === target || String(cameraArg.id) === target || cameraArg.name === target);
}
private static findCameraSwitch(snapshotArg: IAgentDvrSnapshot, requestArg: IServiceCallRequest): { camera: IAgentDvrDeviceSnapshot; key: TAgentDvrSwitchKey } | undefined {
const target = requestArg.target?.entityId || requestArg.target?.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key);
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch');
const entity = target ? entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target) : entities[0];
if (target && !entity) {
return undefined;
}
const key = this.switchKey(entity?.attributes?.key || target);
const objectId = this.numberValue(entity?.attributes?.objectId);
const camera = this.cameras(snapshotArg).find((cameraArg) => cameraArg.objectId === objectId) || this.findCamera(snapshotArg, requestArg);
return camera && key ? { camera, key } : undefined;
}
private static cameras(snapshotArg: IAgentDvrSnapshot): IAgentDvrDeviceSnapshot[] {
return snapshotArg.devices.filter((deviceArg) => deviceArg.typeId === agentDvrCameraTypeId);
}
private static hasPtz(cameraArg: IAgentDvrDeviceSnapshot): boolean {
return cameraArg.typeId === agentDvrCameraTypeId && this.numberValue(cameraArg.data.ptzid) !== -1;
}
private static switchState(cameraArg: IAgentDvrDeviceSnapshot, keyArg: TAgentDvrSwitchKey): boolean {
if (keyArg === 'enabled') {
return Boolean(cameraArg.data.online);
}
if (keyArg === 'alerts') {
return Boolean(cameraArg.data.alertsActive);
}
if (keyArg === 'detector') {
return Boolean(cameraArg.data.detectorActive);
}
return Boolean(cameraArg.data.recording);
}
private static switchName(keyArg: TAgentDvrSwitchKey): string {
return keyArg === 'enabled' ? 'Enabled' : keyArg === 'alerts' ? 'Alerts' : keyArg === 'detector' ? 'Motion Detector' : 'Recording';
}
private static profileValue(valueArg: unknown): TAgentDvrAlarmProfile | string | undefined {
const value = this.stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
return agentDvrAlarmProfiles.includes(value as TAgentDvrAlarmProfile) ? value as TAgentDvrAlarmProfile : value;
}
private static switchKey(valueArg: unknown): TAgentDvrSwitchKey | undefined {
const value = this.stringValue(valueArg) as TAgentDvrSwitchKey | undefined;
return value === 'enabled' || value === 'alerts' || value === 'detector' || value === 'recording' ? value : undefined;
}
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: agentDvrDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static serverUniqueBase(snapshotArg: IAgentDvrSnapshot): string {
return this.slug(snapshotArg.server.unique || snapshotArg.server.id || snapshotArg.server.host || snapshotArg.server.name || 'agent_dvr');
}
private static cameraUniqueBase(snapshotArg: IAgentDvrSnapshot, cameraArg: IAgentDvrDeviceSnapshot): string {
return this.slug(`${this.serverUniqueBase(snapshotArg)}_${cameraArg.typeId}_${cameraArg.objectId || cameraArg.id || cameraArg.name}`);
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (valueArg === undefined) {
return null;
}
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
return valueArg;
}
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return String(valueArg);
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
}
private static 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 static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || agentDvrDomain;
}
}
+249 -2
View File
@@ -1,4 +1,251 @@
export interface IHomeAssistantAgentDvrConfig {
// TODO: replace with the TypeScript-native config for agent_dvr.
import type { IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
export const agentDvrDomain = 'agent_dvr';
export const agentDvrDefaultPort = 8090;
export const agentDvrDefaultTimeoutMs = 10000;
export const agentDvrDefaultSnapshotTimeoutMs = 20000;
export const agentDvrCameraTypeId = 2;
export const agentDvrAlarmProfiles = ['home', 'away', 'night'] as const;
export type TAgentDvrProtocol = 'http' | 'https';
export type TAgentDvrSnapshotSource = 'http' | 'snapshot' | 'client' | 'manual' | 'offline';
export type TAgentDvrAlarmProfile = typeof agentDvrAlarmProfiles[number];
export type TAgentDvrAlarmState = 'disarmed' | 'armed_home' | 'armed_away' | 'armed_night' | 'armed_custom_bypass' | 'unknown';
export type TAgentDvrCommandType =
| 'refresh'
| 'status'
| 'stream_source'
| 'snapshot_image'
| 'camera_command'
| 'alarm_command'
| 'set_profile';
export type TAgentDvrCameraCommandName =
| 'switchOn'
| 'switchOff'
| 'record'
| 'recordStop'
| 'alertOn'
| 'alertOff'
| 'detectorOn'
| 'detectorOff'
| 'snapshot';
export type TAgentDvrAlarmCommandName = 'arm' | 'disarm' | 'setProfileByName';
export interface IAgentDvrConfig {
protocol?: TAgentDvrProtocol;
host?: string;
port?: number;
url?: string;
serverUrl?: string;
timeoutMs?: number;
snapshotTimeoutMs?: number;
name?: string;
uniqueId?: string;
connected?: boolean;
status?: Partial<IAgentDvrServerSnapshot> & Record<string, unknown>;
locations?: IAgentDvrLocation[];
profiles?: IAgentDvrProfile[];
devices?: Array<IAgentDvrDeviceSnapshot | IAgentDvrRawDevice>;
snapshot?: IAgentDvrSnapshot;
client?: IAgentDvrClientLike;
commandExecutor?: IAgentDvrCommandExecutor;
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantAgentDvrConfig extends IAgentDvrConfig {}
export interface IAgentDvrServerSnapshot {
id?: string;
unique?: string;
name?: string;
host?: string;
port?: number;
protocol?: TAgentDvrProtocol;
url?: string;
serverUrl?: string;
version?: string;
remoteAccess?: boolean;
deviceCount?: number;
armed?: boolean | null;
activeProfile?: string | null;
available?: boolean;
locations?: IAgentDvrLocation[];
rawStatus?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface IAgentDvrLocation {
name?: string;
id?: string | number;
[key: string]: unknown;
}
export interface IAgentDvrProfile {
name?: string;
active?: boolean;
id?: string | number;
ind?: number;
index?: number;
[key: string]: unknown;
}
export interface IAgentDvrDeviceData {
recording?: boolean;
alerted?: boolean;
detected?: boolean;
online?: boolean;
alertsActive?: boolean;
detectorActive?: boolean;
connected?: boolean;
ptzid?: number;
width?: number;
height?: number;
mjpegStreamWidth?: number;
mjpegStreamHeight?: number;
[key: string]: unknown;
}
export interface IAgentDvrRawDevice {
id?: string | number;
oid?: string | number;
typeID?: string | number;
typeId?: string | number;
ot?: string | number;
name?: string;
locationIndex?: number;
data?: IAgentDvrDeviceData;
[key: string]: unknown;
}
export interface IAgentDvrDeviceUrls {
stillImageUrl?: string;
mjpegImageUrl?: string;
mp4Url?: string;
webmUrl?: string;
streamSourceUrl?: string;
}
export interface IAgentDvrDeviceSnapshot {
id: string | number;
objectId: number;
typeId: number;
name: string;
location?: string;
locationIndex?: number;
data: IAgentDvrDeviceData;
urls: IAgentDvrDeviceUrls;
available: boolean;
raw?: IAgentDvrRawDevice | Record<string, unknown>;
attributes?: Record<string, unknown>;
}
export interface IAgentDvrSnapshot {
connected: boolean;
server: IAgentDvrServerSnapshot;
devices: IAgentDvrDeviceSnapshot[];
locations: IAgentDvrLocation[];
profiles: IAgentDvrProfile[];
updatedAt?: string;
source?: TAgentDvrSnapshotSource;
error?: string;
metadata?: Record<string, unknown>;
}
export interface IAgentDvrSnapshotImage {
contentType: string;
data: Uint8Array;
}
export interface IAgentDvrHttpCommand {
label: string;
path: 'command.cgi';
params: Record<string, string | number | boolean>;
expect: 'json';
}
export interface IAgentDvrCommandRequest {
type: TAgentDvrCommandType;
service: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
objectId?: number;
objectTypeId?: number;
command?: TAgentDvrCameraCommandName | TAgentDvrAlarmCommandName;
profile?: TAgentDvrAlarmProfile | string;
filename?: string;
size?: string;
httpCommands?: IAgentDvrHttpCommand[];
}
export interface IAgentDvrCommandResult extends IServiceCallResult {
transmitted?: boolean;
command?: IAgentDvrCommandRequest;
responses?: unknown[];
}
export interface IAgentDvrClientLike {
getSnapshot?(): Promise<IAgentDvrSnapshot | Partial<IAgentDvrSnapshot> | Record<string, unknown>>;
getStatus?(): Promise<Record<string, unknown>>;
getObjects?(): Promise<Record<string, unknown> | IAgentDvrRawDevice[]>;
getProfiles?(): Promise<Record<string, unknown> | IAgentDvrProfile[]>;
getSnapshotImage?(deviceArg: IAgentDvrDeviceSnapshot | undefined): Promise<IAgentDvrSnapshotImage>;
execute?(commandArg: IAgentDvrCommandRequest): Promise<unknown>;
close?(): Promise<void> | void;
}
export interface IAgentDvrCommandExecutor {
execute(commandArg: IAgentDvrCommandRequest): Promise<unknown>;
}
export interface IAgentDvrManualEntry {
integrationDomain?: string;
id?: string;
host?: string;
port?: number;
url?: string;
serverUrl?: string;
protocol?: TAgentDvrProtocol;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
snapshot?: IAgentDvrSnapshot;
metadata?: Record<string, unknown>;
}
export interface IAgentDvrMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IAgentDvrSsdpRecord {
manufacturer?: string;
server?: string;
st?: string;
usn?: string;
location?: string;
upnp?: Record<string, string | undefined>;
headers?: Record<string, string | undefined>;
}
export interface IAgentDvrCandidateMetadata extends Record<string, unknown> {
agentDvr?: boolean;
ispy?: boolean;
discoveryProtocol?: 'manual' | 'mdns' | 'ssdp';
protocol?: TAgentDvrProtocol;
url?: string;
serverUrl?: string;
snapshot?: IAgentDvrSnapshot;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './agent_dvr.classes.client.js';
export * from './agent_dvr.classes.configflow.js';
export * from './agent_dvr.classes.integration.js';
export * from './agent_dvr.discovery.js';
export * from './agent_dvr.mapper.js';
export * from './agent_dvr.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,503 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { AirosMapper } from './airos.mapper.js';
import { airosDefaultHttpPort, airosDefaultHttpsPort, airosDefaultTimeoutMs, airosDefaultUsername } from './airos.constants.js';
import type { IAirosCommand, IAirosCommandResult, IAirosConfig, IAirosEvent, IAirosFirmwareStatus, IAirosNativeClient, IAirosRawStatus, IAirosSnapshot, IAirosValueMap } from './airos.types.js';
type TAirosEventHandler = (eventArg: IAirosEvent) => void;
export class AirosApiError extends Error {}
export class AirosApiConnectionError extends AirosApiError {}
export class AirosApiAuthenticationError extends AirosApiError {}
export class AirosApiNotFoundError extends AirosApiError {}
export class AirosApiDataError extends AirosApiError {}
export class AirosClient {
private snapshot?: IAirosSnapshot;
private readonly events: IAirosEvent[] = [];
private readonly eventHandlers = new Set<TAirosEventHandler>();
constructor(private readonly config: IAirosConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IAirosSnapshot> {
if (!forceRefreshArg && this.snapshot) {
return this.cloneSnapshot(this.snapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.snapshot = AirosMapper.toSnapshot(this.config, { source: 'snapshot' }, this.events);
return this.cloneSnapshot(this.snapshot);
}
try {
if (this.config.transport?.readSnapshot) {
this.snapshot = AirosMapper.mergeSnapshot(AirosMapper.toSnapshot(this.config, {}, this.events), await this.config.transport.readSnapshot());
return this.cloneSnapshot(this.snapshot);
}
if (this.config.nativeClient) {
this.snapshot = await this.snapshotFromNativeClient(this.config.nativeClient);
return this.cloneSnapshot(this.snapshot);
}
if (this.config.rawStatus) {
this.snapshot = AirosMapper.toSnapshot(this.config, { source: 'manual', connected: this.config.connected ?? true }, this.events);
return this.cloneSnapshot(this.snapshot);
}
if (this.canUseNativeHttp()) {
this.snapshot = await this.fetchSnapshot();
return this.cloneSnapshot(this.snapshot);
}
this.snapshot = this.offlineSnapshot(this.unsupportedReadMessage());
return this.cloneSnapshot(this.snapshot);
} catch (errorArg) {
this.snapshot = this.offlineSnapshot(this.errorMessage(errorArg));
return this.cloneSnapshot(this.snapshot);
}
}
public onEvent(handlerArg: TAirosEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IAirosCommandResult> {
this.snapshot = undefined;
const snapshot = await this.getSnapshot(Boolean(this.config.host || this.config.nativeClient || this.config.transport?.readSnapshot));
this.emit({ type: 'snapshot_refreshed', data: this.cloneSnapshot(snapshot), timestamp: Date.now() });
const success = snapshot.connected && !snapshot.error && snapshot.source !== 'runtime';
return { success, transmitted: false, data: { snapshot: this.cloneSnapshot(snapshot), source: snapshot.source }, error: success ? undefined : snapshot.error };
}
public async sendCommand(commandArg: IAirosCommand): Promise<IAirosCommandResult> {
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
try {
const result = await this.executeCommand(commandArg);
if (result.success) {
this.patchSnapshot(commandArg, result);
}
this.emit({
type: result.success ? 'command_succeeded' : 'command_failed',
command: commandArg,
deviceId: commandArg.deviceId,
entityId: commandArg.entityId,
data: result,
error: result.error,
timestamp: Date.now(),
});
return result;
} catch (errorArg) {
const result: IAirosCommandResult = { success: false, transmitted: false, action: commandArg.action, error: this.errorMessage(errorArg), data: { command: commandArg } };
this.emit({ type: 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, error: result.error, timestamp: Date.now() });
return result;
}
}
public async fetchSnapshot(): Promise<IAirosSnapshot> {
const client = this.localWebClient();
await client.login();
const rawStatus = await client.getRawStatus();
const derived = AirosMapper.derive(rawStatus, this.config);
const firmware = (derived.fwMajor || 0) >= 8 ? await client.updateCheck().catch(() => undefined) : undefined;
return AirosMapper.toSnapshot({ ...this.config, rawStatus, firmwareStatus: firmware }, { source: 'http', connected: true, device: { authenticated: true } }, this.events);
}
public async destroy(): Promise<void> {
this.eventHandlers.clear();
await this.config.nativeClient?.destroy?.();
}
private async snapshotFromNativeClient(clientArg: IAirosNativeClient): Promise<IAirosSnapshot> {
await clientArg.login?.();
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (this.isSnapshot(result)) {
return AirosMapper.toSnapshot({ ...this.config, snapshot: result }, { source: 'client', connected: result.connected }, this.events);
}
return AirosMapper.mergeSnapshot(AirosMapper.toSnapshot(this.config, { source: 'client', connected: true }, this.events), result);
}
const getRawStatus = clientArg.getRawStatus || clientArg.rawStatus || clientArg.status;
if (!getRawStatus) {
throw new AirosApiConnectionError('airOS nativeClient must expose getSnapshot(), getRawStatus(), rawStatus(), or status().');
}
const rawStatus = await getRawStatus.call(clientArg);
const updateCheck = clientArg.updateCheck || clientArg.update_check;
const derived = AirosMapper.derive(rawStatus, this.config);
const firmware = updateCheck && (derived.fwMajor || 0) >= 8 ? await updateCheck.call(clientArg).catch(() => undefined) : undefined;
return AirosMapper.toSnapshot({ ...this.config, rawStatus, firmwareStatus: firmware }, { source: 'client', connected: true, device: { authenticated: true } }, this.events);
}
private async executeCommand(commandArg: IAirosCommand): Promise<IAirosCommandResult> {
if (commandArg.action === 'refresh') {
return this.refresh();
}
const executor = this.executor();
if (executor) {
return this.commandResult(await executor({ ...commandArg, method: 'executor' }), { ...commandArg, method: 'executor' }, true);
}
if (this.config.transport?.execute) {
return this.commandResult(await this.config.transport.execute({ ...commandArg, method: 'executor' }), { ...commandArg, method: 'executor' }, true);
}
if (this.config.nativeClient) {
return this.executeNativeClientCommand(this.config.nativeClient, commandArg);
}
if (this.canUseNativeHttp()) {
const client = this.localWebClient();
await client.login();
switch (commandArg.action) {
case 'reboot':
return this.commandResult(await client.reboot(), commandArg, true);
case 'download_update':
return this.commandResult(await client.download(), commandArg, true);
case 'install_update':
await client.download();
return this.commandResult(await client.install(), commandArg, true);
case 'firmware_update_check':
return this.commandResult(await client.updateCheck(true), commandArg, true);
default:
return { success: false, transmitted: false, action: commandArg.action, error: `Unsupported airOS command: ${commandArg.action}`, data: { command: commandArg } };
}
}
return {
success: false,
transmitted: false,
action: commandArg.action,
error: 'airOS commands require host/password native HTTP, nativeClient, commandExecutor, or transport.execute. Static snapshots and raw status data are read-only.',
data: { command: commandArg },
};
}
private async executeNativeClientCommand(clientArg: IAirosNativeClient, commandArg: IAirosCommand): Promise<IAirosCommandResult> {
await clientArg.login?.();
switch (commandArg.action) {
case 'reboot':
if (clientArg.reboot) {
return this.commandResult(await clientArg.reboot(), { ...commandArg, method: 'native_client' }, true);
}
break;
case 'download_update':
if (clientArg.download) {
return this.commandResult(await clientArg.download(), { ...commandArg, method: 'native_client' }, true);
}
break;
case 'install_update':
if (clientArg.install) {
await clientArg.download?.();
return this.commandResult(await clientArg.install(), { ...commandArg, method: 'native_client' }, true);
}
break;
case 'firmware_update_check': {
const updateCheck = clientArg.updateCheck || clientArg.update_check;
if (updateCheck) {
return this.commandResult(await updateCheck.call(clientArg, true), { ...commandArg, method: 'native_client' }, true);
}
break;
}
default:
break;
}
return { success: false, transmitted: false, action: commandArg.action, error: `airOS nativeClient does not implement ${commandArg.action}.`, data: { command: commandArg } };
}
private commandResult(resultArg: unknown, commandArg: IAirosCommand, transmittedDefaultArg: boolean): IAirosCommandResult {
if (this.isCommandResult(resultArg)) {
return { transmitted: transmittedDefaultArg, action: commandArg.action, ...resultArg };
}
if (resultArg === false) {
return { success: false, transmitted: transmittedDefaultArg, action: commandArg.action, error: 'airOS command executor reported failure.', data: { command: commandArg, response: resultArg } };
}
if (resultArg && typeof resultArg === 'object') {
const record = resultArg as Record<string, unknown>;
if (record.ok === false || record.error) {
return { success: false, transmitted: transmittedDefaultArg, action: commandArg.action, error: String(record.error || 'airOS command returned ok=false.'), data: { command: commandArg, response: resultArg } };
}
}
return { success: true, transmitted: transmittedDefaultArg, action: commandArg.action, data: { command: commandArg, response: resultArg } };
}
private patchSnapshot(commandArg: IAirosCommand, resultArg: IAirosCommandResult): void {
const current = this.snapshot || AirosMapper.toSnapshot(this.config, {}, this.events);
this.snapshot = AirosMapper.mergeSnapshot(current, {
metadata: { lastCommand: commandArg.action, lastCommandTransmitted: resultArg.transmitted, lastCommandAt: new Date().toISOString() },
updatedAt: new Date().toISOString(),
});
this.emit({ type: 'state_changed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: this.cloneSnapshot(this.snapshot), timestamp: Date.now() });
}
private executor(): ((commandArg: IAirosCommand) => Promise<IAirosCommandResult | unknown>) | undefined {
if (!this.config.commandExecutor) {
return undefined;
}
if (typeof this.config.commandExecutor === 'function') {
return this.config.commandExecutor;
}
return this.config.commandExecutor.execute.bind(this.config.commandExecutor);
}
private canUseNativeHttp(): boolean {
return this.config.nativeHttpEnabled !== false && Boolean(this.config.host && this.config.password);
}
private localWebClient(): AirosLocalWebClient {
return new AirosLocalWebClient({
host: this.config.host as string,
port: this.config.port,
username: this.config.username || airosDefaultUsername,
password: this.config.password as string,
ssl: this.config.ssl ?? this.config.useSsl ?? this.config.advanced_settings?.ssl ?? true,
verifySsl: this.config.verifySsl ?? this.config.verify_ssl ?? this.config.advanced_settings?.verifySsl ?? this.config.advanced_settings?.verify_ssl ?? false,
timeoutMs: this.config.timeoutMs || airosDefaultTimeoutMs,
});
}
private offlineSnapshot(errorArg: string): IAirosSnapshot {
return AirosMapper.toSnapshot(this.config, { connected: false, source: 'runtime', error: errorArg, device: { available: false } }, this.events);
}
private unsupportedReadMessage(): string {
return 'No airOS host/password native HTTP, nativeClient, transport.readSnapshot, snapshot, or rawStatus is configured.';
}
private isSnapshot(valueArg: unknown): valueArg is IAirosSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'host' in valueArg && 'wireless' in valueArg && 'services' in valueArg);
}
private isCommandResult(valueArg: unknown): valueArg is IAirosCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private emit(eventArg: IAirosEvent): void {
this.events.push(eventArg);
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot(snapshotArg: IAirosSnapshot): IAirosSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IAirosSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
export class AirosLocalWebClient implements IAirosNativeClient {
private readonly baseUrl: URL;
private authCookie?: string;
private csrfId?: string;
private connected = false;
constructor(private readonly options: {
host: string;
port?: number;
username?: string;
password: string;
ssl?: boolean;
verifySsl?: boolean;
timeoutMs?: number;
}) {
this.baseUrl = this.resolveBaseUrl(options);
}
public async login(): Promise<boolean> {
try {
await this.requestJson('POST', '/api/auth', { json: { username: this.options.username || airosDefaultUsername, password: this.options.password }, isLogin: true });
this.connected = true;
return true;
} catch (errorArg) {
if (errorArg instanceof AirosApiNotFoundError) {
await this.loginV6();
return true;
}
throw errorArg;
}
}
public async getSnapshot(): Promise<IAirosSnapshot> {
await this.login();
const rawStatus = await this.getRawStatus();
const firmware = (AirosMapper.derive(rawStatus).fwMajor || 0) >= 8 ? await this.updateCheck().catch(() => undefined) : undefined;
return AirosMapper.toSnapshot({ host: this.options.host, port: this.options.port, username: this.options.username, ssl: this.options.ssl, verifySsl: this.options.verifySsl, rawStatus, firmwareStatus: firmware }, { source: 'http', connected: true, device: { authenticated: true } });
}
public async getRawStatus(): Promise<IAirosRawStatus> {
if (!this.connected) {
await this.login();
}
return this.requestJson('GET', '/status.cgi', { authenticated: true }) as Promise<IAirosRawStatus>;
}
public async updateCheck(forceArg = false): Promise<IAirosFirmwareStatus> {
if (!this.connected) {
await this.login();
}
return this.requestJson('POST', '/api/fw/update-check', { authenticated: true, json: forceArg ? { force: true } : {} }) as Promise<IAirosFirmwareStatus>;
}
public async reboot(): Promise<IAirosCommandResult | IAirosValueMap> {
if (!this.connected) {
await this.login();
}
const result = await this.requestJson('POST', '/reboot.cgi', { authenticated: true, form: { reboot: 'yes' } }) as IAirosValueMap;
const okValue = result.ok;
if (okValue === true || okValue === 'true') {
return { ...result, success: true, transmitted: true };
}
return { ...result, success: false, transmitted: true, error: 'airOS reboot endpoint did not return ok=true.' };
}
public async download(): Promise<IAirosValueMap> {
if (!this.connected) {
await this.login();
}
return this.requestJson('POST', '/api/fw/download', { authenticated: true, json: {} }) as Promise<IAirosValueMap>;
}
public async install(): Promise<IAirosValueMap> {
if (!this.connected) {
await this.login();
}
return this.requestJson('POST', '/fwflash.cgi', { authenticated: true, json: { do_update: 1 } }) as Promise<IAirosValueMap>;
}
private async loginV6(): Promise<void> {
const preflight = await this.request('GET', '/login.cgi', { followRedirects: false });
this.storeAuth(preflight.headers);
if (!this.authCookie) {
throw new AirosApiConnectionError('No airOS v6 session cookie received.');
}
const login = await this.request('POST', '/login.cgi', {
followRedirects: false,
form: { username: this.options.username || airosDefaultUsername, password: this.options.password, uri: '/index.cgi' },
headers: { Origin: this.origin(), Referer: this.url('/login.cgi').toString(), Cookie: this.authCookie },
});
if (login.statusCode !== 302) {
throw new AirosApiAuthenticationError('airOS v6 login failed.');
}
const activation = await this.request('GET', '/index.cgi', { followRedirects: false, headers: { Referer: this.url('/login.cgi').toString(), Cookie: this.authCookie } });
if (String(activation.headers.location || '').includes('login.cgi')) {
throw new AirosApiAuthenticationError('airOS v6 session activation failed.');
}
this.connected = true;
}
private async requestJson(methodArg: string, pathArg: string, optionsArg: IAirosRequestOptions = {}): Promise<unknown> {
const response = await this.request(methodArg, pathArg, optionsArg);
if (optionsArg.isLogin) {
this.storeAuth(response.headers);
}
if (response.statusCode === 401 || response.statusCode === 403) {
throw new AirosApiAuthenticationError(`airOS request to ${pathArg} failed with HTTP ${response.statusCode}.`);
}
if (response.statusCode === 404) {
throw new AirosApiNotFoundError(`airOS endpoint not found: ${pathArg}.`);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new AirosApiConnectionError(`airOS request to ${pathArg} failed with HTTP ${response.statusCode}.`);
}
this.storeAuth(response.headers);
if (!response.body.trim()) {
return {};
}
try {
return JSON.parse(response.body) as unknown;
} catch (errorArg) {
throw new AirosApiDataError(`airOS endpoint ${pathArg} did not return JSON: ${errorArg instanceof Error ? errorArg.message : String(errorArg)}`);
}
}
private async request(methodArg: string, pathArg: string, optionsArg: IAirosRequestOptions = {}): Promise<IAirosHttpResponse> {
const targetUrl = this.url(pathArg);
const body = optionsArg.json !== undefined ? JSON.stringify(optionsArg.json) : optionsArg.form ? new URLSearchParams(Object.entries(optionsArg.form).map(([key, value]) => [key, String(value)])).toString() : undefined;
const headers: Record<string, string> = { ...(optionsArg.headers || {}) };
if (optionsArg.json !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (optionsArg.form) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (body !== undefined) {
headers['Content-Length'] = Buffer.byteLength(body).toString();
}
if (optionsArg.authenticated) {
if (!this.connected) {
throw new AirosApiConnectionError('Not connected to airOS device; login first.');
}
if (this.csrfId) {
headers['X-CSRF-ID'] = this.csrfId;
}
if (this.authCookie) {
headers.Cookie = this.authCookie;
}
}
return new Promise<IAirosHttpResponse>((resolve, reject) => {
const requestOptions: http.RequestOptions & { rejectUnauthorized?: boolean } = {
method: methodArg,
headers,
timeout: this.options.timeoutMs || airosDefaultTimeoutMs,
};
if (targetUrl.protocol === 'https:') {
requestOptions.rejectUnauthorized = this.options.verifySsl !== false;
}
const transport = targetUrl.protocol === 'https:' ? https : http;
const request = transport.request(targetUrl, requestOptions, (response) => {
const chunks: Buffer[] = [];
response.on('data', (chunkArg) => chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
response.on('end', () => resolve({ statusCode: response.statusCode || 0, headers: response.headers, body: Buffer.concat(chunks).toString('utf8') }));
});
request.on('timeout', () => request.destroy(new AirosApiConnectionError(`airOS request to ${targetUrl.toString()} timed out.`)));
request.on('error', reject);
if (body !== undefined) {
request.write(body);
}
request.end();
});
}
private storeAuth(headersArg: http.IncomingHttpHeaders): void {
const csrf = headersArg['x-csrf-id'];
if (typeof csrf === 'string' && csrf) {
this.csrfId = csrf;
}
const setCookie = headersArg['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
const airosCookie = cookies.map((cookieArg) => cookieArg.split(';', 1)[0]).find((cookieArg) => cookieArg.startsWith('AIROS_') || cookieArg.startsWith('AIROS'));
if (airosCookie) {
this.authCookie = airosCookie;
}
}
private resolveBaseUrl(optionsArg: { host: string; port?: number; ssl?: boolean }): URL {
const ssl = optionsArg.ssl ?? true;
const base = new URL(optionsArg.host.includes('://') ? optionsArg.host : `${ssl ? 'https' : 'http'}://${optionsArg.host}`);
if (optionsArg.port && !base.port) {
base.port = String(optionsArg.port);
}
if (!base.port) {
base.port = String(ssl ? airosDefaultHttpsPort : airosDefaultHttpPort);
}
return base;
}
private url(pathArg: string): URL {
return new URL(pathArg, this.baseUrl);
}
private origin(): string {
return `${this.baseUrl.protocol}//${this.baseUrl.host}`;
}
}
interface IAirosRequestOptions {
authenticated?: boolean;
isLogin?: boolean;
followRedirects?: boolean;
headers?: Record<string, string>;
json?: Record<string, unknown>;
form?: Record<string, unknown>;
}
interface IAirosHttpResponse {
statusCode: number;
headers: http.IncomingHttpHeaders;
body: string;
}
@@ -0,0 +1,169 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { airosDefaultHttpPort, airosDefaultHttpsPort, airosDefaultName, airosDefaultSsl, airosDefaultTimeoutMs, airosDefaultUsername, airosDefaultVerifySsl } from './airos.constants.js';
import type { IAirosConfig, IAirosFirmwareStatus, IAirosRawStatus, IAirosSnapshot } from './airos.types.js';
export class AirosConfigFlow implements IConfigFlow<IAirosConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAirosConfig>> {
void contextArg;
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IAirosSnapshot | undefined;
const rawStatus = metadata.rawStatus as IAirosRawStatus | undefined;
const ssl = this.booleanValue(metadata.ssl) ?? snapshot?.device.ssl ?? airosDefaultSsl;
const verifySsl = this.booleanValue(metadata.verifySsl ?? metadata.verify_ssl) ?? snapshot?.device.verifySsl ?? airosDefaultVerifySsl;
const port = candidateArg.port || snapshot?.device.port || (ssl ? airosDefaultHttpsPort : airosDefaultHttpPort);
const host = candidateArg.host || this.stringValue(metadata.host) || snapshot?.device.host || '';
const username = this.stringValue(metadata.username) || airosDefaultUsername;
return {
kind: 'form',
title: candidateArg.source === 'dhcp' || candidateArg.source === 'custom' ? 'Confirm Ubiquiti airOS device' : 'Connect Ubiquiti airOS device',
description: 'Provide local airOS web credentials. Native HTTP(S) is used for snapshots, reboot, and firmware update actions unless an injected client/executor is configured.',
fields: [
{ name: 'host', label: host ? `Host (${host})` : 'Host', type: 'text', required: !snapshot && !rawStatus },
{ name: 'username', label: `Username (${username})`, type: 'text' },
{ name: 'password', label: 'Password', type: 'password', required: !snapshot && !rawStatus },
{ name: 'ssl', label: `Use HTTPS (${ssl ? 'yes' : 'no'})`, type: 'boolean' },
{ name: 'verifySsl', label: `Verify SSL certificate (${verifySsl ? 'yes' : 'no'})`, type: 'boolean' },
{ name: 'nativeHttpEnabled', label: 'Enable native local airOS web API', type: 'boolean' },
{ name: 'name', label: candidateArg.name ? `Name (${candidateArg.name})` : 'Name', type: 'text' },
{ name: 'port', label: `Port (${port})`, type: 'number' },
{ name: 'timeoutMs', label: `Timeout ms (${airosDefaultTimeoutMs})`, type: 'number' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
{ name: 'rawStatusJson', label: 'Raw status.cgi JSON', type: 'text' },
{ name: 'firmwareJson', label: 'Firmware update JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IAirosConfig>> {
const metadata = candidateArg.metadata || {};
const candidateSnapshot = metadata.snapshot as IAirosSnapshot | undefined;
const candidateRawStatus = metadata.rawStatus as IAirosRawStatus | undefined;
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson) || candidateSnapshot;
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid airOS snapshot', error: snapshot.message };
}
const rawStatus = this.objectFromInput<IAirosRawStatus>(valuesArg.rawStatusJson, 'Raw status JSON must be an object.') || candidateRawStatus;
if (rawStatus instanceof Error) {
return { kind: 'error', title: 'Invalid airOS raw status', error: rawStatus.message };
}
const firmwareStatus = this.objectFromInput<IAirosFirmwareStatus>(valuesArg.firmwareJson, 'Firmware JSON must be an object.') || metadata.firmwareStatus as IAirosFirmwareStatus | undefined;
if (firmwareStatus instanceof Error) {
return { kind: 'error', title: 'Invalid airOS firmware data', error: firmwareStatus.message };
}
const host = this.stringValue(valuesArg.host) || candidateArg.host || this.stringValue(metadata.host) || snapshot?.device.host;
if (!host && !snapshot && !rawStatus) {
return { kind: 'error', title: 'airOS setup failed', error: 'airOS setup requires a host unless a snapshot or raw status JSON is provided.' };
}
const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password);
if (!password && !snapshot && !rawStatus) {
return { kind: 'error', title: 'airOS setup failed', error: 'airOS setup requires a password unless a snapshot or raw status JSON is provided.' };
}
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(metadata.ssl) ?? snapshot?.device.ssl ?? airosDefaultSsl;
const port = this.numberValue(valuesArg.port) ?? candidateArg.port ?? snapshot?.device.port ?? (ssl ? airosDefaultHttpsPort : airosDefaultHttpPort);
if (port < 1 || port > 65535) {
return { kind: 'error', title: 'airOS setup failed', error: 'Port must be between 1 and 65535.' };
}
const timeoutMs = this.numberValue(valuesArg.timeoutMs) ?? airosDefaultTimeoutMs;
if (timeoutMs < 1) {
return { kind: 'error', title: 'airOS setup failed', error: 'Timeout must be greater than zero.' };
}
const config: IAirosConfig = {
id: candidateArg.id || candidateArg.macAddress || snapshot?.device.id || host,
host,
port,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || this.stringValue(rawStatus?.host?.hostname) || airosDefaultName,
username: this.stringValue(valuesArg.username) || this.stringValue(metadata.username) || airosDefaultUsername,
password,
ssl,
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl ?? metadata.verify_ssl) ?? snapshot?.device.verifySsl ?? airosDefaultVerifySsl,
nativeHttpEnabled: this.booleanValue(valuesArg.nativeHttpEnabled) ?? true,
timeoutMs,
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer,
model: candidateArg.model || snapshot?.device.modelName || this.stringValue(rawStatus?.host?.devmodel),
macAddress: candidateArg.macAddress || snapshot?.device.macAddress,
snapshot,
rawStatus,
firmwareStatus,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: metadata,
nativeHttpImplemented: true,
configFlowConnectionVerified: false,
},
};
return {
kind: 'done',
title: 'Ubiquiti airOS device configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IAirosSnapshot | undefined | Error {
const parsed = this.objectFromInput<IAirosSnapshot>(valueArg, 'Snapshot JSON must be an object.');
if (!parsed || parsed instanceof Error) {
return parsed;
}
if (!parsed.device || !parsed.host || !parsed.services || !parsed.wireless) {
return new Error('Snapshot JSON must include device, host, services, and wireless objects.');
}
return parsed;
}
private objectFromInput<TValue>(valueArg: unknown, errorArg: string): TValue | undefined | Error {
if (valueArg && typeof valueArg === 'object') {
return valueArg as TValue;
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return new Error(errorArg);
}
return parsed as TValue;
} catch (errorArg2) {
return errorArg2 instanceof Error ? errorArg2 : new Error(String(errorArg2));
}
}
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') {
const normalized = valueArg.trim().toLowerCase();
if (normalized === 'true' || normalized === 'yes' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === 'no' || normalized === '0') {
return false;
}
}
return undefined;
}
}
@@ -1,27 +1,104 @@
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 { AirosClient } from './airos.classes.client.js';
import { AirosConfigFlow } from './airos.classes.configflow.js';
import { airosDefaultName, airosDisplayName, airosDomain } from './airos.constants.js';
import { createAirosDiscoveryDescriptor } from './airos.discovery.js';
import { AirosMapper } from './airos.mapper.js';
import type { IAirosConfig } from './airos.types.js';
export class HomeAssistantAirosIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "airos",
displayName: "Ubiquiti airOS",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/airos",
"upstreamDomain": "airos",
"integrationType": "device",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"airos==0.6.5"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@CoMPaTech"
]
},
});
export class AirosIntegration extends BaseIntegration<IAirosConfig> {
public readonly domain = airosDomain;
public readonly displayName = airosDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAirosDiscoveryDescriptor();
public readonly configFlow = new AirosConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/airos',
upstreamDomain: airosDomain,
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: 'platinum',
requirements: ['airos==0.6.5'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@CoMPaTech'],
documentation: 'https://www.home-assistant.io/integrations/airos',
configFlow: true,
discovery: {
dhcp: ['registered_devices:true'],
custom: ['UDP broadcast listener on port 10002 using python-airos packet format'],
manual: true,
note: 'Home Assistant declares DHCP registered-device discovery. This native port supports that signal, native UDP airOS broadcasts, and manual host/snapshot/raw-status candidates.',
},
runtime: {
type: 'control-runtime',
polling: 'local airOS web API status.cgi/update-check or supplied snapshot/nativeClient',
platforms: ['binary_sensor', 'button', 'sensor', 'update'],
services: ['airos.snapshot', 'airos.refresh', 'airos.reboot', 'airos.firmware_update_check', 'airos.download_update', 'airos.install_update', 'button.press', 'update.install'],
},
localApi: {
implemented: [
'DHCP registered-device/manual matching and native UDP broadcast packet parsing/listening on port 10002',
'Config flow shape for host, username, password, HTTPS, verify SSL, native HTTP, port, timeout, snapshot JSON, raw status JSON, and firmware JSON',
'Native airOS 8 /api/auth login, status.cgi snapshots, update-check, firmware download/install, and reboot.cgi where host/password are configured',
'Fallback airOS 6 login.cgi session login for status.cgi snapshots and reboot.cgi; firmware update actions remain airOS 8 only by endpoint availability',
'Snapshot-to-device/entity mapper for Home Assistant sensors, binary sensors, reboot button, and firmware update entity',
],
explicitUnsupported: [
'UISP cloud APIs',
'Station kick/provisioning-mode commands as runtime services because Home Assistant airos does not expose them as entities/services',
'Claiming command success without host/password native HTTP, nativeClient command method, commandExecutor, or transport.execute',
],
},
};
public async setup(configArg: IAirosConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new AirosRuntime(new AirosClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAirosIntegration extends AirosIntegration {}
class AirosRuntime implements IIntegrationRuntime {
public domain = airosDomain;
constructor(private readonly client: AirosClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AirosMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AirosMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AirosMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === airosDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === airosDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = AirosMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported airOS service or target: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
+27
View File
@@ -0,0 +1,27 @@
export const airosDomain = 'airos';
export const airosDisplayName = 'Ubiquiti airOS';
export const airosManufacturer = 'Ubiquiti';
export const airosDefaultName = 'airOS device';
export const airosDefaultUsername = 'ubnt';
export const airosDefaultSsl = true;
export const airosDefaultVerifySsl = false;
export const airosDefaultHttpPort = 80;
export const airosDefaultHttpsPort = 443;
export const airosDefaultTimeoutMs = 5000;
export const airosDiscoveryPort = 10002;
export const airosDiscoveryTimeoutMs = 30000;
export const airosAttribution = 'Data provided by Ubiquiti airOS local web API';
export const airosTextHints = [
'airos',
'airmax',
'airfiber',
'ubiquiti',
'uisp',
'litebeam',
'nanobeam',
'nanostation',
'powerbeam',
'rocket',
'prismstation',
];
+398
View File
@@ -0,0 +1,398 @@
import * as dgram from 'node:dgram';
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryProbe, IDiscoveryProbeResult, IDiscoveryValidator } from '../../core/types.js';
import { airosDefaultHttpsPort, airosDefaultName, airosDefaultSsl, airosDefaultVerifySsl, airosDiscoveryPort, airosDiscoveryTimeoutMs, airosDisplayName, airosDomain, airosManufacturer, airosTextHints } from './airos.constants.js';
import type { IAirosCandidateMetadata, IAirosDhcpRecord, IAirosLocalDiscoveryRecord, IAirosManualEntry } from './airos.types.js';
export class AirosDiscoveryParseError extends Error {}
export class AirosUdpDiscoveryProbe implements IDiscoveryProbe {
public id = 'airos-udp-broadcast-probe';
public source = 'custom' as const;
public description = 'Listen for native airOS UDP discovery broadcasts on port 10002.';
constructor(private readonly timeoutMs = airosDiscoveryTimeoutMs) {}
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
const candidates = new Map<string, IDiscoveryCandidate>();
const matcher = new AirosLocalDiscoveryMatcher();
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
socket.off('error', onError);
socket.off('listening', onListening);
};
const onError = (errorArg: Error): void => {
cleanup();
reject(errorArg);
};
const onListening = (): void => {
cleanup();
try {
socket.setBroadcast(true);
} catch {
// Some platforms do not allow toggling broadcast on a receive-only socket.
}
resolve();
};
socket.once('error', onError);
socket.once('listening', onListening);
socket.bind(airosDiscoveryPort, '0.0.0.0');
});
const onMessage = (messageArg: Buffer, remoteArg: dgram.RemoteInfo): void => {
void (async () => {
try {
const parsed = parseAirosDiscoveryPacket(messageArg, remoteArg.address);
const match = await matcher.matches(parsed);
if (match.matched && match.candidate) {
candidates.set(match.normalizedDeviceId || match.candidate.id || match.candidate.host || remoteArg.address, match.candidate);
}
} catch (errorArg) {
contextArg.logger?.log('debug', 'Ignoring malformed airOS discovery packet.', { error: errorArg instanceof Error ? errorArg.message : String(errorArg) });
}
})();
};
socket.on('message', onMessage);
try {
await waitForDiscovery(this.timeoutMs, contextArg.abortSignal);
} finally {
socket.off('message', onMessage);
socket.close();
}
return { candidates: [...candidates.values()] };
}
}
export class AirosDhcpMatcher implements IDiscoveryMatcher<IAirosDhcpRecord> {
public id = 'airos-dhcp-registered-device-match';
public source = 'dhcp' as const;
public description = 'Recognize Home Assistant registered-device DHCP updates and Ubiquiti airOS DHCP metadata.';
public async matches(recordArg: IAirosDhcpRecord): Promise<IDiscoveryMatch> {
const metadata = recordArg.metadata || {};
const host = recordArg.host || recordArg.ipAddress || recordArg.address || recordArg.ip || stringValue(metadata.host);
const hostname = recordArg.hostname || recordArg.hostName || stringValue(metadata.hostname);
const macAddress = normalizeAirosMac(recordArg.macAddress || recordArg.macaddress || recordArg.mac || stringValue(metadata.macAddress));
const model = recordArg.model || stringValue(metadata.model) || stringValue(metadata.modelName);
const registeredDevice = recordArg.registeredDevice === true || metadata.registeredDevice === true || metadata.registered_devices === true || metadata.homeAssistantRegisteredDevice === true;
const textMatched = airosText(recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
const matched = recordArg.integrationDomain === airosDomain || metadata.airos === true || registeredDevice && (metadata.integrationDomain === airosDomain || metadata.domain === airosDomain) || textMatched;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP record is not a registered airOS device and has no Ubiquiti airOS metadata.' };
}
const id = macAddress || hostname || host;
return {
matched: true,
confidence: registeredDevice && host ? 'certain' : host ? 'high' : 'medium',
reason: registeredDevice ? 'DHCP record matches Home Assistant registered-device discovery for airOS.' : 'DHCP metadata identifies a Ubiquiti airOS device.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: airosDomain,
id,
host,
port: airosDefaultHttpsPort,
name: hostname || airosDefaultName,
manufacturer: recordArg.manufacturer || airosManufacturer,
model: model || airosDisplayName,
macAddress,
metadata: {
...metadata,
airos: true,
discoveryProtocol: 'dhcp-registered-device',
hostname,
registeredDevice,
ssl: airosDefaultSsl,
verifySsl: airosDefaultVerifySsl,
nativeHttpImplemented: true,
} satisfies IAirosCandidateMetadata,
},
metadata: { registeredDevice, textMatched },
};
}
}
export class AirosLocalDiscoveryMatcher implements IDiscoveryMatcher<IAirosLocalDiscoveryRecord> {
public id = 'airos-udp-broadcast-match';
public source = 'custom' as const;
public description = 'Recognize normalized airOS UDP broadcast records from the native discovery listener.';
public async matches(recordArg: IAirosLocalDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = recordArg.metadata || {};
const host = recordArg.host || recordArg.ipAddress || recordArg.ip_address || recordArg.ip || stringValue(metadata.ip_address) || stringValue(metadata.host);
const macAddress = normalizeAirosMac(recordArg.macAddress || recordArg.mac_address || recordArg.mac || stringValue(metadata.macAddress));
const hostname = nullToUndefined(recordArg.hostname) || stringValue(metadata.hostname);
const firmwareVersion = nullToUndefined(recordArg.firmwareVersion) || nullToUndefined(recordArg.firmware_version) || stringValue(metadata.firmwareVersion);
const fullModelName = nullToUndefined(recordArg.fullModelName) || nullToUndefined(recordArg.full_model_name) || stringValue(metadata.fullModelName);
const model = nullToUndefined(recordArg.model) || fullModelName || stringValue(metadata.model);
const matched = Boolean(host && (macAddress || firmwareVersion || model || metadata.airos === true || recordArg.integrationDomain === airosDomain));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Local discovery record is not an airOS UDP broadcast.' };
}
const id = macAddress || host;
return {
matched: true,
confidence: host && macAddress ? 'certain' : host ? 'high' : 'medium',
reason: 'Native airOS UDP broadcast contains local host and device metadata.',
normalizedDeviceId: id,
candidate: {
source: 'custom',
integrationDomain: airosDomain,
id,
host,
port: airosDefaultHttpsPort,
name: hostname || airosDefaultName,
manufacturer: recordArg.manufacturer || airosManufacturer,
model: model || airosDisplayName,
macAddress,
metadata: {
...metadata,
airos: true,
discoveryProtocol: 'airos-udp-broadcast',
hostname,
firmwareVersion,
fullModelName,
ssid: nullToUndefined(recordArg.ssid) || stringValue(metadata.ssid),
uptimeSeconds: recordArg.uptimeSeconds ?? recordArg.uptime_seconds ?? metadata.uptimeSeconds,
ssl: airosDefaultSsl,
verifySsl: airosDefaultVerifySsl,
nativeHttpImplemented: true,
} satisfies IAirosCandidateMetadata,
},
};
}
}
export class AirosManualMatcher implements IDiscoveryMatcher<IAirosManualEntry> {
public id = 'airos-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual airOS host, credential, raw status, and snapshot setup entries.';
public async matches(inputArg: IAirosManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot as IAirosManualEntry['snapshot'];
const rawStatus = inputArg.rawStatus || metadata.rawStatus as IAirosManualEntry['rawStatus'];
const host = inputArg.host || inputArg.ipAddress || inputArg.address || inputArg.ip || snapshot?.device.host || stringValue(metadata.host);
const macAddress = normalizeAirosMac(inputArg.macAddress || inputArg.mac || snapshot?.device.macAddress || stringValue(metadata.macAddress));
const model = inputArg.modelName || inputArg.model || snapshot?.device.modelName || stringValue(rawStatus?.host?.devmodel) || stringValue(metadata.modelName) || stringValue(metadata.model);
const textMatched = airosText(inputArg.integrationDomain, inputArg.manufacturer, model, inputArg.name, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
const hasCredentials = Boolean(inputArg.password || metadata.password);
const matched = inputArg.integrationDomain === airosDomain
|| metadata.airos === true
|| Boolean(snapshot)
|| Boolean(rawStatus)
|| textMatched
|| Boolean(host && hasCredentials);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain airOS setup data.' };
}
const id = inputArg.id || macAddress || snapshot?.device.id || host;
return {
matched: true,
confidence: snapshot || rawStatus ? 'certain' : host && hasCredentials ? 'high' : host ? 'medium' : 'low',
reason: snapshot ? 'Manual entry includes an airOS snapshot.' : rawStatus ? 'Manual entry includes raw airOS status data.' : 'Manual entry can start airOS setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: airosDomain,
id,
host,
port: inputArg.port || snapshot?.device.port || (resolveSsl(inputArg) ? airosDefaultHttpsPort : 80),
name: inputArg.name || snapshot?.device.name || stringValue(rawStatus?.host?.hostname) || airosDefaultName,
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || airosManufacturer,
model: model || airosDisplayName,
macAddress,
metadata: {
...metadata,
airos: true,
discoveryProtocol: 'manual',
username: inputArg.username || stringValue(metadata.username),
ssl: resolveSsl(inputArg),
verifySsl: resolveVerifySsl(inputArg),
snapshot,
rawStatus,
nativeHttpImplemented: true,
} satisfies IAirosCandidateMetadata,
},
metadata: { snapshotConfigured: Boolean(snapshot), rawStatusConfigured: Boolean(rawStatus), credentialsConfigured: hasCredentials },
};
}
}
export class AirosCandidateValidator implements IDiscoveryValidator {
public id = 'airos-candidate-validator';
public description = 'Validate airOS candidates from DHCP, UDP discovery, and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IAirosManualEntry['snapshot'];
const rawStatus = metadata.rawStatus as IAirosManualEntry['rawStatus'];
const textMatched = airosText(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
const matched = candidateArg.integrationDomain === airosDomain || metadata.airos === true || Boolean(snapshot) || Boolean(rawStatus) || textMatched;
const usable = Boolean(candidateArg.host || snapshot || rawStatus);
if (!matched || !usable) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'airOS candidate lacks a host, snapshot, or raw status data.' : 'Candidate is not airOS.',
};
}
const ssl = booleanValue(metadata.ssl) ?? airosDefaultSsl;
return {
matched: true,
confidence: snapshot || rawStatus || candidateArg.macAddress ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has airOS metadata and a usable local source.',
candidate: { ...candidateArg, port: candidateArg.port || (ssl ? airosDefaultHttpsPort : 80) },
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host || snapshot?.device.id,
metadata: { snapshotConfigured: Boolean(snapshot), rawStatusConfigured: Boolean(rawStatus), ssl },
};
}
}
export const createAirosDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: airosDomain, displayName: airosDisplayName })
.addProbe(new AirosUdpDiscoveryProbe())
.addMatcher(new AirosDhcpMatcher())
.addMatcher(new AirosLocalDiscoveryMatcher())
.addMatcher(new AirosManualMatcher())
.addValidator(new AirosCandidateValidator());
};
export const parseAirosDiscoveryPacket = (dataArg: Uint8Array, hostIpArg: string): IAirosLocalDiscoveryRecord => {
const data = Buffer.from(dataArg);
if (data.length < 6) {
throw new AirosDiscoveryParseError(`Packet too short for airOS header: ${data.length} bytes.`);
}
if (data[0] !== 0x01 || data[1] !== 0x06) {
throw new AirosDiscoveryParseError(`Packet does not start with airOS header 0x01 0x06: ${data.subarray(0, 2).toString('hex')}.`);
}
const parsed: IAirosLocalDiscoveryRecord = { ip_address: hostIpArg };
let offset = 6;
while (offset < data.length) {
const tlvType = data[offset];
offset += 1;
if (tlvType === 0x06) {
if (data.length - offset < 6) {
throw new AirosDiscoveryParseError('Truncated airOS MAC address TLV.');
}
parsed.mac_address = [...data.subarray(offset, offset + 6)].map((byteArg) => byteArg.toString(16).padStart(2, '0')).join(':');
offset += 6;
continue;
}
if (!twoByteLengthTlvTypes.has(tlvType)) {
throw new AirosDiscoveryParseError(`Unhandled airOS discovery TLV type 0x${tlvType.toString(16)}.`);
}
if (data.length - offset < 2) {
throw new AirosDiscoveryParseError(`Truncated airOS discovery TLV length for type 0x${tlvType.toString(16)}.`);
}
const length = data.readUInt16BE(offset);
offset += 2;
if (length > data.length - offset) {
throw new AirosDiscoveryParseError(`airOS discovery TLV type 0x${tlvType.toString(16)} length ${length} exceeds remaining packet data.`);
}
const value = data.subarray(offset, offset + length);
switch (tlvType) {
case 0x02:
if (length === 10) {
parsed.ip_address = [...value.subarray(6, 10)].join('.');
}
break;
case 0x03:
parsed.firmware_version = value.toString('ascii');
break;
case 0x0a:
if (length === 4) {
parsed.uptime_seconds = value.readUInt32BE(0);
}
break;
case 0x0b:
parsed.hostname = value.toString('utf8');
break;
case 0x0c:
parsed.model = value.toString('ascii');
break;
case 0x0d:
parsed.ssid = value.toString('utf8');
break;
case 0x14:
parsed.full_model_name = value.toString('utf8');
break;
default:
break;
}
offset += length;
}
return parsed;
};
export const normalizeAirosMac = (valueArg?: string | null): string | undefined => {
if (!valueArg) {
return undefined;
}
const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase();
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined;
};
const twoByteLengthTlvTypes = new Set([0x02, 0x03, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x10, 0x14, 0x18]);
const waitForDiscovery = async (timeoutMsArg: number, abortSignalArg?: AbortSignal): Promise<void> => {
if (abortSignalArg?.aborted) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMsArg);
const onAbort = (): void => {
clearTimeout(timer);
resolve();
};
abortSignalArg?.addEventListener('abort', onAbort, { once: true });
});
};
const airosText = (...valuesArg: unknown[]): string[] => {
return [valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase()];
};
const resolveSsl = (inputArg: IAirosManualEntry): boolean => {
return inputArg.ssl ?? inputArg.useSsl ?? inputArg.advanced_settings?.ssl ?? airosDefaultSsl;
};
const resolveVerifySsl = (inputArg: IAirosManualEntry): boolean => {
return inputArg.verifySsl ?? inputArg.verify_ssl ?? inputArg.advanced_settings?.verifySsl ?? inputArg.advanced_settings?.verify_ssl ?? airosDefaultVerifySsl;
};
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};
const nullToUndefined = (valueArg: string | null | undefined): string | undefined => valueArg || undefined;
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (normalized === 'true' || normalized === 'yes' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === 'no' || normalized === '0') {
return false;
}
}
return undefined;
};
+491
View File
@@ -0,0 +1,491 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js';
import { airosAttribution, airosDefaultHttpPort, airosDefaultHttpsPort, airosDefaultName, airosDefaultSsl, airosDefaultVerifySsl, airosDefaultUsername, airosDomain, airosManufacturer } from './airos.constants.js';
import type { IAirosCommand, IAirosConfig, IAirosDerivedSnapshot, IAirosEvent, IAirosFirmwareStatus, IAirosRawInterface, IAirosRawStatus, IAirosSnapshot, IAirosValueMap, TAirosWirelessMode, TAirosWirelessRole } from './airos.types.js';
export class AirosMapper {
public static toSnapshot(configArg: IAirosConfig = {}, overridesArg: Partial<IAirosSnapshot> = {}, eventsArg: IAirosEvent[] = []): IAirosSnapshot {
const source = configArg.snapshot;
const rawStatus = overridesArg.rawStatus || configArg.rawStatus || source?.rawStatus;
const firmware = overridesArg.firmware || configArg.firmwareStatus || source?.firmware;
const hostData = this.objectValue(rawStatus?.host);
const servicesData = this.objectValue(rawStatus?.services);
const wirelessData = this.objectValue(rawStatus?.wireless);
const derived = this.derive(rawStatus, configArg, source?.derived);
const ssl = overridesArg.device?.ssl ?? this.sslValue(configArg, source?.device.ssl);
const port = overridesArg.device?.port || configArg.port || source?.device.port || (ssl ? airosDefaultHttpsPort : airosDefaultHttpPort);
const host = configArg.host || configArg.ipAddress || configArg.address || configArg.ip || source?.device.host;
const fwVersion = this.stringValue(overridesArg.device?.firmwareVersion) || this.stringValue(hostData?.fwversion) || source?.device.firmwareVersion;
const fwMajor = overridesArg.device?.firmwareMajor ?? derived.fwMajor ?? source?.device.firmwareMajor;
const name = overridesArg.device?.name || configArg.name || source?.device.name || this.stringValue(hostData?.hostname) || airosDefaultName;
const rawPresent = Boolean(rawStatus);
const connected = overridesArg.connected ?? configArg.connected ?? source?.connected ?? rawPresent;
const sourceName = overridesArg.source || source?.source || (rawPresent ? 'manual' : 'runtime');
const updatedAt = overridesArg.updatedAt || source?.updatedAt || new Date().toISOString();
return {
connected,
source: sourceName,
device: {
id: overridesArg.device?.id || configArg.id || source?.device.id || derived.mac || host || name,
name,
host,
port,
ssl,
verifySsl: overridesArg.device?.verifySsl ?? this.verifySslValue(configArg, source?.device.verifySsl),
configurationUrl: host ? `${ssl ? 'https' : 'http'}://${host}${this.defaultPort(ssl) === port ? '' : `:${port}`}` : undefined,
macAddress: overridesArg.device?.macAddress || configArg.macAddress || configArg.mac || source?.device.macAddress || derived.mac,
manufacturer: overridesArg.device?.manufacturer || configArg.manufacturer || source?.device.manufacturer || airosManufacturer,
modelName: overridesArg.device?.modelName || configArg.modelName || configArg.model || source?.device.modelName || this.stringValue(hostData?.devmodel) || airosDefaultName,
modelId: overridesArg.device?.modelId || source?.device.modelId || derived.sku,
firmwareVersion: fwVersion,
firmwareMajor: fwMajor,
authenticated: overridesArg.device?.authenticated ?? source?.device.authenticated,
available: overridesArg.device?.available ?? source?.device.available ?? connected,
metadata: this.cleanAttributes({
...source?.device.metadata,
...configArg.metadata,
...overridesArg.device?.metadata,
username: configArg.username || airosDefaultUsername,
macInterface: derived.macInterface,
}),
},
host: {
hostname: overridesArg.host?.hostname || this.stringValue(hostData?.hostname) || source?.host.hostname || name,
uptime: this.numberValue(overridesArg.host?.uptime) ?? this.numberValue(hostData?.uptime) ?? source?.host.uptime,
cpuLoad: this.numberValue(overridesArg.host?.cpuLoad) ?? this.numberValue(hostData?.cpuload) ?? source?.host.cpuLoad ?? null,
netRole: overridesArg.host?.netRole || this.stringValue(hostData?.netrole) || source?.host.netRole,
temperature: this.numberValue(overridesArg.host?.temperature) ?? this.numberValue(hostData?.temperature) ?? source?.host.temperature ?? null,
totalRam: this.numberValue(overridesArg.host?.totalRam) ?? this.numberValue(hostData?.totalram) ?? source?.host.totalRam,
freeRam: this.numberValue(overridesArg.host?.freeRam) ?? this.numberValue(hostData?.freeram) ?? source?.host.freeRam,
attributes: this.cleanAttributes({ ...source?.host.attributes, ...overridesArg.host?.attributes }),
},
services: {
dhcpClient: this.booleanValue(overridesArg.services?.dhcpClient) ?? this.booleanValue(servicesData?.dhcpc) ?? source?.services.dhcpClient,
dhcpServer: this.booleanValue(overridesArg.services?.dhcpServer) ?? this.booleanValue(servicesData?.dhcpd) ?? source?.services.dhcpServer,
dhcp6Server: this.booleanValue(overridesArg.services?.dhcp6Server) ?? this.booleanValue(servicesData?.dhcp6d_stateful) ?? source?.services.dhcp6Server,
pppoe: this.booleanValue(overridesArg.services?.pppoe) ?? this.booleanValue(servicesData?.pppoe) ?? source?.services.pppoe,
portForwarding: this.booleanValue(overridesArg.services?.portForwarding) ?? this.booleanValue(rawStatus?.portfw) ?? source?.services.portForwarding,
attributes: this.cleanAttributes({ ...source?.services.attributes, ...overridesArg.services?.attributes }),
},
wireless: {
essid: overridesArg.wireless?.essid || this.stringValue(wirelessData?.essid) || source?.wireless.essid,
frequency: this.numberValue(overridesArg.wireless?.frequency) ?? this.numberValue(wirelessData?.frequency) ?? source?.wireless.frequency,
distance: this.numberValue(overridesArg.wireless?.distance) ?? this.numberValue(wirelessData?.distance) ?? source?.wireless.distance,
antennaGain: this.numberValue(overridesArg.wireless?.antennaGain) ?? this.numberValue(wirelessData?.antenna_gain) ?? source?.wireless.antennaGain ?? null,
mode: overridesArg.wireless?.mode || derived.mode || source?.wireless.mode || 'unknown',
role: overridesArg.wireless?.role || derived.role || source?.wireless.role || 'unknown',
rawMode: overridesArg.wireless?.rawMode || this.stringValue(wirelessData?.mode) || source?.wireless.rawMode,
pollingDownloadCapacity: this.numberValue(overridesArg.wireless?.pollingDownloadCapacity) ?? this.numberValue(this.objectValue(wirelessData?.polling)?.dl_capacity) ?? source?.wireless.pollingDownloadCapacity ?? null,
pollingUploadCapacity: this.numberValue(overridesArg.wireless?.pollingUploadCapacity) ?? this.numberValue(this.objectValue(wirelessData?.polling)?.ul_capacity) ?? source?.wireless.pollingUploadCapacity ?? null,
throughputTx: this.numberValue(overridesArg.wireless?.throughputTx) ?? this.numberValue(this.objectValue(wirelessData?.throughput)?.tx) ?? source?.wireless.throughputTx ?? null,
throughputRx: this.numberValue(overridesArg.wireless?.throughputRx) ?? this.numberValue(this.objectValue(wirelessData?.throughput)?.rx) ?? source?.wireless.throughputRx ?? null,
attributes: this.cleanAttributes({ ...source?.wireless.attributes, ...overridesArg.wireless?.attributes }),
},
firmware,
derived,
rawStatus,
updatedAt,
events: [...(source?.events || []), ...eventsArg, ...(overridesArg.events || [])],
metadata: this.cleanAttributes({
...source?.metadata,
...configArg.metadata,
...overridesArg.metadata,
nativeHttpImplemented: true,
nativeHttpEnabled: configArg.nativeHttpEnabled !== false,
commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.transport?.execute),
nativeClientConfigured: Boolean(configArg.nativeClient),
}),
error: overridesArg.error || source?.error,
};
}
public static mergeSnapshot(baseArg: IAirosSnapshot, patchArg: Partial<IAirosSnapshot>): IAirosSnapshot {
return {
...baseArg,
...patchArg,
device: { ...baseArg.device, ...patchArg.device, metadata: this.cleanAttributes({ ...baseArg.device.metadata, ...patchArg.device?.metadata }) },
host: { ...baseArg.host, ...patchArg.host, attributes: this.cleanAttributes({ ...baseArg.host.attributes, ...patchArg.host?.attributes }) },
services: { ...baseArg.services, ...patchArg.services, attributes: this.cleanAttributes({ ...baseArg.services.attributes, ...patchArg.services?.attributes }) },
wireless: { ...baseArg.wireless, ...patchArg.wireless, attributes: this.cleanAttributes({ ...baseArg.wireless.attributes, ...patchArg.wireless?.attributes }) },
derived: { ...baseArg.derived, ...patchArg.derived },
events: [...baseArg.events, ...(patchArg.events || [])],
metadata: this.cleanAttributes({ ...baseArg.metadata, ...patchArg.metadata }),
updatedAt: patchArg.updatedAt || baseArg.updatedAt || new Date().toISOString(),
};
}
public static toDevices(snapshotArg: IAirosSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'cpu_load', capability: 'sensor', name: 'CPU load', readable: true, writable: false, unit: '%' },
{ id: 'uptime', capability: 'sensor', name: 'Uptime', readable: true, writable: false, unit: 's' },
{ id: 'wireless_frequency', capability: 'sensor', name: 'Wireless frequency', readable: true, writable: false, unit: 'MHz' },
{ id: 'wireless_distance', capability: 'sensor', name: 'Wireless distance', readable: true, writable: false, unit: 'm' },
{ id: 'download_capacity', capability: 'sensor', name: 'Download capacity', readable: true, writable: false, unit: 'kbit/s' },
{ id: 'upload_capacity', capability: 'sensor', name: 'Upload capacity', readable: true, writable: false, unit: 'kbit/s' },
{ id: 'dhcp_client', capability: 'sensor', name: 'DHCP client', readable: true, writable: false },
{ id: 'dhcp_server', capability: 'sensor', name: 'DHCP server', readable: true, writable: false },
{ id: 'pppoe', capability: 'sensor', name: 'PPPoE link', readable: true, writable: false },
{ id: 'reboot', capability: 'switch', name: 'Reboot', readable: false, writable: true },
];
if ((snapshotArg.device.firmwareMajor || 0) >= 8) {
features.push(
{ id: 'throughput_tx', capability: 'sensor', name: 'Throughput transmit', readable: true, writable: false, unit: 'kbit/s' },
{ id: 'throughput_rx', capability: 'sensor', name: 'Throughput receive', readable: true, writable: false, unit: 'kbit/s' },
{ id: 'firmware_update', capability: 'switch', name: 'Firmware update', readable: true, writable: true },
);
}
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'cpu_load', value: snapshotArg.host.cpuLoad ?? null, updatedAt },
{ featureId: 'uptime', value: snapshotArg.host.uptime ?? null, updatedAt },
{ featureId: 'wireless_frequency', value: snapshotArg.wireless.frequency ?? null, updatedAt },
{ featureId: 'wireless_distance', value: snapshotArg.wireless.distance ?? null, updatedAt },
{ featureId: 'download_capacity', value: snapshotArg.wireless.pollingDownloadCapacity ?? null, updatedAt },
{ featureId: 'upload_capacity', value: snapshotArg.wireless.pollingUploadCapacity ?? null, updatedAt },
{ featureId: 'dhcp_client', value: snapshotArg.services.dhcpClient ?? null, updatedAt },
{ featureId: 'dhcp_server', value: snapshotArg.services.dhcpServer ?? null, updatedAt },
{ featureId: 'pppoe', value: snapshotArg.services.pppoe ?? null, updatedAt },
];
if ((snapshotArg.device.firmwareMajor || 0) >= 8) {
state.push(
{ featureId: 'throughput_tx', value: snapshotArg.wireless.throughputTx ?? null, updatedAt },
{ featureId: 'throughput_rx', value: snapshotArg.wireless.throughputRx ?? null, updatedAt },
{ featureId: 'firmware_update', value: snapshotArg.firmware?.update ?? false, updatedAt },
);
}
return [{
id: this.deviceId(snapshotArg),
integrationDomain: airosDomain,
name: snapshotArg.device.name || airosDefaultName,
protocol: 'http',
manufacturer: snapshotArg.device.manufacturer || airosManufacturer,
model: snapshotArg.device.modelName || airosDefaultName,
online: snapshotArg.connected && snapshotArg.device.available !== false,
features,
state,
metadata: this.cleanAttributes({
attribution: airosAttribution,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
ssl: snapshotArg.device.ssl,
verifySsl: snapshotArg.device.verifySsl,
configurationUrl: snapshotArg.device.configurationUrl,
macAddress: snapshotArg.device.macAddress,
firmwareVersion: snapshotArg.device.firmwareVersion,
firmwareMajor: snapshotArg.device.firmwareMajor,
modelId: snapshotArg.device.modelId,
source: snapshotArg.source,
error: snapshotArg.error,
...snapshotArg.metadata,
}),
}];
}
public static toEntities(snapshotArg: IAirosSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [
this.sensorEntity(snapshotArg, 'host_cpuload', 'CPU load', snapshotArg.host.cpuLoad ?? null, { unit_of_measurement: '%', nativeType: 'host_cpuload' }),
this.sensorEntity(snapshotArg, 'host_netrole', 'Network role', snapshotArg.host.netRole ?? null, { nativeType: 'host_netrole' }),
this.sensorEntity(snapshotArg, 'wireless_frequency', 'Wireless frequency', snapshotArg.wireless.frequency ?? null, { unit_of_measurement: 'MHz', nativeType: 'wireless_frequency' }),
this.sensorEntity(snapshotArg, 'wireless_essid', 'Wireless SSID', snapshotArg.wireless.essid ?? null, { nativeType: 'wireless_essid' }),
this.sensorEntity(snapshotArg, 'host_uptime', 'Uptime', snapshotArg.host.uptime ?? null, { unit_of_measurement: 's', nativeType: 'host_uptime' }),
this.sensorEntity(snapshotArg, 'wireless_distance', 'Wireless distance', snapshotArg.wireless.distance ?? null, { unit_of_measurement: 'm', nativeType: 'wireless_distance' }),
this.sensorEntity(snapshotArg, 'wireless_mode', 'Wireless mode', snapshotArg.wireless.mode ?? null, { nativeType: 'wireless_mode' }),
this.sensorEntity(snapshotArg, 'wireless_role', 'Wireless role', snapshotArg.wireless.role ?? null, { nativeType: 'wireless_role' }),
this.sensorEntity(snapshotArg, 'wireless_antenna_gain', 'Antenna gain', snapshotArg.wireless.antennaGain ?? null, { unit_of_measurement: 'dB', nativeType: 'wireless_antenna_gain' }),
this.sensorEntity(snapshotArg, 'wireless_polling_dl_capacity', 'Download capacity', snapshotArg.wireless.pollingDownloadCapacity ?? null, { unit_of_measurement: 'kbit/s', nativeType: 'wireless_polling_dl_capacity' }),
this.sensorEntity(snapshotArg, 'wireless_polling_ul_capacity', 'Upload capacity', snapshotArg.wireless.pollingUploadCapacity ?? null, { unit_of_measurement: 'kbit/s', nativeType: 'wireless_polling_ul_capacity' }),
this.binarySensorEntity(snapshotArg, 'dhcp_client', 'DHCP client', snapshotArg.services.dhcpClient),
this.binarySensorEntity(snapshotArg, 'dhcp_server', 'DHCP server', snapshotArg.services.dhcpServer),
this.binarySensorEntity(snapshotArg, 'pppoe', 'PPPoE link', snapshotArg.services.pppoe),
this.rebootButtonEntity(snapshotArg),
];
if ((snapshotArg.device.firmwareMajor || 0) >= 8) {
entities.push(
this.sensorEntity(snapshotArg, 'wireless_throughput_tx', 'Throughput transmit (actual)', snapshotArg.wireless.throughputTx ?? null, { unit_of_measurement: 'kbit/s', nativeType: 'wireless_throughput_tx' }),
this.sensorEntity(snapshotArg, 'wireless_throughput_rx', 'Throughput receive (actual)', snapshotArg.wireless.throughputRx ?? null, { unit_of_measurement: 'kbit/s', nativeType: 'wireless_throughput_rx' }),
this.binarySensorEntity(snapshotArg, 'portfw', 'Port forwarding', snapshotArg.services.portForwarding),
this.binarySensorEntity(snapshotArg, 'dhcp6_server', 'DHCPv6 server', snapshotArg.services.dhcp6Server),
this.updateEntity(snapshotArg),
);
}
return entities;
}
public static commandForService(snapshotArg: IAirosSnapshot, requestArg: IServiceCallRequest): IAirosCommand | undefined {
const target = requestArg.target || {};
const deviceId = this.deviceId(snapshotArg);
const buttonId = this.rebootButtonEntityId(snapshotArg);
const updateId = this.updateEntityId(snapshotArg);
const targetMatchesDevice = !target.deviceId || target.deviceId === deviceId;
const noEntityTarget = !target.entityId;
if (requestArg.domain === airosDomain && requestArg.service === 'firmware_update_check' && targetMatchesDevice) {
return this.command(snapshotArg, requestArg, 'firmware_update_check', 'api/fw/update-check', noEntityTarget ? undefined : target.entityId);
}
if (requestArg.domain === airosDomain && requestArg.service === 'download_update' && targetMatchesDevice) {
return this.command(snapshotArg, requestArg, 'download_update', 'api/fw/download', noEntityTarget ? undefined : target.entityId);
}
if ((requestArg.domain === airosDomain && requestArg.service === 'install_update' && targetMatchesDevice) || (requestArg.domain === 'update' && requestArg.service === 'install' && target.entityId === updateId)) {
return this.command(snapshotArg, requestArg, 'install_update', 'fwflash.cgi', updateId);
}
if ((requestArg.domain === airosDomain && requestArg.service === 'reboot' && targetMatchesDevice) || (requestArg.domain === 'button' && requestArg.service === 'press' && target.entityId === buttonId)) {
return this.command(snapshotArg, requestArg, 'reboot', 'reboot.cgi', buttonId);
}
return undefined;
}
public static toIntegrationEvent(eventArg: IAirosEvent): IIntegrationEvent {
return {
type: eventArg.type === 'command_failed' ? 'error' : 'state_changed',
integrationDomain: airosDomain,
deviceId: eventArg.deviceId,
entityId: eventArg.entityId,
data: eventArg,
timestamp: eventArg.timestamp || Date.now(),
};
}
public static deviceId(snapshotArg: IAirosSnapshot): string {
return `airos.device.${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name || 'device')}`;
}
public static rebootButtonEntityId(snapshotArg: IAirosSnapshot): string {
return `button.${this.slug(snapshotArg.device.name || snapshotArg.device.id || 'airos_device')}_reboot`;
}
public static updateEntityId(snapshotArg: IAirosSnapshot): string {
return `update.${this.slug(snapshotArg.device.name || snapshotArg.device.id || 'airos_device')}_firmware_update`;
}
public static normalizeMac(valueArg: unknown): string | undefined {
if (typeof valueArg !== 'string') {
return undefined;
}
const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase();
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined;
}
public static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg.replace(/\s*(MHz|dBi|dBm|kbps|kbit\/s|s)$/i, ''));
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
public static slug(valueArg: unknown): string {
const slug = String(valueArg || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return slug || 'airos';
}
public static cleanAttributes<TValue extends Record<string, unknown>>(valueArg: TValue): TValue {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(valueArg)) {
if (value !== undefined) {
result[key] = value;
}
}
return result as TValue;
}
public static derive(rawStatusArg: IAirosRawStatus | undefined, configArg: IAirosConfig = {}, sourceArg?: IAirosDerivedSnapshot): IAirosDerivedSnapshot {
const wireless = this.objectValue(rawStatusArg?.wireless);
const rawDerived = (rawStatusArg?.derived || {}) as IAirosValueMap;
const rawMode = this.stringValue(wireless?.mode);
const derivedMode = this.deriveWireless(rawMode);
const macInfo = this.getMac(rawStatusArg?.interfaces);
const fwVersion = this.stringValue(rawStatusArg?.host?.fwversion);
const fwMajor = this.fwMajor(fwVersion);
return this.cleanAttributes({
...sourceArg,
mac: this.normalizeMac(rawDerived?.mac) || this.normalizeMac(configArg.macAddress || configArg.mac) || sourceArg?.mac || macInfo.mac,
macInterface: this.stringValue(rawDerived?.macInterface) || this.stringValue(rawDerived?.mac_interface) || sourceArg?.macInterface || macInfo.macInterface,
fwMajor: fwMajor ?? this.numberValue(rawDerived?.fwMajor) ?? sourceArg?.fwMajor,
sku: this.stringValue(rawDerived?.sku) || sourceArg?.sku,
role: derivedMode.role || sourceArg?.role,
mode: derivedMode.mode || sourceArg?.mode,
station: derivedMode.station ?? sourceArg?.station,
accessPoint: derivedMode.accessPoint ?? sourceArg?.accessPoint,
ptp: derivedMode.ptp ?? sourceArg?.ptp,
ptmp: derivedMode.ptmp ?? sourceArg?.ptmp,
});
}
private static command(snapshotArg: IAirosSnapshot, requestArg: IServiceCallRequest, actionArg: IAirosCommand['action'], methodArg: IAirosCommand['method'], entityIdArg?: string): IAirosCommand {
return {
action: actionArg,
method: methodArg,
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
deviceId: this.deviceId(snapshotArg),
entityId: entityIdArg,
host: snapshotArg.device.host,
transmitted: false,
metadata: this.cleanAttributes({ firmwareMajor: snapshotArg.device.firmwareMajor, firmwareVersion: snapshotArg.device.firmwareVersion }),
};
}
private static sensorEntity(snapshotArg: IAirosSnapshot, keyArg: string, nameArg: string, stateArg: unknown, attributesArg: Record<string, unknown>): IIntegrationEntity {
return {
id: `sensor.${this.slug(snapshotArg.device.name || snapshotArg.device.id || 'airos_device')}_${keyArg}`,
uniqueId: `airos_${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name)}_${keyArg}`,
integrationDomain: airosDomain,
deviceId: this.deviceId(snapshotArg),
platform: 'sensor',
name: nameArg,
state: stateArg,
available: snapshotArg.connected && snapshotArg.device.available !== false && stateArg !== undefined,
attributes: this.cleanAttributes({ attribution: airosAttribution, ...attributesArg }),
};
}
private static binarySensorEntity(snapshotArg: IAirosSnapshot, keyArg: string, nameArg: string, stateArg: boolean | undefined): IIntegrationEntity {
return {
id: `binary_sensor.${this.slug(snapshotArg.device.name || snapshotArg.device.id || 'airos_device')}_${keyArg}`,
uniqueId: `airos_${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name)}_${keyArg}`,
integrationDomain: airosDomain,
deviceId: this.deviceId(snapshotArg),
platform: 'binary_sensor',
name: nameArg,
state: stateArg ?? null,
available: snapshotArg.connected && snapshotArg.device.available !== false && stateArg !== undefined,
attributes: this.cleanAttributes({ attribution: airosAttribution, nativeType: keyArg }),
};
}
private static rebootButtonEntity(snapshotArg: IAirosSnapshot): IIntegrationEntity {
return {
id: this.rebootButtonEntityId(snapshotArg),
uniqueId: `airos_${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name)}_reboot`,
integrationDomain: airosDomain,
deviceId: this.deviceId(snapshotArg),
platform: 'button',
name: 'Reboot',
state: 'idle',
available: snapshotArg.connected && snapshotArg.device.available !== false,
attributes: this.cleanAttributes({ attribution: airosAttribution, nativeType: 'reboot', writable: true }),
};
}
private static updateEntity(snapshotArg: IAirosSnapshot): IIntegrationEntity {
return {
id: this.updateEntityId(snapshotArg),
uniqueId: `airos_${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name)}_firmware_update`,
integrationDomain: airosDomain,
deviceId: this.deviceId(snapshotArg),
platform: 'update',
name: 'Firmware update',
state: snapshotArg.firmware?.update ? 'on' : 'off',
available: snapshotArg.connected && snapshotArg.device.available !== false,
attributes: this.cleanAttributes({
attribution: airosAttribution,
nativeType: 'firmware_update',
installed_version: snapshotArg.device.firmwareVersion,
latest_version: snapshotArg.firmware?.update ? snapshotArg.firmware.version : snapshotArg.device.firmwareVersion,
release_url: snapshotArg.firmware?.changelog,
writable: true,
}),
};
}
private static getMac(interfacesArg: IAirosRawInterface[] | undefined): { mac?: string; macInterface?: string } {
if (!interfacesArg?.length) {
return {};
}
const addresses = new Map<string, string>();
for (const item of interfacesArg) {
const ifname = this.stringValue(item.ifname);
const mac = this.normalizeMac(item.hwaddr);
if (ifname && mac && item.enabled !== false) {
addresses.set(ifname, mac);
}
}
for (const preferred of ['br0', 'eth0', 'ath0']) {
const mac = addresses.get(preferred);
if (mac) {
return { mac, macInterface: preferred };
}
}
const first = interfacesArg.find((itemArg) => this.normalizeMac(itemArg.hwaddr));
return { mac: this.normalizeMac(first?.hwaddr), macInterface: this.stringValue(first?.ifname) };
}
private static fwMajor(valueArg: string | undefined): number | undefined {
if (!valueArg) {
return undefined;
}
const parsed = Number(valueArg.replace(/^v/i, '').split('.', 1)[0]);
return Number.isFinite(parsed) ? parsed : undefined;
}
private static deriveWireless(valueArg: string | undefined): { role?: TAirosWirelessRole; mode?: TAirosWirelessMode; station?: boolean; accessPoint?: boolean; ptp?: boolean; ptmp?: boolean } {
switch ((valueArg || '').toLowerCase()) {
case 'ap-ptmp':
return { role: 'access_point', mode: 'point_to_multipoint', accessPoint: true, station: false, ptmp: true, ptp: false };
case 'sta-ptmp':
return { role: 'station', mode: 'point_to_multipoint', accessPoint: false, station: true, ptmp: true, ptp: false };
case 'ap-ptp':
case 'ap':
return { role: 'access_point', mode: 'point_to_point', accessPoint: true, station: false, ptmp: false, ptp: true };
case 'sta-ptp':
case 'sta':
return { role: 'station', mode: 'point_to_point', accessPoint: false, station: true, ptmp: false, ptp: true };
default:
return { role: 'unknown', mode: 'unknown' };
}
}
private static sslValue(configArg: IAirosConfig, fallbackArg?: boolean): boolean {
return configArg.ssl ?? configArg.useSsl ?? configArg.advanced_settings?.ssl ?? fallbackArg ?? airosDefaultSsl;
}
private static verifySslValue(configArg: IAirosConfig, fallbackArg?: boolean): boolean {
return configArg.verifySsl ?? configArg.verify_ssl ?? configArg.advanced_settings?.verifySsl ?? configArg.advanced_settings?.verify_ssl ?? fallbackArg ?? airosDefaultVerifySsl;
}
private static defaultPort(sslArg: boolean): number {
return sslArg ? airosDefaultHttpsPort : airosDefaultHttpPort;
}
private static objectValue(valueArg: unknown): IAirosValueMap | undefined {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IAirosValueMap : undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
if (typeof valueArg === 'string' && valueArg.trim()) {
return valueArg.trim();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return String(valueArg);
}
return undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', '1', 'on', 'enabled'].includes(normalized)) {
return true;
}
if (['false', 'no', '0', 'off', 'disabled'].includes(normalized)) {
return false;
}
}
return undefined;
}
}
+272 -2
View File
@@ -1,4 +1,274 @@
export interface IHomeAssistantAirosConfig {
// TODO: replace with the TypeScript-native config for airos.
import type { IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
export type TAirosSnapshotSource = 'snapshot' | 'manual' | 'client' | 'http' | 'transport' | 'runtime';
export type TAirosCommandAction = 'refresh' | 'reboot' | 'download_update' | 'install_update' | 'firmware_update_check';
export type TAirosEventType = 'snapshot_refreshed' | 'command_mapped' | 'command_succeeded' | 'command_failed' | 'state_changed';
export type TAirosWirelessRole = 'access_point' | 'station' | 'unknown';
export type TAirosWirelessMode = 'point_to_point' | 'point_to_multipoint' | 'unknown';
export interface IAirosValueMap {
[key: string]: unknown;
}
export interface IAirosRawInterface extends IAirosValueMap {
ifname?: string;
hwaddr?: string;
enabled?: boolean;
status?: IAirosValueMap;
mtu?: number | null;
}
export interface IAirosRawStatus extends IAirosValueMap {
host?: IAirosValueMap;
services?: IAirosValueMap;
wireless?: IAirosValueMap;
interfaces?: IAirosRawInterface[];
portfw?: boolean;
derived?: Partial<IAirosDerivedSnapshot>;
}
export interface IAirosFirmwareStatus extends IAirosValueMap {
update?: boolean;
version?: string;
changelog?: string;
url?: string;
progress?: number;
ok?: boolean;
code?: number;
}
export interface IAirosDerivedSnapshot {
mac?: string;
macInterface?: string;
fwMajor?: number;
sku?: string;
role?: TAirosWirelessRole;
mode?: TAirosWirelessMode;
station?: boolean;
accessPoint?: boolean;
ptp?: boolean;
ptmp?: boolean;
}
export interface IAirosDeviceSnapshot {
id?: string;
name?: string;
host?: string;
port?: number;
ssl?: boolean;
verifySsl?: boolean;
configurationUrl?: string;
macAddress?: string;
manufacturer?: string;
modelName?: string;
modelId?: string;
firmwareVersion?: string;
firmwareMajor?: number;
authenticated?: boolean;
available?: boolean;
metadata?: Record<string, unknown>;
}
export interface IAirosHostSnapshot {
hostname?: string;
uptime?: number;
cpuLoad?: number | null;
netRole?: string;
temperature?: number | null;
totalRam?: number;
freeRam?: number;
attributes?: Record<string, unknown>;
}
export interface IAirosServicesSnapshot {
dhcpClient?: boolean;
dhcpServer?: boolean;
dhcp6Server?: boolean;
pppoe?: boolean;
portForwarding?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAirosWirelessSnapshot {
essid?: string;
frequency?: number;
distance?: number;
antennaGain?: number | null;
mode?: TAirosWirelessMode;
role?: TAirosWirelessRole;
rawMode?: string;
pollingDownloadCapacity?: number | null;
pollingUploadCapacity?: number | null;
throughputTx?: number | null;
throughputRx?: number | null;
attributes?: Record<string, unknown>;
}
export interface IAirosSnapshot {
connected: boolean;
source: TAirosSnapshotSource;
device: IAirosDeviceSnapshot;
host: IAirosHostSnapshot;
services: IAirosServicesSnapshot;
wireless: IAirosWirelessSnapshot;
firmware?: IAirosFirmwareStatus;
derived: IAirosDerivedSnapshot;
rawStatus?: IAirosRawStatus;
updatedAt?: string;
events: IAirosEvent[];
metadata?: Record<string, unknown>;
error?: string;
}
export interface IAirosCommand {
action: TAirosCommandAction;
method: 'refresh' | 'reboot.cgi' | 'api/fw/download' | 'fwflash.cgi' | 'api/fw/update-check' | 'executor' | 'native_client';
service: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
deviceId?: string;
entityId?: string;
host?: string;
transmitted?: boolean;
metadata?: Record<string, unknown>;
}
export interface IAirosCommandResult extends IServiceCallResult {
transmitted?: boolean;
action?: TAirosCommandAction;
}
export interface IAirosEvent {
type: TAirosEventType;
command?: IAirosCommand;
deviceId?: string;
entityId?: string;
data?: unknown;
error?: string;
timestamp: number;
}
export interface IAirosNativeClient {
login?(): Promise<boolean | void>;
getSnapshot?(): Promise<IAirosSnapshot | Partial<IAirosSnapshot>>;
getRawStatus?(): Promise<IAirosRawStatus>;
rawStatus?(): Promise<IAirosRawStatus>;
status?(): Promise<IAirosRawStatus>;
updateCheck?(forceArg?: boolean): Promise<IAirosFirmwareStatus>;
update_check?(forceArg?: boolean): Promise<IAirosFirmwareStatus>;
reboot?(): Promise<IAirosCommandResult | boolean | IAirosValueMap | void>;
download?(): Promise<IAirosCommandResult | IAirosValueMap | void>;
install?(): Promise<IAirosCommandResult | IAirosValueMap | void>;
destroy?(): Promise<void>;
}
export interface IAirosTransport {
execute(commandArg: IAirosCommand): Promise<IAirosCommandResult | unknown>;
readSnapshot?(): Promise<IAirosSnapshot | Partial<IAirosSnapshot>>;
}
export type TAirosCommandExecutor = ((commandArg: IAirosCommand) => Promise<IAirosCommandResult | unknown>) | {
execute(commandArg: IAirosCommand): Promise<IAirosCommandResult | unknown>;
};
export interface IAirosManualEntry {
integrationDomain?: string;
id?: string;
host?: string;
ip?: string;
ipAddress?: string;
address?: string;
port?: number;
name?: string;
username?: string;
password?: string;
ssl?: boolean;
useSsl?: boolean;
verifySsl?: boolean;
verify_ssl?: boolean;
advanced_settings?: {
ssl?: boolean;
verify_ssl?: boolean;
verifySsl?: boolean;
};
manufacturer?: string;
model?: string;
modelName?: string;
mac?: string;
macAddress?: string;
snapshot?: IAirosSnapshot;
rawStatus?: IAirosRawStatus;
firmwareStatus?: IAirosFirmwareStatus;
metadata?: Record<string, unknown>;
}
export interface IAirosDhcpRecord {
integrationDomain?: string;
host?: string;
ip?: string;
ipAddress?: string;
address?: string;
hostname?: string;
hostName?: string;
manufacturer?: string;
model?: string;
mac?: string;
macAddress?: string;
macaddress?: string;
registeredDevice?: boolean;
metadata?: Record<string, unknown>;
}
export interface IAirosLocalDiscoveryRecord {
integrationDomain?: string;
host?: string;
ip?: string;
ipAddress?: string;
ip_address?: string;
hostname?: string | null;
model?: string | null;
fullModelName?: string | null;
full_model_name?: string | null;
firmwareVersion?: string | null;
firmware_version?: string | null;
uptimeSeconds?: number | null;
uptime_seconds?: number | null;
ssid?: string | null;
mac?: string;
macAddress?: string | null;
mac_address?: string | null;
manufacturer?: string;
metadata?: Record<string, unknown>;
}
export interface IAirosCandidateMetadata extends Record<string, unknown> {
airos?: boolean;
discoveryProtocol?: 'dhcp-registered-device' | 'airos-udp-broadcast' | 'manual';
hostname?: string;
firmwareVersion?: string;
fullModelName?: string;
ssid?: string;
username?: string;
ssl?: boolean;
verifySsl?: boolean;
snapshot?: IAirosSnapshot;
rawStatus?: IAirosRawStatus;
nativeHttpImplemented?: boolean;
}
export interface IAirosConfig extends IAirosManualEntry {
username?: string;
password?: string;
port?: number;
timeoutMs?: number;
nativeHttpEnabled?: boolean;
connected?: boolean;
snapshot?: IAirosSnapshot;
rawStatus?: IAirosRawStatus;
firmwareStatus?: IAirosFirmwareStatus;
nativeClient?: IAirosNativeClient;
commandExecutor?: TAirosCommandExecutor;
transport?: IAirosTransport;
}
export type IHomeAssistantAirosConfig = IAirosConfig;
+5
View File
@@ -1,2 +1,7 @@
export * from './airos.classes.client.js';
export * from './airos.classes.configflow.js';
export * from './airos.classes.integration.js';
export * from './airos.constants.js';
export * from './airos.discovery.js';
export * from './airos.mapper.js';
export * from './airos.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,362 @@
import type {
IChannelsCommand,
IChannelsCommandResult,
IChannelsConfig,
IChannelsDeviceInfo,
IChannelsFavoriteChannel,
IChannelsSnapshot,
IChannelsStatus,
} from './channels.types.js';
import { channelsDefaultPort } from './channels.types.js';
const defaultTimeoutMs = 1000;
export class ChannelsHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'ChannelsHttpError';
}
}
export class ChannelsClient {
private currentSnapshot?: IChannelsSnapshot;
constructor(private readonly config: IChannelsConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot)) : undefined;
}
public async getSnapshot(): Promise<IChannelsSnapshot> {
if (this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
return this.cloneSnapshot(this.currentSnapshot);
}
if (!this.config.host) {
if (this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.hasManualSnapshotData() ? this.config.connected ?? true : false));
return this.cloneSnapshot(this.currentSnapshot);
}
try {
const status = await this.getStatus();
const favoriteChannels = await this.getFavoriteChannels().catch(() => [] as IChannelsFavoriteChannel[]);
this.currentSnapshot = this.normalizeSnapshot({
deviceInfo: this.deviceInfoFromConfig(),
status,
favoriteChannels,
online: !this.isOfflineStatus(status.status),
updatedAt: new Date().toISOString(),
});
} catch {
this.currentSnapshot = this.normalizeSnapshot({
deviceInfo: this.deviceInfoFromConfig(),
status: { status: 'offline' },
favoriteChannels: this.config.favoriteChannels || this.currentSnapshot?.favoriteChannels || [],
online: false,
updatedAt: new Date().toISOString(),
});
}
return this.cloneSnapshot(this.currentSnapshot);
}
public async getStatus(): Promise<IChannelsStatus> {
if (!this.config.host) {
return this.clone(this.currentSnapshot?.status || this.config.status || { status: this.config.connected === false ? 'offline' : 'stopped' });
}
return this.getJson<IChannelsStatus>('/api/status');
}
public async getFavoriteChannels(): Promise<IChannelsFavoriteChannel[]> {
if (!this.config.host) {
return this.clone(this.currentSnapshot?.favoriteChannels || this.config.favoriteChannels || []);
}
const response = await this.getJson<unknown>('/api/favorite_channels');
return Array.isArray(response) ? response as IChannelsFavoriteChannel[] : [];
}
public async toggleMuted(): Promise<IChannelsStatus> {
return this.executeApiCommand('toggle_mute', '/api/toggle_mute');
}
public async toggleMute(): Promise<IChannelsStatus> {
return this.toggleMuted();
}
public async setMuted(mutedArg: boolean): Promise<IChannelsStatus | undefined> {
this.ensureCanExecuteCommand();
const snapshot = await this.getSnapshot();
if (snapshot.status.muted === mutedArg) {
return snapshot.status;
}
return this.toggleMuted();
}
public async toggleCaptions(): Promise<IChannelsStatus> {
return this.executeApiCommand('toggle_cc', '/api/toggle_cc');
}
public async toggleRecord(): Promise<IChannelsStatus> {
return this.executeApiCommand('toggle_record', '/api/toggle_record');
}
public async channelUp(): Promise<IChannelsStatus> {
return this.executeApiCommand('channel_up', '/api/channel_up');
}
public async channelDown(): Promise<IChannelsStatus> {
return this.executeApiCommand('channel_down', '/api/channel_down');
}
public async previousChannel(): Promise<IChannelsStatus> {
return this.executeApiCommand('previous_channel', '/api/previous_channel');
}
public async togglePause(): Promise<IChannelsStatus> {
return this.executeApiCommand('toggle_pause', '/api/toggle_pause');
}
public async pause(): Promise<IChannelsStatus> {
return this.executeApiCommand('pause', '/api/pause');
}
public async resume(): Promise<IChannelsStatus> {
return this.executeApiCommand('resume', '/api/resume');
}
public async stop(): Promise<IChannelsStatus> {
return this.executeApiCommand('stop', '/api/stop');
}
public async seek(secondsArg: number): Promise<IChannelsStatus> {
const seconds = Math.trunc(secondsArg || 0);
return this.executeApiCommand('seek', `/api/seek/${encodeURIComponent(String(seconds))}`, { seconds });
}
public async seekForward(): Promise<IChannelsStatus> {
return this.executeApiCommand('seek_forward', '/api/seek_forward');
}
public async seekBackward(): Promise<IChannelsStatus> {
return this.executeApiCommand('seek_backward', '/api/seek_backward');
}
public async skipForward(): Promise<IChannelsStatus> {
return this.executeApiCommand('skip_forward', '/api/skip_forward');
}
public async skipBackward(): Promise<IChannelsStatus> {
return this.executeApiCommand('skip_backward', '/api/skip_backward');
}
public async playChannel(channelNumberArg: string | number): Promise<IChannelsStatus> {
const mediaId = String(channelNumberArg);
return this.executeApiCommand('play_channel', `/api/play/channel/${encodeURIComponent(mediaId)}`, { mediaId, mediaType: 'channel' });
}
public async playRecording(recordingIdArg: string | number): Promise<IChannelsStatus> {
const mediaId = String(recordingIdArg);
return this.executeApiCommand('play_recording', `/api/play/recording/${encodeURIComponent(mediaId)}`, { mediaId, mediaType: 'recording' });
}
public async navigate(sectionArg: string): Promise<IChannelsStatus> {
return this.executeApiCommand('navigate', `/api/navigate/${encodeURIComponent(sectionArg)}`, { source: sectionArg });
}
public async notify(titleArg: string, messageArg: string): Promise<IChannelsStatus> {
return this.executeApiCommand('notify', '/api/notify', { body: { title: titleArg, message: messageArg } });
}
public async destroy(): Promise<void> {}
private async executeApiCommand(typeArg: IChannelsCommand['type'], pathArg: string, optionsArg: Partial<IChannelsCommand> = {}): Promise<IChannelsStatus> {
const command: IChannelsCommand = {
type: typeArg,
method: 'POST',
path: pathArg,
...optionsArg,
};
if (this.config.commandExecutor) {
const result = await this.config.commandExecutor(command);
if (!result.success) {
throw new Error(result.error || `Channels command ${typeArg} was rejected by commandExecutor.`);
}
this.applyCommandResult(result);
return this.clone(result.status || this.currentSnapshot?.status || { status: 'stopped' });
}
this.ensureCanExecuteCommand();
const response = await this.postJson<IChannelsStatus>(pathArg, command.body);
if (this.isOfflineStatus(response.status)) {
throw new Error(`Channels command ${typeArg} returned ${response.status}.`);
}
this.patchCachedStatus(response);
return this.clone(response);
}
private applyCommandResult(resultArg: IChannelsCommandResult): void {
if (resultArg.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(resultArg.snapshot));
return;
}
if (resultArg.status) {
this.patchCachedStatus(resultArg.status);
}
}
private patchCachedStatus(statusArg: IChannelsStatus): void {
const base = this.currentSnapshot || this.snapshotFromConfig(true);
const status = { ...base.status, ...statusArg };
this.currentSnapshot = this.normalizeSnapshot({
...base,
status,
online: !this.isOfflineStatus(status.status),
updatedAt: new Date().toISOString(),
});
}
private async getJson<T>(pathArg: string): Promise<T> {
return this.requestJson<T>('GET', pathArg);
}
private async postJson<T>(pathArg: string, bodyArg?: unknown): Promise<T> {
return this.requestJson<T>('POST', pathArg, bodyArg);
}
private async requestJson<T>(methodArg: 'GET' | 'POST', pathArg: string, bodyArg?: unknown): Promise<T> {
const response = await this.request(pathArg, {
method: methodArg,
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: bodyArg !== undefined ? JSON.stringify(bodyArg) : undefined,
});
const text = await response.text();
if (!response.ok) {
throw new ChannelsHttpError(response.status, `Channels request ${pathArg} failed with HTTP ${response.status}: ${text}`);
}
if (!text.trim()) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch {
return {} as T;
}
}
private async request(pathArg: string, initArg: RequestInit): Promise<Response> {
const abortController = new AbortController();
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
try {
return await globalThis.fetch(`${this.baseUrl()}${pathArg}`, {
...initArg,
signal: abortController.signal,
});
} finally {
globalThis.clearTimeout(timeout);
}
}
private ensureCanExecuteCommand(): void {
if (!this.config.host && !this.config.commandExecutor) {
throw new Error('Channels media controls require config.host or commandExecutor.');
}
}
private snapshotFromConfig(onlineArg: boolean): IChannelsSnapshot {
return {
deviceInfo: this.deviceInfoFromConfig(),
status: this.config.status || { status: onlineArg ? 'stopped' : 'offline' },
favoriteChannels: this.config.favoriteChannels || [],
online: onlineArg,
updatedAt: new Date().toISOString(),
};
}
private normalizeSnapshot(snapshotArg: IChannelsSnapshot): IChannelsSnapshot {
const status = { ...(snapshotArg.status || {}) };
const online = Boolean(snapshotArg.online) && !this.isOfflineStatus(status.status);
return {
...snapshotArg,
deviceInfo: {
...this.deviceInfoFromConfig(),
...snapshotArg.deviceInfo,
},
status,
favoriteChannels: Array.isArray(snapshotArg.favoriteChannels) ? snapshotArg.favoriteChannels : [],
online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private deviceInfoFromConfig(): IChannelsDeviceInfo {
const endpoint = this.endpointFromHost();
return {
...this.config.deviceInfo,
id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.name || 'channels',
name: this.config.deviceInfo?.name || this.config.name || endpoint.host || 'Channels',
host: this.config.deviceInfo?.host || endpoint.host,
port: this.config.deviceInfo?.port || endpoint.port || this.config.port || channelsDefaultPort,
manufacturer: this.config.deviceInfo?.manufacturer || 'Fancy Bits',
model: this.config.deviceInfo?.model || 'Channels',
};
}
private baseUrl(): string {
if (!this.config.host) {
throw new Error('Channels host is required for HTTP API calls.');
}
const endpoint = this.endpointFromHost();
if (endpoint.url) {
return endpoint.url;
}
return `http://${this.config.host}:${this.config.port || channelsDefaultPort}`;
}
private endpointFromHost(): { host?: string; port?: number; url?: string } {
if (!this.config.host) {
return { port: this.config.port || channelsDefaultPort };
}
const value = /^https?:\/\//i.test(this.config.host) ? this.config.host : `http://${this.config.host}`;
try {
const url = new URL(value);
if (this.config.port && !url.port) {
url.port = String(this.config.port);
}
if (!url.port) {
url.port = String(channelsDefaultPort);
}
url.pathname = '';
url.search = '';
url.hash = '';
return {
host: url.hostname,
port: Number(url.port) || channelsDefaultPort,
url: url.toString().replace(/\/$/, ''),
};
} catch {
return {
host: this.config.host,
port: this.config.port || channelsDefaultPort,
};
}
}
private hasManualSnapshotData(): boolean {
return Boolean(this.config.status || this.config.favoriteChannels || this.config.deviceInfo);
}
private isOfflineStatus(statusArg: unknown): boolean {
return statusArg === 'offline' || statusArg === 'error';
}
private cloneSnapshot(snapshotArg: IChannelsSnapshot): IChannelsSnapshot {
return this.clone(snapshotArg);
}
private clone<T>(valueArg: T): T {
return JSON.parse(JSON.stringify(valueArg)) as T;
}
}
@@ -0,0 +1,91 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IChannelsConfig, IChannelsFavoriteChannel, IChannelsSnapshot, IChannelsStatus } from './channels.types.js';
import { channelsDefaultPort } from './channels.types.js';
const defaultTimeoutMs = 1000;
export class ChannelsConfigFlow implements IConfigFlow<IChannelsConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IChannelsConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Channels',
description: 'Configure the local Channels HTTP API endpoint.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const metadataEndpoint = endpointFromUrl(this.stringValue(candidateArg.metadata?.url));
const valueEndpoint = endpointFromUrl(this.stringValue(valuesArg.host));
const host = valueEndpoint.host || this.stringValue(valuesArg.host) || candidateArg.host || metadataEndpoint.host;
const port = this.numberValue(valuesArg.port) || valueEndpoint.port || candidateArg.port || metadataEndpoint.port || channelsDefaultPort;
const name = this.stringValue(valuesArg.name) || candidateArg.name || 'Channels';
const snapshot = this.snapshotValue(candidateArg.metadata?.snapshot);
const status = this.statusValue(candidateArg.metadata?.status);
const favoriteChannels = this.favoriteChannelsValue(candidateArg.metadata?.favoriteChannels);
if (!host && !snapshot && !status) {
return { kind: 'error', title: 'Channels setup failed', error: 'Channels setup requires a host or snapshot data.' };
}
return {
kind: 'done',
title: 'Channels configured',
config: {
host,
port,
name,
uniqueId: candidateArg.id,
timeoutMs: defaultTimeoutMs,
snapshot,
status,
favoriteChannels,
},
};
},
};
}
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 snapshotValue(valueArg: unknown): IChannelsSnapshot | undefined {
return valueArg && typeof valueArg === 'object' ? valueArg as IChannelsSnapshot : undefined;
}
private statusValue(valueArg: unknown): IChannelsStatus | undefined {
return valueArg && typeof valueArg === 'object' ? valueArg as IChannelsStatus : undefined;
}
private favoriteChannelsValue(valueArg: unknown): IChannelsFavoriteChannel[] | undefined {
return Array.isArray(valueArg) ? valueArg as IChannelsFavoriteChannel[] : undefined;
}
}
const endpointFromUrl = (valueArg: string | undefined): { host?: string; port?: number } => {
if (!valueArg) {
return {};
}
const value = /^https?:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`;
try {
const url = new URL(value);
return {
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
};
} catch {
return {};
}
};
@@ -1,24 +1,243 @@
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 { ChannelsClient } from './channels.classes.client.js';
import { ChannelsConfigFlow } from './channels.classes.configflow.js';
import { createChannelsDiscoveryDescriptor } from './channels.discovery.js';
import { ChannelsMapper } from './channels.mapper.js';
import type { IChannelsConfig, IChannelsFavoriteChannel } from './channels.types.js';
export class HomeAssistantChannelsIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "channels",
displayName: "Channels",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/channels",
"upstreamDomain": "channels",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"pychannels==1.2.3"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": []
},
});
export class ChannelsIntegration extends BaseIntegration<IChannelsConfig> {
public readonly domain = 'channels';
public readonly displayName = 'Channels';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createChannelsDiscoveryDescriptor();
public readonly configFlow = new ChannelsConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/channels',
upstreamDomain: 'channels',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: ['pychannels==1.2.3'],
dependencies: [],
afterDependencies: [],
codeowners: [],
documentation: 'https://www.home-assistant.io/integrations/channels',
configFlow: true,
nativeRuntime: {
polling: true,
localHttpApi: true,
livePush: false,
commandRequiresHostOrExecutor: true,
},
};
public async setup(configArg: IChannelsConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new ChannelsRuntime(new ChannelsClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantChannelsIntegration extends ChannelsIntegration {}
class ChannelsRuntime implements IIntegrationRuntime {
public domain = 'channels';
constructor(private readonly client: ChannelsClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ChannelsMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ChannelsMapper.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 === 'channels') {
return await this.callChannelsService(requestArg);
}
return { success: false, error: `Unsupported Channels service domain: ${requestArg.domain}` };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
await this.client.resume();
return { success: true };
}
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
await this.client.pause();
return { success: true };
}
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
await this.client.togglePause();
return { success: true };
}
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
await this.client.stop();
return { success: true };
}
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
await this.client.skipForward();
return { success: true };
}
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
await this.client.skipBackward();
return { success: true };
}
if (requestArg.service === 'volume_mute') {
const muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'muted') ?? this.boolData(requestArg, 'mute');
if (typeof muted !== 'boolean') {
return { success: false, error: 'Channels volume_mute requires data.is_volume_muted.' };
}
await this.client.setMuted(muted);
return { success: true };
}
if (requestArg.service === 'select_source') {
const source = this.stringData(requestArg, 'source');
if (!source) {
return { success: false, error: 'Channels select_source requires data.source.' };
}
const snapshot = await this.client.getSnapshot();
const channel = this.findFavoriteChannel(snapshot.favoriteChannels, source);
const channelNumber = ChannelsMapper.channelNumber(channel);
if (!channelNumber) {
return { success: false, error: `Channels favorite channel not found: ${source}` };
}
await this.client.playChannel(channelNumber);
return { success: true };
}
if (requestArg.service === 'play_media') {
return await this.playMedia(requestArg);
}
return { success: false, error: `Unsupported Channels media_player service: ${requestArg.service}` };
}
private async callChannelsService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'snapshot') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'seek_forward') {
await this.client.seekForward();
return { success: true };
}
if (requestArg.service === 'seek_backward') {
await this.client.seekBackward();
return { success: true };
}
if (requestArg.service === 'seek_by' || requestArg.service === 'seek') {
const seconds = this.numberData(requestArg, 'seconds') ?? this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position');
if (typeof seconds !== 'number') {
return { success: false, error: 'Channels seek_by requires data.seconds.' };
}
await this.client.seek(seconds);
return { success: true };
}
if (requestArg.service === 'channel_up') {
await this.client.channelUp();
return { success: true };
}
if (requestArg.service === 'channel_down') {
await this.client.channelDown();
return { success: true };
}
if (requestArg.service === 'previous_channel') {
await this.client.previousChannel();
return { success: true };
}
if (requestArg.service === 'toggle_cc') {
await this.client.toggleCaptions();
return { success: true };
}
if (requestArg.service === 'toggle_record') {
await this.client.toggleRecord();
return { success: true };
}
if (requestArg.service === 'navigate') {
const section = this.stringData(requestArg, 'section');
if (!section) {
return { success: false, error: 'Channels navigate requires data.section.' };
}
await this.client.navigate(section);
return { success: true };
}
if (requestArg.service === 'notify') {
const title = this.stringData(requestArg, 'title');
const message = this.stringData(requestArg, 'message');
if (!title || !message) {
return { success: false, error: 'Channels notify requires data.title and data.message.' };
}
await this.client.notify(title, message);
return { success: true };
}
return { success: false, error: `Unsupported Channels service: ${requestArg.service}` };
}
private async playMedia(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType') || this.stringData(requestArg, 'media_type');
const mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'media_id');
if (!mediaId) {
return { success: false, error: 'Channels play_media requires data.media_content_id.' };
}
if (!mediaType || mediaType === 'channel') {
await this.client.playChannel(mediaId);
return { success: true };
}
if (mediaType === 'movie' || mediaType === 'episode' || mediaType === 'tvshow' || mediaType === 'recording') {
await this.client.playRecording(mediaId);
return { success: true };
}
return { success: false, error: `Unsupported Channels media type: ${mediaType}` };
}
private findFavoriteChannel(channelsArg: IChannelsFavoriteChannel[], sourceArg: string): IChannelsFavoriteChannel | undefined {
return channelsArg.find((channelArg) => ChannelsMapper.channelName(channelArg) === sourceArg || ChannelsMapper.channelNumber(channelArg) === sourceArg);
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') {
return true;
}
if (value.toLowerCase() === 'false') {
return false;
}
}
return undefined;
}
}
@@ -0,0 +1,176 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IChannelsFavoriteChannel, IChannelsHttpApiEntry, IChannelsManualEntry, IChannelsSnapshot, IChannelsStatus } from './channels.types.js';
import { channelsDefaultPort } from './channels.types.js';
const channelsDomain = 'channels';
export class ChannelsManualMatcher implements IDiscoveryMatcher<IChannelsManualEntry> {
public id = 'channels-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Channels app HTTP API endpoints.';
public async matches(inputArg: IChannelsManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromInput(inputArg);
const metadata = inputArg.metadata || {};
const hasChannelsHint = Boolean(metadata.channels || metadata.channelsApi || metadata.channelsDvr || metadata.channels_dvr || includesChannels(inputArg.name) || includesChannels(inputArg.model) || includesChannels(inputArg.manufacturer));
const matched = Boolean(endpoint.host || inputArg.snapshot || inputArg.status || hasChannelsHint);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain a Channels host or Channels metadata.' };
}
const normalizedDeviceId = inputArg.id || endpoint.host || inputArg.name;
return {
matched: true,
confidence: endpoint.host ? 'high' : hasChannelsHint ? 'medium' : 'low',
reason: endpoint.host ? 'Manual entry contains a Channels HTTP API host.' : 'Manual entry contains Channels snapshot metadata.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: channelsDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.port || channelsDefaultPort,
name: inputArg.name || endpoint.host || 'Channels',
manufacturer: inputArg.manufacturer || 'Fancy Bits',
model: inputArg.model || 'Channels',
metadata: {
...metadata,
url: endpoint.url,
status: inputArg.status,
favoriteChannels: inputArg.favoriteChannels,
snapshot: inputArg.snapshot,
discoveryProtocol: 'manual',
channelsApi: true,
},
},
};
}
}
export class ChannelsHttpApiMatcher implements IDiscoveryMatcher<IChannelsHttpApiEntry> {
public id = 'channels-http-api-match';
public source = 'http' as const;
public description = 'Recognize Channels HTTP API status responses.';
public async matches(inputArg: IChannelsHttpApiEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromInput(inputArg);
const status = statusFromHttpEntry(inputArg);
const matched = Boolean(status || inputArg.metadata?.channelsApi);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'HTTP payload is not a Channels API status response.' };
}
const normalizedDeviceId = inputArg.id || endpoint.host || inputArg.name;
return {
matched: true,
confidence: status && endpoint.host ? 'high' : 'medium',
reason: 'HTTP payload matches Channels API status shape.',
normalizedDeviceId,
candidate: {
source: 'http',
integrationDomain: channelsDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.port || channelsDefaultPort,
name: inputArg.name || endpoint.host || 'Channels',
manufacturer: 'Fancy Bits',
model: 'Channels',
metadata: {
...inputArg.metadata,
url: endpoint.url,
status,
favoriteChannels: inputArg.favoriteChannels,
channelsApi: true,
},
},
};
}
}
export class ChannelsCandidateValidator implements IDiscoveryValidator {
public id = 'channels-candidate-validator';
public description = 'Validate Channels HTTP API candidates.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = candidateArg.metadata || {};
const matched = candidateArg.integrationDomain === channelsDomain
|| Boolean(metadata.channels || metadata.channelsApi || metadata.channelsDvr || metadata.channels_dvr)
|| (candidateArg.source === 'manual' && Boolean(candidateArg.host) && includesChannels(`${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Candidate is not Channels.' };
}
return {
matched: true,
confidence: candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Channels HTTP API metadata.',
normalizedDeviceId: candidateArg.id || candidateArg.host,
candidate: {
...candidateArg,
integrationDomain: channelsDomain,
port: candidateArg.port || channelsDefaultPort,
manufacturer: candidateArg.manufacturer || 'Fancy Bits',
model: candidateArg.model || 'Channels',
metadata: {
...metadata,
channelsApi: true,
},
},
};
}
}
export const createChannelsDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: channelsDomain, displayName: 'Channels' })
.addMatcher(new ChannelsManualMatcher())
.addMatcher(new ChannelsHttpApiMatcher())
.addValidator(new ChannelsCandidateValidator());
};
const statusFromHttpEntry = (inputArg: IChannelsHttpApiEntry): IChannelsStatus | undefined => {
const status = inputArg.status || inputArg.response;
if (!status || typeof status !== 'object') {
return undefined;
}
if (typeof status.status === 'string' || status.channel || status.now_playing || status.nowPlaying) {
return status;
}
return undefined;
};
const endpointFromInput = (inputArg: { host?: string; port?: number; url?: string }): { host?: string; port?: number; url?: string } => {
const raw = inputArg.url || inputArg.host;
if (raw) {
const value = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
try {
const url = new URL(value);
if (!url.port) {
url.port = String(inputArg.port || channelsDefaultPort);
}
url.pathname = '';
url.search = '';
url.hash = '';
return {
host: url.hostname,
port: Number(url.port) || channelsDefaultPort,
url: url.toString().replace(/\/$/, ''),
};
} catch {
return {
host: inputArg.host,
port: inputArg.port || channelsDefaultPort,
};
}
}
return { port: inputArg.port || channelsDefaultPort };
};
const includesChannels = (valueArg: string | undefined): boolean => {
return Boolean(valueArg?.toLowerCase().includes('channels'));
};
export type TChannelsDiscoveryStatus = IChannelsStatus;
export type TChannelsDiscoverySnapshot = IChannelsSnapshot;
export type TChannelsDiscoveryFavoriteChannel = IChannelsFavoriteChannel;
+144
View File
@@ -0,0 +1,144 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IChannelsFavoriteChannel, IChannelsNowPlaying, IChannelsSnapshot, IChannelsStatusChannel } from './channels.types.js';
const defaultImageUrl = 'https://getchannels.com/assets/img/icon-1024.png';
export class ChannelsMapper {
public static toDevices(snapshotArg: IChannelsSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
return [{
id: this.deviceId(snapshotArg),
integrationDomain: 'channels',
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Fancy Bits',
model: snapshotArg.deviceInfo.model || 'Channels',
online: snapshotArg.online,
features: [
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
{ id: 'channel', capability: 'media', name: 'Channel', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
{ id: 'episode_title', capability: 'media', name: 'Episode title', readable: true, writable: false },
{ id: 'summary', capability: 'media', name: 'Summary', readable: true, writable: false },
],
state: [
{ featureId: 'playback', value: this.mediaState(snapshotArg), updatedAt },
{ featureId: 'muted', value: snapshotArg.status.muted ?? null, updatedAt },
{ featureId: 'channel', value: this.channelNumber(snapshotArg.status.channel) ?? null, updatedAt },
{ featureId: 'current_title', value: this.nowPlaying(snapshotArg)?.title || null, updatedAt },
{ featureId: 'episode_title', value: this.episodeTitle(this.nowPlaying(snapshotArg)) || null, updatedAt },
{ featureId: 'summary', value: this.nowPlaying(snapshotArg)?.summary || null, updatedAt },
],
metadata: {
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
channelName: this.channelName(snapshotArg.status.channel),
channelNumber: this.channelNumber(snapshotArg.status.channel),
status: snapshotArg.status.status,
favoriteChannels: snapshotArg.favoriteChannels,
},
}];
}
public static toEntities(snapshotArg: IChannelsSnapshot): IIntegrationEntity[] {
const channel = snapshotArg.status.channel;
const nowPlaying = this.nowPlaying(snapshotArg);
return [{
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
uniqueId: `channels_${this.uniqueBase(snapshotArg)}`,
integrationDomain: 'channels',
deviceId: this.deviceId(snapshotArg),
platform: 'media_player',
name: this.deviceName(snapshotArg),
state: this.mediaState(snapshotArg),
attributes: {
isVolumeMuted: snapshotArg.status.muted,
mediaContentId: this.channelNumber(channel),
mediaContentType: 'channel',
mediaTitle: nowPlaying?.title,
mediaEpisodeTitle: this.episodeTitle(nowPlaying),
mediaSeason: this.numberValue(nowPlaying?.season_number ?? nowPlaying?.seasonNumber),
mediaEpisode: this.numberValue(nowPlaying?.episode_number ?? nowPlaying?.episodeNumber),
mediaSummary: nowPlaying?.summary,
mediaImageUrl: this.mediaImageUrl(snapshotArg),
source: this.channelName(channel),
sourceList: snapshotArg.favoriteChannels.map((channelArg) => this.channelName(channelArg)).filter((channelArg): channelArg is string => Boolean(channelArg)),
channelNumber: this.channelNumber(channel),
channelName: this.channelName(channel),
status: snapshotArg.status.status,
},
available: snapshotArg.online,
}];
}
public static deviceId(snapshotArg: IChannelsSnapshot): string {
return `channels.device.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'channels';
}
public static channelNumber(channelArg: IChannelsStatusChannel | IChannelsFavoriteChannel | undefined): string | undefined {
const value = channelArg?.channel_number ?? channelArg?.number;
return value === undefined || value === null ? undefined : String(value);
}
public static channelName(channelArg: IChannelsStatusChannel | IChannelsFavoriteChannel | undefined): string | undefined {
return channelArg?.channel_name || channelArg?.name || channelArg?.call_sign;
}
public static channelImageUrl(channelArg: IChannelsStatusChannel | IChannelsFavoriteChannel | undefined): string | undefined {
return channelArg?.channel_image_url || channelArg?.image_url;
}
public static mediaImageUrl(snapshotArg: IChannelsSnapshot): string {
const nowPlaying = this.nowPlaying(snapshotArg);
return nowPlaying?.image_url || nowPlaying?.imageUrl || this.channelImageUrl(snapshotArg.status.channel) || defaultImageUrl;
}
private static mediaState(snapshotArg: IChannelsSnapshot): string {
if (!snapshotArg.online) {
return 'off';
}
if (snapshotArg.status.status === 'stopped') {
return 'idle';
}
if (snapshotArg.status.status === 'paused') {
return 'paused';
}
if (snapshotArg.status.status === 'playing') {
return 'playing';
}
return snapshotArg.status.status || 'unknown';
}
private static nowPlaying(snapshotArg: IChannelsSnapshot): IChannelsNowPlaying | undefined {
return snapshotArg.status.now_playing || snapshotArg.status.nowPlaying;
}
private static episodeTitle(nowPlayingArg: IChannelsNowPlaying | undefined): string | undefined {
return nowPlayingArg?.episode_title || nowPlayingArg?.episodeTitle;
}
private static uniqueBase(snapshotArg: IChannelsSnapshot): string {
return this.slug(snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg));
}
private static deviceName(snapshotArg: IChannelsSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Channels';
}
private static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
}
+141 -2
View File
@@ -1,4 +1,143 @@
export interface IHomeAssistantChannelsConfig {
// TODO: replace with the TypeScript-native config for channels.
export const channelsDefaultPort = 57000;
export interface IChannelsConfig {
host?: string;
port?: number;
timeoutMs?: number;
name?: string;
uniqueId?: string;
connected?: boolean;
deviceInfo?: IChannelsDeviceInfo;
status?: IChannelsStatus;
favoriteChannels?: IChannelsFavoriteChannel[];
snapshot?: IChannelsSnapshot;
commandExecutor?: TChannelsCommandExecutor;
}
export interface IHomeAssistantChannelsConfig extends IChannelsConfig {}
export interface IChannelsDeviceInfo {
id?: string;
name?: string;
host?: string;
port?: number;
manufacturer?: string;
model?: string;
softwareVersion?: string;
}
export interface IChannelsStatus {
status?: 'stopped' | 'paused' | 'playing' | 'offline' | 'error' | string;
muted?: boolean;
channel?: IChannelsStatusChannel;
now_playing?: IChannelsNowPlaying;
nowPlaying?: IChannelsNowPlaying;
[key: string]: unknown;
}
export interface IChannelsStatusChannel {
number?: string | number;
name?: string;
image_url?: string;
channel_number?: string | number;
channel_name?: string;
channel_image_url?: string;
call_sign?: string;
hd?: boolean;
[key: string]: unknown;
}
export interface IChannelsNowPlaying {
title?: string;
episode_title?: string;
episodeTitle?: string;
season_number?: number | string;
seasonNumber?: number | string;
episode_number?: number | string;
episodeNumber?: number | string;
summary?: string;
image_url?: string;
imageUrl?: string;
[key: string]: unknown;
}
export interface IChannelsFavoriteChannel extends IChannelsStatusChannel {}
export interface IChannelsSnapshot {
deviceInfo: IChannelsDeviceInfo;
status: IChannelsStatus;
favoriteChannels: IChannelsFavoriteChannel[];
online: boolean;
updatedAt?: string;
}
export interface IChannelsManualEntry {
host?: string;
port?: number;
url?: string;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
status?: IChannelsStatus;
favoriteChannels?: IChannelsFavoriteChannel[];
snapshot?: IChannelsSnapshot;
}
export interface IChannelsHttpApiEntry {
host?: string;
port?: number;
url?: string;
path?: string;
name?: string;
id?: string;
status?: IChannelsStatus;
response?: IChannelsStatus;
favoriteChannels?: IChannelsFavoriteChannel[];
metadata?: Record<string, unknown>;
}
export interface IChannelsCommand {
type: TChannelsCommandType;
method: 'POST';
path: string;
body?: unknown;
seconds?: number;
mediaId?: string;
mediaType?: string;
source?: string;
}
export interface IChannelsCommandResult {
success: boolean;
transmitted?: boolean;
status?: IChannelsStatus;
snapshot?: IChannelsSnapshot;
data?: unknown;
error?: string;
}
export type TChannelsCommandExecutor = (commandArg: IChannelsCommand) => Promise<IChannelsCommandResult>;
export type TChannelsCommandType =
| 'toggle_mute'
| 'toggle_cc'
| 'toggle_record'
| 'channel_up'
| 'channel_down'
| 'previous_channel'
| 'toggle_pause'
| 'pause'
| 'resume'
| 'stop'
| 'seek'
| 'seek_forward'
| 'seek_backward'
| 'skip_forward'
| 'skip_backward'
| 'play_channel'
| 'play_recording'
| 'navigate'
| 'notify'
| string;
+4
View File
@@ -1,2 +1,6 @@
export * from './channels.classes.integration.js';
export * from './channels.classes.client.js';
export * from './channels.classes.configflow.js';
export * from './channels.discovery.js';
export * from './channels.mapper.js';
export * from './channels.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,346 @@
import { DelugeMapper, endpointFromConfig } from './deluge.mapper.js';
import type { IDelugeCommandRequest, IDelugeConfig, IDelugeRawData, IDelugeSnapshot, IDelugeWebUpdateUiResponse } from './deluge.types.js';
import { delugeDefaultTimeoutMs } from './deluge.types.js';
const delugeTorrentKeys = [
'name',
'state',
'paused',
'progress',
'download_payload_rate',
'upload_payload_rate',
'download_rate',
'upload_rate',
'total_size',
'total_done',
'ratio',
'eta',
'num_seeds',
'num_peers',
'tracker_host',
'save_path',
];
const delugeSessionStatusKeys = ['download_rate', 'upload_rate', 'dht_download_rate', 'dht_upload_rate'];
export class DelugeHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'DelugeHttpError';
}
}
export class DelugeClient {
private snapshot?: IDelugeSnapshot;
private cookie?: string;
private rpcId = 0;
constructor(private readonly config: IDelugeConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IDelugeSnapshot> {
if (!forceRefreshArg && this.snapshot) {
return this.snapshot;
}
if (!forceRefreshArg && this.config.snapshot) {
this.snapshot = this.cloneSnapshot(this.config.snapshot);
return this.snapshot;
}
if (this.config.client?.getSnapshot) {
this.snapshot = this.snapshotFromUnknown(await this.config.client.getSnapshot(), 'client');
return this.snapshot;
}
if (this.config.client?.getRawData) {
this.snapshot = DelugeMapper.toSnapshot({ config: this.config, rawData: await this.config.client.getRawData(), online: true, source: 'client' });
return this.snapshot;
}
if (this.config.rawData) {
this.snapshot = DelugeMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: this.config.connected ?? true, source: 'manual' });
return this.snapshot;
}
if (this.hasLiveReadTarget()) {
try {
this.snapshot = await this.fetchSnapshot();
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<IDelugeSnapshot> {
if (this.config.snapshot) {
return this.cloneSnapshot(this.config.snapshot);
}
if (this.config.rawData) {
return DelugeMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: true, source: 'manual' });
}
if (this.config.client?.getSnapshot) {
return this.snapshotFromUnknown(await this.config.client.getSnapshot(), 'client');
}
if (!this.hasLiveReadTarget()) {
throw new Error('Deluge live validation requires config.host/config.url and a Deluge Web password, or a snapshot/client.');
}
return this.fetchSnapshot();
}
public async execute(commandArg: IDelugeCommandRequest): Promise<unknown> {
if (commandArg.action === 'snapshot') {
return this.getSnapshot(false);
}
if (commandArg.action === 'refresh') {
return this.getSnapshot(true);
}
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client?.execute) {
return this.config.client.execute(commandArg);
}
if (this.config.client?.call) {
return this.executeWithInjectedCall(commandArg);
}
if (!this.hasLiveReadTarget()) {
throw new Error('Deluge controls require config.host/config.url and password, or an injected client/commandExecutor.');
}
return this.executeLive(commandArg);
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async fetchSnapshot(): Promise<IDelugeSnapshot> {
await this.ensureAuthenticated();
let connectError: string | undefined;
if (this.hasDaemonCredentials()) {
try {
await this.ensureDaemonConnected();
} catch (errorArg) {
connectError = errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
const webUi = await this.jsonRpc<IDelugeWebUpdateUiResponse>('web.update_ui', [delugeTorrentKeys, {}]);
const rawData: Partial<IDelugeRawData> = {
webUi,
fetchedAt: new Date().toISOString(),
errors: connectError ? { connect: connectError } : undefined,
};
if (webUi.connected) {
rawData.sessionStatus = await this.jsonRpc<Record<string, unknown>>('core.get_session_status', [delugeSessionStatusKeys]).catch(() => undefined);
rawData.sessionState = await this.jsonRpc<string[]>('core.get_session_state', []).catch(() => undefined);
}
return DelugeMapper.toSnapshot({ config: this.config, rawData, online: Boolean(webUi.connected), source: 'web', error: connectError });
}
private async executeLive(commandArg: IDelugeCommandRequest): Promise<unknown> {
await this.ensureDaemonConnected();
if (commandArg.action === 'pause' || commandArg.action === 'resume') {
const torrentIds = await this.torrentIds(commandArg);
if (!torrentIds.length) {
return { ok: true, action: commandArg.action, torrentIds };
}
await this.jsonRpc(commandArg.action === 'pause' ? 'core.pause_torrent' : 'core.resume_torrent', [torrentIds]);
this.snapshot = undefined;
return { ok: true, action: commandArg.action, torrentIds };
}
if (commandArg.action === 'add_torrent') {
const path = await this.torrentPath(commandArg);
const result = await this.jsonRpc('web.add_torrents', [[{ path, options: commandArg.options || {} }]]);
this.snapshot = undefined;
return { ok: true, action: commandArg.action, path, result };
}
if (commandArg.action === 'remove_torrent') {
const torrentIds = commandArg.torrentIds || [];
if (!torrentIds.length) {
throw new Error('Deluge remove_torrent requires data.torrent_id or data.torrent_ids.');
}
const results: unknown[] = [];
for (const torrentId of torrentIds) {
results.push(await this.jsonRpc('core.remove_torrent', [torrentId, commandArg.removeData === true]));
}
this.snapshot = undefined;
return { ok: true, action: commandArg.action, torrentIds, results };
}
throw new Error(`Unsupported Deluge command: ${commandArg.action}`);
}
private async executeWithInjectedCall(commandArg: IDelugeCommandRequest): Promise<unknown> {
const call = this.config.client?.call;
if (!call) {
throw new Error('Deluge injected client has no call method.');
}
if (commandArg.action === 'pause' || commandArg.action === 'resume') {
const torrentIds = commandArg.torrentIds?.length ? commandArg.torrentIds : await this.injectedSessionState();
return call(commandArg.action === 'pause' ? 'core.pause_torrent' : 'core.resume_torrent', torrentIds);
}
if (commandArg.action === 'add_torrent') {
return call('web.add_torrents', [{ path: commandArg.magnet || commandArg.path || commandArg.url, options: commandArg.options || {} }]);
}
if (commandArg.action === 'remove_torrent') {
const torrentIds = commandArg.torrentIds || [];
if (!torrentIds.length) {
throw new Error('Deluge remove_torrent requires data.torrent_id or data.torrent_ids.');
}
return Promise.all(torrentIds.map((torrentIdArg) => call('core.remove_torrent', torrentIdArg, commandArg.removeData === true)));
}
throw new Error(`Unsupported Deluge command: ${commandArg.action}`);
}
private async torrentIds(commandArg: IDelugeCommandRequest): Promise<string[]> {
if (commandArg.torrentIds?.length) {
return commandArg.torrentIds;
}
const sessionState = await this.jsonRpc<string[]>('core.get_session_state', []).catch(() => undefined);
if (Array.isArray(sessionState)) {
return sessionState.filter((itemArg) => typeof itemArg === 'string');
}
return (await this.getSnapshot()).torrents.map((torrentArg) => torrentArg.id);
}
private async injectedSessionState(): Promise<string[]> {
const result = await this.config.client?.call?.('core.get_session_state');
return Array.isArray(result) && result.every((itemArg) => typeof itemArg === 'string') ? result : [];
}
private async torrentPath(commandArg: IDelugeCommandRequest): Promise<string> {
if (commandArg.magnet) {
return commandArg.magnet;
}
if (commandArg.path) {
return commandArg.path;
}
if (commandArg.url) {
return this.jsonRpc<string>('web.download_torrent_from_url', [commandArg.url, commandArg.cookie || null]);
}
throw new Error('Deluge add_torrent requires data.magnet, data.url, or data.path.');
}
private async ensureDaemonConnected(): Promise<void> {
await this.ensureAuthenticated();
const connected = await this.jsonRpc<boolean>('web.connected', []).catch(() => false);
if (connected) {
return;
}
if (!this.hasDaemonCredentials()) {
throw new Error('Deluge Web is not connected to a daemon; provide daemonHost/username/daemonPassword or connect it in Deluge Web.');
}
const hostId = await this.hostId();
await this.jsonRpc('web.connect', [hostId]);
const nowConnected = await this.jsonRpc<boolean>('web.connected', []).catch(() => false);
if (!nowConnected) {
throw new Error('Deluge Web did not connect to the configured daemon.');
}
}
private async hostId(): Promise<string> {
const endpoint = endpointFromConfig(this.config);
const daemonHost = this.config.daemonHost || endpoint.host;
const daemonPort = this.config.daemonPort || this.config.rpcPort || endpoint.rpcPort;
const hosts = await this.jsonRpc<unknown[]>('web.get_hosts', []).catch(() => []);
for (const host of hosts) {
const hostRecord = Array.isArray(host) ? host : undefined;
if (hostRecord && hostRecord.length >= 3 && hostRecord[1] === daemonHost && Number(hostRecord[2]) === daemonPort && typeof hostRecord[0] === 'string') {
return hostRecord[0];
}
}
const addResult = await this.jsonRpc<unknown[]>('web.add_host', [daemonHost, daemonPort, this.config.daemonUsername || this.config.username || '', this.config.daemonPassword || this.config.rpcPassword || this.config.password || '']);
if (Array.isArray(addResult) && addResult[0] === true && typeof addResult[1] === 'string') {
return addResult[1];
}
throw new Error(`Deluge Web could not add daemon host: ${Array.isArray(addResult) ? String(addResult[1]) : 'unknown error'}`);
}
private async ensureAuthenticated(): Promise<void> {
if (this.cookie) {
return;
}
const password = this.webPassword();
if (!password) {
throw new Error('Deluge Web JSON-RPC requires config.password or config.webPassword.');
}
const loggedIn = await this.jsonRpc<boolean>('auth.login', [password]);
if (!loggedIn) {
throw new Error('Deluge Web authentication failed.');
}
}
private async jsonRpc<TValue = unknown>(methodArg: string, paramsArg: unknown[] = [], retriedArg = false): Promise<TValue> {
const endpoint = endpointFromConfig(this.config);
if (!endpoint.url) {
throw new Error('Deluge Web JSON-RPC requires config.host or config.url.');
}
const headers = new Headers({ 'content-type': 'application/json' });
if (this.cookie) {
headers.set('cookie', this.cookie);
}
const response = await this.fetchWithTimeout(`${endpoint.url}/json`, {
method: 'POST',
headers,
body: JSON.stringify({ method: methodArg, params: paramsArg, id: ++this.rpcId }),
});
const setCookie = response.headers.get('set-cookie');
if (setCookie) {
this.cookie = setCookie.split(';')[0];
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new DelugeHttpError(response.status, `Deluge Web JSON-RPC ${methodArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
const body = await response.json() as { result?: TValue; error?: { message?: string; code?: number } | null };
if (body.error) {
const message = body.error.message || `Deluge Web JSON-RPC error ${body.error.code ?? 'unknown'}`;
if (!retriedArg && methodArg !== 'auth.login' && message.includes('Not authenticated')) {
this.cookie = undefined;
await this.ensureAuthenticated();
return this.jsonRpc<TValue>(methodArg, paramsArg, true);
}
throw new Error(message);
}
return body.result as TValue;
}
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || delugeDefaultTimeoutMs);
try {
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
} finally {
clearTimeout(timeout);
}
}
private snapshotFromUnknown(valueArg: unknown, sourceArg: 'client'): IDelugeSnapshot {
if (isDelugeSnapshot(valueArg)) {
return this.cloneSnapshot(valueArg);
}
return DelugeMapper.toSnapshot({ config: this.config, rawData: valueArg && typeof valueArg === 'object' ? valueArg as Partial<IDelugeRawData> : {}, online: true, source: sourceArg });
}
private snapshotFromConfig(connectedArg: boolean, errorArg?: string): IDelugeSnapshot {
return DelugeMapper.toSnapshot({ config: this.config, rawData: this.config.rawData || {}, online: connectedArg, source: this.config.snapshot ? 'snapshot' : 'manual', error: errorArg });
}
private hasLiveReadTarget(): boolean {
const endpoint = endpointFromConfig(this.config);
return Boolean(endpoint.url && this.webPassword());
}
private hasDaemonCredentials(): boolean {
return Boolean((this.config.daemonHost || this.config.host || this.config.url) && (this.config.daemonUsername || this.config.username || this.config.daemonPassword || this.config.rpcPassword));
}
private webPassword(): string | undefined {
return this.config.webPassword || this.config.password;
}
private cloneSnapshot(snapshotArg: IDelugeSnapshot): IDelugeSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IDelugeSnapshot;
}
}
const isDelugeSnapshot = (valueArg: unknown): valueArg is IDelugeSnapshot => {
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'torrents' in valueArg && 'sensors' in valueArg);
};
@@ -0,0 +1,135 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { DelugeClient } from './deluge.classes.client.js';
import type { IDelugeConfig, IDelugeRawData, IDelugeSnapshot, TDelugeProtocol } from './deluge.types.js';
import { delugeDefaultRpcPort, delugeDefaultTimeoutMs, delugeDefaultWebPort } from './deluge.types.js';
export class DelugeConfigFlow implements IConfigFlow<IDelugeConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDelugeConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Deluge',
description: 'Configure a local Deluge Web JSON-RPC endpoint. Use a base URL such as http://192.168.1.20:8112 or host plus web port.',
fields: [
{ name: 'url', label: 'Deluge Web URL', type: 'text' },
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'webPort', label: 'Web port', type: 'number' },
{ name: 'password', label: 'Deluge Web password', type: 'password' },
{ name: 'daemonHost', label: 'Daemon host', type: 'text' },
{ name: 'daemonPort', label: 'Daemon RPC port', type: 'number' },
{ name: 'username', label: 'Daemon username', type: 'text' },
{ name: 'daemonPassword', label: 'Daemon password', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDelugeConfig>> {
const snapshot = snapshotMetadata(candidateArg);
if (snapshot) {
return { kind: 'done', title: 'Deluge configured', config: { snapshot, name: snapshot.device.name, uniqueId: snapshot.device.id } };
}
const rawData = rawDataMetadata(candidateArg);
const injectedClient = candidateArg.metadata?.client;
if (injectedClient && typeof injectedClient === 'object') {
return { kind: 'done', title: 'Deluge configured', config: { client: injectedClient as IDelugeConfig['client'], name: stringValue(valuesArg.name) || candidateArg.name || 'Deluge', uniqueId: candidateArg.id } };
}
if (rawData) {
return { kind: 'done', title: 'Deluge configured', config: { rawData, name: stringValue(valuesArg.name) || candidateArg.name || 'Deluge', uniqueId: candidateArg.id } };
}
const resolvedEndpoint = resolveEndpoint(stringValue(valuesArg.url) || stringMetadata(candidateArg, 'url'), stringValue(valuesArg.host) || candidateArg.host, numberValue(valuesArg.webPort) || candidateArg.port || numberMetadata(candidateArg, 'webPort') || numberMetadata(candidateArg, 'web_port'), protocolMetadata(candidateArg));
if (!resolvedEndpoint.host) {
return { kind: 'error', error: 'Deluge setup requires a Deluge Web URL, host, snapshot, raw data, or injected client.' };
}
const password = stringValue(valuesArg.password) || stringMetadata(candidateArg, 'password') || stringMetadata(candidateArg, 'webPassword');
if (!password) {
return { kind: 'error', error: 'Deluge Web JSON-RPC setup requires the Deluge Web password.' };
}
const config: IDelugeConfig = {
protocol: resolvedEndpoint.protocol,
host: resolvedEndpoint.host,
webPort: resolvedEndpoint.webPort,
url: resolvedEndpoint.url,
password,
daemonHost: stringValue(valuesArg.daemonHost) || stringMetadata(candidateArg, 'daemonHost') || resolvedEndpoint.host,
daemonPort: numberValue(valuesArg.daemonPort) || numberMetadata(candidateArg, 'daemonPort') || numberMetadata(candidateArg, 'rpcPort') || delugeDefaultRpcPort,
username: stringValue(valuesArg.username) || stringMetadata(candidateArg, 'username'),
daemonPassword: stringValue(valuesArg.daemonPassword) || stringMetadata(candidateArg, 'daemonPassword') || stringMetadata(candidateArg, 'rpcPassword'),
name: stringValue(valuesArg.name) || candidateArg.name || resolvedEndpoint.host,
uniqueId: candidateArg.id || resolvedEndpoint.host,
manufacturer: candidateArg.manufacturer || 'Deluge',
model: candidateArg.model,
timeoutMs: delugeDefaultTimeoutMs,
};
try {
const liveSnapshot = await new DelugeClient(config).validateConnection();
return { kind: 'done', title: 'Deluge configured', config: { ...config, uniqueId: liveSnapshot.device.id, name: config.name || liveSnapshot.device.name } };
} catch (errorArg) {
return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
}
const resolveEndpoint = (urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TDelugeProtocol | undefined): { protocol: TDelugeProtocol; host?: string; webPort: number; url?: string } => {
const url = safeUrl(urlArg || hostArg);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const webPort = url.port ? Number(url.port) : protocol === 'https' ? 443 : delugeDefaultWebPort;
return { protocol, host: url.hostname, webPort, url: url.toString().replace(/\/$/, '') };
}
const protocol = protocolArg || 'http';
const webPort = portArg || delugeDefaultWebPort;
return { protocol, host: hostArg, webPort, url: hostArg ? `${protocol}://${hostArg}:${webPort}` : undefined };
};
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 valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
const stringMetadata = (candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined => {
const value = candidateArg.metadata?.[keyArg];
return stringValue(value);
};
const numberMetadata = (candidateArg: IDiscoveryCandidate, keyArg: string): number | undefined => {
return numberValue(candidateArg.metadata?.[keyArg]);
};
const protocolMetadata = (candidateArg: IDiscoveryCandidate): TDelugeProtocol | undefined => {
const protocol = candidateArg.metadata?.protocol;
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
};
const snapshotMetadata = (candidateArg: IDiscoveryCandidate): IDelugeSnapshot | undefined => {
const snapshot = candidateArg.metadata?.snapshot;
return snapshot && typeof snapshot === 'object' && 'device' in snapshot && 'torrents' in snapshot ? snapshot as IDelugeSnapshot : undefined;
};
const rawDataMetadata = (candidateArg: IDiscoveryCandidate): Partial<IDelugeRawData> | undefined => {
const rawData = candidateArg.metadata?.rawData;
return rawData && typeof rawData === 'object' ? rawData as Partial<IDelugeRawData> : undefined;
};
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
@@ -1,26 +1,96 @@
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 { DelugeClient } from './deluge.classes.client.js';
import { DelugeConfigFlow } from './deluge.classes.configflow.js';
import { createDelugeDiscoveryDescriptor } from './deluge.discovery.js';
import { DelugeMapper } from './deluge.mapper.js';
import type { IDelugeConfig } from './deluge.types.js';
import { delugeDefaultRpcPort, delugeDefaultWebPort, delugeDisplayName, delugeDomain } from './deluge.types.js';
export class HomeAssistantDelugeIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "deluge",
displayName: "Deluge",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/deluge",
"upstreamDomain": "deluge",
"integrationType": "service",
"iotClass": "local_polling",
"requirements": [
"deluge-client==1.10.2"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@tkdrob"
]
},
});
export class DelugeIntegration extends BaseIntegration<IDelugeConfig> {
public readonly domain = delugeDomain;
public readonly displayName = delugeDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDelugeDiscoveryDescriptor();
public readonly configFlow = new DelugeConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/deluge',
upstreamDomain: delugeDomain,
integrationType: 'service',
iotClass: 'local_polling',
requirements: ['deluge-client==1.10.2'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@tkdrob'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/deluge',
platforms: ['sensor', 'switch'],
discovery: {
manual: true,
http: `Manual/local Deluge Web JSON-RPC endpoint on port ${delugeDefaultWebPort}`,
},
runtime: {
type: 'control-runtime',
polling: 'local Deluge Web JSON-RPC snapshots at /json',
services: ['snapshot', 'status', 'refresh', 'pause', 'resume', 'add_torrent', 'remove_torrent'],
controls: 'Deluge Web JSON-RPC with password, or delegated to an injected client/commandExecutor',
},
localApi: {
implemented: [
'manual host/url, static snapshot/raw-data, and injected client/executor configuration',
`Deluge Web JSON-RPC authentication against /json on port ${delugeDefaultWebPort}`,
'web.update_ui snapshots with Home Assistant-compatible status, transfer-rate, and torrent-count sensors',
`optional web.add_host/web.connect to a daemon on RPC port ${delugeDefaultRpcPort} when daemon credentials are provided`,
'core.pause_torrent, core.resume_torrent, web.add_torrents/download_torrent_from_url, and core.remove_torrent controls when a live transport or executor is available',
],
explicitUnsupported: [
'raw Deluge daemon RPC without Deluge Web JSON-RPC or an injected client',
'pretending controls succeeded for static snapshots without host/password/client/executor',
'torrent file upload bodies; use magnet/url/server-side path or an injected executor',
],
},
};
public async setup(configArg: IDelugeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DelugeRuntime(new DelugeClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDelugeIntegration extends DelugeIntegration {}
class DelugeRuntime implements IIntegrationRuntime {
public domain = delugeDomain;
constructor(private readonly client: DelugeClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DelugeMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DelugeMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
const snapshot = await this.client.getSnapshot();
const command = DelugeMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Deluge 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();
}
}
+217
View File
@@ -0,0 +1,217 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IDelugeHttpDiscoveryInput, IDelugeManualEntry, TDelugeProtocol } from './deluge.types.js';
import { delugeDefaultRpcPort, delugeDefaultWebPort, delugeDisplayName, delugeDomain, delugeManufacturer } from './deluge.types.js';
export class DelugeManualMatcher implements IDiscoveryMatcher<IDelugeManualEntry> {
public id = 'deluge-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Deluge Web JSON-RPC endpoints, snapshots, raw data, or injected clients.';
public async matches(inputArg: IDelugeManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromManual(inputArg);
const hasStaticSource = Boolean(inputArg.snapshot || inputArg.rawData || inputArg.client || inputArg.commandExecutor || inputArg.metadata?.snapshot || inputArg.metadata?.rawData || inputArg.metadata?.client);
const hasHint = Boolean(endpoint.host || hasStaticSource || inputArg.metadata?.deluge || inputArg.metadata?.deluge_web || inputArg.metadata?.domain === delugeDomain);
if (!hasHint) {
return { matched: false, confidence: 'low', reason: 'Manual Deluge entry requires host, url, snapshot/raw data, injected client, or Deluge metadata.' };
}
const normalizedDeviceId = inputArg.id || endpoint.host || endpoint.url || inputArg.snapshot?.device.id || 'manual-deluge';
return {
matched: true,
confidence: endpoint.host || hasStaticSource ? 'high' : 'medium',
reason: hasStaticSource ? 'Manual entry contains Deluge snapshot/raw data or injected client metadata.' : 'Manual entry contains a Deluge Web endpoint.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: delugeDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.webPort,
name: inputArg.name || endpoint.host || delugeDisplayName,
manufacturer: inputArg.manufacturer || delugeManufacturer,
model: inputArg.model,
metadata: {
...inputArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
webPort: endpoint.webPort,
rpcPort: endpoint.rpcPort,
password: inputArg.password,
webPassword: inputArg.webPassword,
username: inputArg.username || inputArg.daemonUsername,
daemonHost: inputArg.daemonHost || endpoint.host,
daemonPassword: inputArg.daemonPassword,
snapshot: inputArg.snapshot || inputArg.metadata?.snapshot,
rawData: inputArg.rawData || inputArg.metadata?.rawData,
client: inputArg.client || inputArg.metadata?.client,
commandExecutor: inputArg.commandExecutor || inputArg.metadata?.commandExecutor,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
manualSupported: true,
},
};
}
}
export class DelugeHttpMatcher implements IDiscoveryMatcher<IDelugeHttpDiscoveryInput> {
public id = 'deluge-http-match';
public source = 'http' as const;
public description = 'Recognize HTTP candidates that point at a Deluge Web JSON-RPC endpoint.';
public async matches(inputArg: IDelugeHttpDiscoveryInput, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromHttp(inputArg);
const hasJsonPath = inputArg.path === '/json' || endpoint.url?.endsWith('/json');
const hasDelugeHint = Boolean(inputArg.metadata?.deluge || inputArg.metadata?.deluge_web || inputArg.metadata?.server === 'deluge' || inputArg.name?.toLowerCase().includes('deluge'));
const isDefaultWebPort = endpoint.webPort === delugeDefaultWebPort;
if (!endpoint.host || (!hasJsonPath && !hasDelugeHint && !isDefaultWebPort)) {
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Deluge Web JSON-RPC endpoint.' };
}
const normalizedDeviceId = inputArg.id || endpoint.host;
return {
matched: true,
confidence: hasJsonPath || hasDelugeHint ? 'high' : 'medium',
reason: hasJsonPath ? 'HTTP candidate targets /json.' : isDefaultWebPort ? `HTTP candidate uses Deluge Web default port ${delugeDefaultWebPort}.` : 'HTTP candidate includes Deluge metadata.',
normalizedDeviceId,
candidate: {
source: 'http',
integrationDomain: delugeDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.webPort,
name: inputArg.name || endpoint.host,
manufacturer: inputArg.manufacturer || delugeManufacturer,
model: inputArg.model,
metadata: {
...inputArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
webPort: endpoint.webPort,
rpcPort: delugeDefaultRpcPort,
discoveryProtocol: 'http',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export class DelugeCandidateValidator implements IDiscoveryValidator {
public id = 'deluge-candidate-validator';
public description = 'Validate that a Deluge candidate has a usable local source.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== delugeDomain) {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Deluge.` };
}
const endpoint = endpointFromCandidate(candidateArg);
const hasStaticSource = Boolean(candidateArg.metadata?.snapshot || candidateArg.metadata?.rawData || candidateArg.metadata?.client);
if (!endpoint.host && !hasStaticSource) {
return { matched: false, confidence: 'low', reason: 'Deluge candidate lacks a host, snapshot, raw data, or injected client.' };
}
if (endpoint.host && (!Number.isInteger(endpoint.webPort) || endpoint.webPort < 1 || endpoint.webPort > 65535)) {
return { matched: false, confidence: 'low', reason: 'Deluge candidate has an invalid web port.' };
}
return {
matched: true,
confidence: candidateArg.source === 'manual' || hasStaticSource ? 'high' : 'medium',
reason: 'Candidate has enough Deluge metadata to start configuration.',
normalizedDeviceId: candidateArg.id || endpoint.host || 'manual-deluge',
candidate: {
...candidateArg,
integrationDomain: delugeDomain,
host: endpoint.host,
port: endpoint.webPort,
manufacturer: candidateArg.manufacturer || delugeManufacturer,
metadata: {
...candidateArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
webPort: endpoint.webPort,
rpcPort: endpoint.rpcPort,
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
manualSupported: candidateArg.source === 'manual',
},
};
}
}
export const createDelugeDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: delugeDomain, displayName: delugeDisplayName })
.addMatcher(new DelugeManualMatcher())
.addMatcher(new DelugeHttpMatcher())
.addValidator(new DelugeCandidateValidator());
};
const endpointFromManual = (inputArg: IDelugeManualEntry): { protocol: TDelugeProtocol; host?: string; webPort: number; rpcPort: number; url?: string } => {
const url = safeUrl(inputArg.url || inputArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const webPort = url.port ? Number(url.port) : protocol === 'https' ? 443 : delugeDefaultWebPort;
return { protocol, host: url.hostname, webPort, rpcPort: inputArg.rpcPort || inputArg.daemonPort || inputArg.port || delugeDefaultRpcPort, url: url.toString().replace(/\/json$/, '').replace(/\/$/, '') };
}
const protocol = inputArg.protocol || 'http';
const webPort = inputArg.webPort || inputArg.web_port || ((inputArg.rpcPort || inputArg.daemonPort) ? undefined : inputArg.port) || delugeDefaultWebPort;
return { protocol, host: inputArg.host, webPort, rpcPort: inputArg.rpcPort || inputArg.daemonPort || ((inputArg.webPort || inputArg.web_port) ? inputArg.port : undefined) || delugeDefaultRpcPort, url: inputArg.host ? `${protocol}://${inputArg.host}:${webPort}` : undefined };
};
const endpointFromHttp = (inputArg: IDelugeHttpDiscoveryInput): { protocol: TDelugeProtocol; host?: string; webPort: 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 webPort = url.port ? Number(url.port) : protocol === 'https' ? 443 : (inputArg.port || delugeDefaultWebPort);
return { protocol, host: url.hostname, webPort, url: `${protocol}://${url.hostname}:${webPort}` };
}
const protocol = inputArg.metadata?.protocol === 'https' ? 'https' : 'http';
const webPort = inputArg.port || delugeDefaultWebPort;
return { protocol, host: inputArg.host, webPort, url: inputArg.host ? `${protocol}://${inputArg.host}:${webPort}` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TDelugeProtocol; host?: string; webPort: number; rpcPort: number; url?: string } => {
const protocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
const url = safeUrl(metadataUrl || candidateArg.host);
if (url) {
const urlProtocol = url.protocol === 'https:' ? 'https' : 'http';
const webPort = url.port ? Number(url.port) : urlProtocol === 'https' ? 443 : delugeDefaultWebPort;
return { protocol: urlProtocol, host: url.hostname, webPort, rpcPort: numberValue(candidateArg.metadata?.rpcPort) || delugeDefaultRpcPort, url: `${urlProtocol}://${url.hostname}:${webPort}` };
}
const webPort = numberValue(candidateArg.metadata?.webPort ?? candidateArg.metadata?.web_port) || candidateArg.port || delugeDefaultWebPort;
return { protocol, host: candidateArg.host, webPort, rpcPort: numberValue(candidateArg.metadata?.rpcPort) || delugeDefaultRpcPort, url: candidateArg.host ? `${protocol}://${candidateArg.host}:${webPort}` : metadataUrl };
};
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 safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
+457
View File
@@ -0,0 +1,457 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
import type {
IDelugeCommandRequest,
IDelugeConfig,
IDelugeRawData,
IDelugeSensorSnapshot,
IDelugeSessionStats,
IDelugeSnapshot,
IDelugeSnapshotInput,
IDelugeTorrentSnapshot,
TDelugeProtocol,
} from './deluge.types.js';
import { delugeDefaultRpcPort, delugeDefaultWebPort, delugeDisplayName, delugeDomain, delugeManufacturer, delugeSensorDescriptions } from './deluge.types.js';
const pauseServices = new Set(['pause', 'pause_torrent', 'pause_torrents', 'turn_off']);
const resumeServices = new Set(['resume', 'resume_torrent', 'resume_torrents', 'turn_on']);
const addServices = new Set(['add_torrent', 'add_torrents', 'add_magnet', 'add_url']);
const removeServices = new Set(['remove_torrent', 'remove_torrents']);
export class DelugeMapper {
public static toSnapshot(inputArg: IDelugeSnapshotInput): IDelugeSnapshot {
const config = inputArg.config || {};
const rawData = inputArg.rawData || {};
const webUi = rawData.webUi || rawData.updateUi;
const connected = booleanValue(webUi?.connected) ?? booleanValue(rawData.connected) ?? Boolean(inputArg.online);
const online = connected && inputArg.error === undefined;
const stats = this.stats(rawData);
const torrents = this.torrents(rawData, connected);
const sensors = this.sensors(stats, torrents, online);
const switches = [{
key: 'enabled',
name: 'Enabled',
isOn: torrents.some((torrentArg) => !torrentArg.paused),
available: online,
attributes: {
torrentCount: torrents.length,
pausedCount: torrents.filter((torrentArg) => torrentArg.paused).length,
},
}];
const endpoint = endpointFromConfig(config);
const updatedAt = rawData.fetchedAt || new Date().toISOString();
return {
device: {
id: config.uniqueId || endpoint.host || endpoint.url || 'manual-deluge',
name: config.name || endpoint.host || delugeDisplayName,
manufacturer: config.manufacturer || delugeManufacturer,
model: config.model,
host: endpoint.host,
webPort: endpoint.webPort,
rpcPort: endpoint.rpcPort,
protocol: endpoint.protocol,
url: endpoint.url,
online,
},
stats,
torrents,
sensors,
switches,
connected,
online,
source: inputArg.source || 'manual',
capabilities: {
localRead: Boolean(config.client || config.snapshot || config.rawData || (endpoint.host && (config.password || config.webPassword))),
localControl: Boolean(config.commandExecutor || config.client || (endpoint.host && (config.password || config.webPassword))),
addTorrent: Boolean(config.commandExecutor || config.client || (endpoint.host && (config.password || config.webPassword))),
removeTorrent: Boolean(config.commandExecutor || config.client || (endpoint.host && (config.password || config.webPassword))),
},
updatedAt,
rawData,
error: inputArg.error,
};
}
public static toDevices(snapshotArg: IDelugeSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
{ id: 'enabled', capability: 'switch', name: 'Enabled', readable: true, writable: snapshotArg.capabilities.localControl },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
{ featureId: 'enabled', value: snapshotArg.switches[0]?.isOn ?? false, updatedAt },
];
for (const sensor of snapshotArg.sensors) {
features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
state.push({ featureId: sensor.key, value: this.deviceStateValue(sensor.value), updatedAt });
}
features.push({ id: 'torrents', capability: 'sensor', name: 'Torrents', readable: true, writable: false });
state.push({ featureId: 'torrents', value: snapshotArg.torrents.length, updatedAt });
return [{
id: this.deviceId(snapshotArg),
integrationDomain: delugeDomain,
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.device.manufacturer || delugeManufacturer,
model: snapshotArg.device.model,
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
host: snapshotArg.device.host,
webPort: snapshotArg.device.webPort,
rpcPort: snapshotArg.device.rpcPort,
protocol: snapshotArg.device.protocol,
url: snapshotArg.device.url,
connected: snapshotArg.connected,
source: snapshotArg.source,
capabilities: snapshotArg.capabilities,
}),
}];
}
public static toEntities(snapshotArg: IDelugeSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
const deviceId = this.deviceId(snapshotArg);
const baseName = this.deviceName(snapshotArg);
for (const sensor of snapshotArg.sensors) {
entities.push(this.entity('sensor', `${baseName} ${sensor.name}`, deviceId, `deluge_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.value, usedIds, {
key: sensor.key,
nativeUnitOfMeasurement: sensor.unit,
deviceClass: sensor.deviceClass,
stateClass: sensor.stateClass,
entityCategory: sensor.entityCategory,
...sensor.attributes,
}, snapshotArg.online && sensor.available !== false));
}
const enabledSwitch = snapshotArg.switches[0];
entities.push(this.entity('switch', `${baseName} Enabled`, deviceId, `deluge_${this.uniqueBase(snapshotArg)}_enabled`, enabledSwitch?.isOn ? 'on' : 'off', usedIds, {
key: 'enabled',
torrentIds: snapshotArg.torrents.map((torrentArg) => torrentArg.id),
serviceMappings: {
turnOn: 'deluge.resume',
turnOff: 'deluge.pause',
},
...enabledSwitch?.attributes,
}, snapshotArg.online && enabledSwitch?.available !== false));
return entities;
}
public static commandForService(snapshotArg: IDelugeSnapshot, requestArg: IServiceCallRequest): IDelugeCommandRequest | undefined {
if (requestArg.domain === delugeDomain) {
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { action: 'snapshot', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'refresh') {
return { action: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (pauseServices.has(requestArg.service)) {
return { action: 'pause', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds: this.torrentIdsFromRequest(snapshotArg, requestArg) };
}
if (resumeServices.has(requestArg.service)) {
return { action: 'resume', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds: this.torrentIdsFromRequest(snapshotArg, requestArg) };
}
if (addServices.has(requestArg.service)) {
return {
action: 'add_torrent',
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
magnet: this.stringValue(requestArg.data?.magnet ?? requestArg.data?.magnet_uri ?? requestArg.data?.uri),
url: this.stringValue(requestArg.data?.url),
path: this.stringValue(requestArg.data?.path ?? requestArg.data?.filename ?? requestArg.data?.torrentFilePath),
cookie: this.stringValue(requestArg.data?.cookie),
options: record(requestArg.data?.options) || {},
};
}
if (removeServices.has(requestArg.service)) {
const torrentIds = this.torrentIdsFromRequest(snapshotArg, requestArg);
if (!torrentIds.length) {
return undefined;
}
return { action: 'remove_torrent', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds, removeData: this.booleanValue(requestArg.data?.remove_data ?? requestArg.data?.removeData) ?? false };
}
return undefined;
}
if (requestArg.domain === 'switch' && pauseServices.has(requestArg.service)) {
return { action: 'pause', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds: this.torrentIdsFromRequest(snapshotArg, requestArg) };
}
if (requestArg.domain === 'switch' && resumeServices.has(requestArg.service)) {
return { action: 'resume', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds: this.torrentIdsFromRequest(snapshotArg, requestArg) };
}
if (requestArg.domain === 'switch' && requestArg.service === 'toggle') {
return { action: snapshotArg.switches[0]?.isOn ? 'pause' : 'resume', service: requestArg.service, target: requestArg.target, data: requestArg.data, torrentIds: this.torrentIdsFromRequest(snapshotArg, requestArg) };
}
return undefined;
}
public static deviceId(snapshotArg: IDelugeSnapshot): string {
return `deluge.device.${this.uniqueBase(snapshotArg)}`;
}
public static entityTorrentIds(snapshotArg: IDelugeSnapshot, entityIdArg: string): string[] | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg || entityArg.uniqueId === entityIdArg);
const torrentIds = entity?.attributes?.torrentIds;
return Array.isArray(torrentIds) && torrentIds.every((idArg) => typeof idArg === 'string') ? torrentIds : undefined;
}
private static stats(rawDataArg: Partial<IDelugeRawData>): IDelugeSessionStats {
const webUi = rawDataArg.webUi || rawDataArg.updateUi;
const stats = {
...(record(webUi?.stats) || {}),
...(record(rawDataArg.stats) || {}),
...(record(rawDataArg.sessionStatus) || {}),
};
const uploadRate = numberValue(stats.upload_rate) ?? numberValue(stats.payload_upload_rate) ?? 0;
const downloadRate = numberValue(stats.download_rate) ?? numberValue(stats.payload_download_rate) ?? 0;
const protocolDownloadRate = numberValue(stats.dht_download_rate) ?? numberValue(stats.download_protocol_rate) ?? Math.max(0, downloadRate - (numberValue(stats.payload_download_rate) ?? downloadRate));
const protocolUploadRate = numberValue(stats.dht_upload_rate) ?? numberValue(stats.upload_protocol_rate) ?? Math.max(0, uploadRate - (numberValue(stats.payload_upload_rate) ?? uploadRate));
return {
downloadRate,
uploadRate,
dhtDownloadRate: numberValue(stats.dht_download_rate) ?? protocolDownloadRate,
dhtUploadRate: numberValue(stats.dht_upload_rate) ?? protocolUploadRate,
protocolDownloadRate,
protocolUploadRate,
numConnections: numberValue(stats.num_connections) ?? numberValue(stats['peer.num_peers_connected']),
dhtNodes: numberValue(stats.dht_nodes) ?? numberValue(stats['dht.dht_nodes']),
freeSpace: numberValue(stats.free_space),
externalIp: stringValue(stats.external_ip),
maxDownload: numberValue(stats.max_download),
maxUpload: numberValue(stats.max_upload),
maxConnections: numberValue(stats.max_num_connections),
hasIncomingConnections: booleanValue(stats.has_incoming_connections) ?? booleanValue(stats['net.has_incoming_connections']),
raw: stats,
};
}
private static torrents(rawDataArg: Partial<IDelugeRawData>, connectedArg: boolean): IDelugeTorrentSnapshot[] {
const webUi = rawDataArg.webUi || rawDataArg.updateUi;
const rawTorrents = record(rawDataArg.torrentsStatus) || record(webUi?.torrents) || {};
const pausedData = record(rawDataArg.torrentsPaused) || {};
return Object.entries(rawTorrents).map(([idArg, valueArg]) => {
const status = record(valueArg) || {};
const pausedStatus = record(pausedData[idArg]) || {};
const state = stringValue(status.state) || (booleanValue(status.paused ?? pausedStatus.paused) ? 'Paused' : 'Unknown');
const paused = booleanValue(status.paused ?? pausedStatus.paused) ?? state.toLowerCase() === 'paused';
return {
id: idArg,
name: stringValue(status.name) || idArg,
state,
paused,
progress: numberValue(status.progress),
downloadRate: numberValue(status.download_payload_rate) ?? numberValue(status.download_rate),
uploadRate: numberValue(status.upload_payload_rate) ?? numberValue(status.upload_rate),
totalSize: numberValue(status.total_size),
completedSize: numberValue(status.total_done) ?? numberValue(status.completed_size),
ratio: numberValue(status.ratio),
eta: numberValue(status.eta),
seeds: numberValue(status.num_seeds),
peers: numberValue(status.num_peers),
trackerHost: stringValue(status.tracker_host),
savePath: stringValue(status.save_path),
available: connectedArg,
attributes: status,
};
});
}
private static sensors(statsArg: IDelugeSessionStats, torrentsArg: IDelugeTorrentSnapshot[], onlineArg: boolean): IDelugeSensorSnapshot[] {
const counts = torrentsArg.reduce((accumulatorArg, torrentArg) => {
if (torrentArg.state === 'Downloading') {
accumulatorArg.downloading += 1;
}
if (torrentArg.state === 'Seeding') {
accumulatorArg.seeding += 1;
}
return accumulatorArg;
}, { downloading: 0, seeding: 0 });
const values: Record<string, unknown> = {
current_status: currentStatus(statsArg.uploadRate, statsArg.downloadRate),
download_speed: roundKiB(statsArg.downloadRate),
upload_speed: roundKiB(statsArg.uploadRate),
protocol_traffic_upload_speed: roundKiB(statsArg.protocolUploadRate),
protocol_traffic_download_speed: roundKiB(statsArg.protocolDownloadRate),
downloading_count: counts.downloading,
seeding_count: counts.seeding,
};
return delugeSensorDescriptions.map((descriptionArg) => ({
key: descriptionArg.key,
name: descriptionArg.name,
value: values[descriptionArg.key],
unit: descriptionArg.unit,
deviceClass: descriptionArg.deviceClass,
stateClass: descriptionArg.stateClass,
entityCategory: descriptionArg.entityCategory,
available: onlineArg,
}));
}
private static torrentIdsFromRequest(snapshotArg: IDelugeSnapshot, requestArg: IServiceCallRequest): string[] {
const direct = this.stringArrayValue(requestArg.data?.torrent_ids ?? requestArg.data?.torrentIds ?? requestArg.data?.ids ?? requestArg.data?.hashes)
|| this.stringArrayValue(requestArg.data?.torrent_id ?? requestArg.data?.torrentId ?? requestArg.data?.id ?? requestArg.data?.hash);
if (direct?.length) {
return direct;
}
if (requestArg.target.entityId) {
const entityIds = this.entityTorrentIds(snapshotArg, requestArg.target.entityId);
if (entityIds?.length && requestArg.target.entityId !== this.toEntities(snapshotArg).find((entityArg) => entityArg.platform === 'switch')?.id) {
return entityIds;
}
}
return [];
}
private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
const baseId = `${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: delugeDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static deviceName(snapshotArg: IDelugeSnapshot): string {
return snapshotArg.device.name || snapshotArg.device.host || delugeDisplayName;
}
private static uniqueBase(snapshotArg: IDelugeSnapshot): string {
return this.slug(snapshotArg.device.id || snapshotArg.device.host || snapshotArg.device.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 deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (valueArg === undefined) {
return null;
}
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
return valueArg;
}
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return String(valueArg);
}
private static stringArrayValue(valueArg: unknown): string[] | undefined {
if (typeof valueArg === 'string' && valueArg.trim()) {
return [valueArg.trim()];
}
return Array.isArray(valueArg) && valueArg.every((itemArg) => typeof itemArg === 'string' && Boolean(itemArg.trim())) ? valueArg.map((itemArg) => itemArg.trim()) : undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
return stringValue(valueArg);
}
private static booleanValue(valueArg: unknown): boolean | undefined {
return booleanValue(valueArg);
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || delugeDomain;
}
}
export const endpointFromConfig = (configArg: IDelugeConfig): { protocol: TDelugeProtocol; host?: string; webPort: number; rpcPort: number; url?: string } => {
const url = safeUrl(configArg.url || configArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const webPort = url.port ? Number(url.port) : protocol === 'https' ? 443 : delugeDefaultWebPort;
return {
protocol,
host: url.hostname,
webPort,
rpcPort: configArg.rpcPort || configArg.daemonPort || configArg.port || delugeDefaultRpcPort,
url: url.toString().replace(/\/$/, ''),
};
}
const protocol = configArg.protocol || 'http';
const webPort = configArg.webPort || configArg.web_port || ((configArg.rpcPort || configArg.daemonPort) ? undefined : configArg.port) || delugeDefaultWebPort;
return {
protocol,
host: configArg.host,
webPort,
rpcPort: configArg.rpcPort || configArg.daemonPort || ((configArg.webPort || configArg.web_port) ? configArg.port : undefined) || delugeDefaultRpcPort,
url: configArg.host ? `${protocol}://${configArg.host}:${webPort}` : undefined,
};
};
const currentStatus = (uploadRateArg: number, downloadRateArg: number): string => {
if (uploadRateArg > 0 && downloadRateArg > 0) {
return 'seeding_and_downloading';
}
if (uploadRateArg > 0 && downloadRateArg === 0) {
return 'seeding';
}
if (uploadRateArg === 0 && downloadRateArg > 0) {
return 'downloading';
}
return 'idle';
};
const roundKiB = (bytesPerSecondArg: number): number => {
const value = bytesPerSecondArg / 1024;
const precision = value < 0.1 ? 100 : 10;
return Math.round(value * precision) / precision;
};
const record = (valueArg: unknown): Record<string, unknown> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : 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 stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (['true', 'on', 'yes', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['false', 'off', 'no', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
};
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
+246 -2
View File
@@ -1,4 +1,248 @@
export interface IHomeAssistantDelugeConfig {
// TODO: replace with the TypeScript-native config for deluge.
export const delugeDomain = 'deluge';
export const delugeDisplayName = 'Deluge';
export const delugeManufacturer = 'Deluge';
export const delugeDefaultRpcPort = 58846;
export const delugeDefaultWebPort = 8112;
export const delugeDefaultTimeoutMs = 10000;
export type TDelugeProtocol = 'http' | 'https';
export type TDelugeSnapshotSource = 'web' | 'client' | 'manual' | 'snapshot' | 'runtime';
export type TDelugeCommandAction = 'snapshot' | 'refresh' | 'pause' | 'resume' | 'add_torrent' | 'remove_torrent';
export interface IDelugeCommandRequest {
action: TDelugeCommandAction;
service?: string;
target?: {
entityId?: string;
deviceId?: string;
};
data?: Record<string, unknown>;
torrentIds?: string[];
magnet?: string;
url?: string;
path?: string;
cookie?: string;
options?: Record<string, unknown>;
removeData?: boolean;
}
export interface IDelugeCommandExecutor {
execute(commandArg: IDelugeCommandRequest): Promise<unknown> | unknown;
}
export interface IDelugeClientLike {
getSnapshot?(): Promise<IDelugeSnapshot | Partial<IDelugeRawData> | unknown> | IDelugeSnapshot | Partial<IDelugeRawData> | unknown;
getRawData?(): Promise<Partial<IDelugeRawData>> | Partial<IDelugeRawData>;
call?(methodArg: string, ...paramsArg: unknown[]): Promise<unknown> | unknown;
execute?(commandArg: IDelugeCommandRequest): Promise<unknown> | unknown;
destroy?(): Promise<void> | void;
}
export interface IDelugeConfig {
host?: string;
url?: string;
port?: number;
webPort?: number;
web_port?: number;
rpcPort?: number;
daemonPort?: number;
protocol?: TDelugeProtocol;
password?: string;
webPassword?: string;
username?: string;
daemonUsername?: string;
daemonHost?: string;
daemonPassword?: string;
rpcPassword?: string;
timeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
connected?: boolean;
snapshot?: IDelugeSnapshot;
rawData?: Partial<IDelugeRawData>;
client?: IDelugeClientLike;
commandExecutor?: IDelugeCommandExecutor;
}
export interface IHomeAssistantDelugeConfig extends IDelugeConfig {}
export interface IDelugeDeviceSnapshot {
id: string;
name: string;
manufacturer: string;
model?: string;
host?: string;
webPort?: number;
rpcPort?: number;
protocol?: TDelugeProtocol;
url?: string;
online: boolean;
}
export interface IDelugeSessionStats {
downloadRate: number;
uploadRate: number;
dhtDownloadRate: number;
dhtUploadRate: number;
protocolDownloadRate: number;
protocolUploadRate: number;
numConnections?: number;
dhtNodes?: number;
freeSpace?: number;
externalIp?: string;
maxDownload?: number;
maxUpload?: number;
maxConnections?: number;
hasIncomingConnections?: boolean;
raw?: Record<string, unknown>;
}
export interface IDelugeTorrentSnapshot {
id: string;
name: string;
state: string;
paused: boolean;
progress?: number;
downloadRate?: number;
uploadRate?: number;
totalSize?: number;
completedSize?: number;
ratio?: number;
eta?: number;
seeds?: number;
peers?: number;
trackerHost?: string;
savePath?: string;
available: boolean;
attributes?: Record<string, unknown>;
}
export interface IDelugeSensorSnapshot<TValue = unknown> {
key: string;
name: string;
value: TValue;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
available: boolean;
attributes?: Record<string, unknown>;
}
export interface IDelugeSwitchSnapshot {
key: string;
name: string;
isOn: boolean;
available: boolean;
attributes?: Record<string, unknown>;
}
export interface IDelugeCapabilitiesSnapshot {
localRead: boolean;
localControl: boolean;
addTorrent: boolean;
removeTorrent: boolean;
}
export interface IDelugeSnapshot {
device: IDelugeDeviceSnapshot;
stats: IDelugeSessionStats;
torrents: IDelugeTorrentSnapshot[];
sensors: IDelugeSensorSnapshot[];
switches: IDelugeSwitchSnapshot[];
connected: boolean;
online: boolean;
source: TDelugeSnapshotSource;
capabilities: IDelugeCapabilitiesSnapshot;
updatedAt: string;
rawData?: Partial<IDelugeRawData>;
error?: string;
}
export interface IDelugeRawData {
webUi?: IDelugeWebUpdateUiResponse;
updateUi?: IDelugeWebUpdateUiResponse;
sessionStatus?: Record<string, unknown>;
torrentsStatus?: Record<string, Record<string, unknown>>;
torrentsPaused?: Record<string, Record<string, unknown>>;
sessionState?: string[];
stats?: Record<string, unknown>;
connected?: boolean;
fetchedAt?: string;
errors?: Record<string, string>;
}
export interface IDelugeWebUpdateUiResponse {
connected?: boolean;
torrents?: Record<string, Record<string, unknown>>;
filters?: unknown;
stats?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IDelugeSnapshotInput {
config?: IDelugeConfig;
rawData?: Partial<IDelugeRawData>;
online?: boolean;
source?: TDelugeSnapshotSource;
error?: string;
}
export interface IDelugeSensorDescription {
key: string;
name: string;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
}
export interface IDelugeManualEntry {
host?: string;
url?: string;
port?: number;
webPort?: number;
web_port?: number;
rpcPort?: number;
daemonPort?: number;
protocol?: TDelugeProtocol;
password?: string;
webPassword?: string;
username?: string;
daemonUsername?: string;
daemonHost?: string;
daemonPassword?: string;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
snapshot?: IDelugeSnapshot;
rawData?: Partial<IDelugeRawData>;
client?: IDelugeClientLike;
commandExecutor?: IDelugeCommandExecutor;
metadata?: Record<string, unknown>;
}
export interface IDelugeHttpDiscoveryInput {
id?: string;
url?: string;
host?: string;
port?: number;
path?: string;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export const delugeSensorDescriptions: IDelugeSensorDescription[] = [
{ key: 'current_status', name: 'Status', deviceClass: 'enum' },
{ key: 'download_speed', name: 'Download speed', unit: 'KiB/s', deviceClass: 'data_rate', stateClass: 'measurement' },
{ key: 'upload_speed', name: 'Upload speed', unit: 'KiB/s', deviceClass: 'data_rate', stateClass: 'measurement' },
{ key: 'protocol_traffic_upload_speed', name: 'Protocol traffic upload speed', unit: 'KiB/s', deviceClass: 'data_rate', stateClass: 'measurement' },
{ key: 'protocol_traffic_download_speed', name: 'Protocol traffic download speed', unit: 'KiB/s', deviceClass: 'data_rate', stateClass: 'measurement' },
{ key: 'downloading_count', name: 'Downloading count', stateClass: 'total' },
{ key: 'seeding_count', name: 'Seeding count', stateClass: 'total' },
];
+4
View File
@@ -1,2 +1,6 @@
export * from './deluge.classes.client.js';
export * from './deluge.classes.configflow.js';
export * from './deluge.classes.integration.js';
export * from './deluge.discovery.js';
export * from './deluge.mapper.js';
export * from './deluge.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+500
View File
@@ -0,0 +1,500 @@
import type {
IEmbyClientLike,
IEmbyCommandRequest,
IEmbyConfig,
IEmbyGeneralCommandRequest,
IEmbyRefreshResult,
IEmbyServerInfo,
IEmbySession,
IEmbySnapshot,
TEmbyGeneralCommand,
TEmbyPlayCommand,
TEmbyPlaystateCommand,
} from './emby.types.js';
import { embyDefaultHttpPort, embyDefaultHttpsPort, embyDefaultTimeoutMs, embyDisplayName } from './emby.types.js';
const clientName = 'smarthome.exchange';
const clientVersion = '0.1.0';
export class EmbyApiError extends Error {}
export class EmbyApiConnectionError extends EmbyApiError {}
export class EmbyApiAuthorizationError extends EmbyApiError {}
export class EmbyUnsupportedCommandError extends EmbyApiError {}
export class EmbyUnsupportedLivePushError extends Error {
constructor() {
super('Emby live push via pyemby/websocket is not implemented in this native integration; use snapshot polling or an injected client.');
this.name = 'EmbyUnsupportedLivePushError';
}
}
export class EmbyClient {
private currentSnapshot?: IEmbySnapshot;
constructor(private readonly config: IEmbyConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IEmbySnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot({ ...this.cloneSnapshot(this.config.snapshot), source: 'snapshot' });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.hasManualData()) {
this.currentSnapshot = this.normalizeSnapshot({
server: this.serverInfoFromConfig(),
sessions: this.config.sessions || [],
users: this.config.users,
online: this.config.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
});
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasEndpoint()) {
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 Emby local endpoint, injected client, snapshot, or manual session data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IEmbyRefreshResult> {
try {
this.currentSnapshot = undefined;
const liveCapable = Boolean(this.config.client || this.hasEndpoint());
const snapshot = await this.getSnapshot(liveCapable);
const success = liveCapable && snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
return { success, snapshot, error: success ? undefined : snapshot.error || 'Emby refresh requires a live local endpoint or injected client.', data: { source: snapshot.source } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = this.offlineSnapshot(error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
}
}
public async fetchSnapshot(): Promise<IEmbySnapshot> {
if (!this.hasEndpoint()) {
throw new EmbyApiConnectionError('Emby local API snapshot requires config.host or config.url.');
}
const token = this.accessToken();
const server = await this.getSystemInfo();
const sessions = token ? await this.getSessions() : [];
return this.normalizeSnapshot({
server,
sessions,
users: this.config.users,
online: true,
updatedAt: new Date().toISOString(),
source: 'http',
error: token ? undefined : 'Emby session polling requires config.apiKey, apiToken, or accessToken.',
});
}
public async getSystemInfo(): Promise<IEmbyServerInfo> {
if (this.config.server && !this.hasEndpoint()) {
return this.serverInfoFromConfig();
}
const token = this.accessToken();
const path = token ? '/System/Info' : '/System/Info/Public';
return this.fetchJson<IEmbyServerInfo>(path, { token });
}
public async getSessions(): Promise<IEmbySession[]> {
const token = this.accessToken();
if (!token) {
return this.config.sessions || [];
}
const query: Record<string, string | number> = {};
if (this.config.userId) {
query.ControllableByUserId = this.config.userId;
}
if (this.config.deviceId) {
query.DeviceId = this.config.deviceId;
}
if (this.config.sessionId) {
query.Id = this.config.sessionId;
}
return this.fetchJson<IEmbySession[]>('/Sessions', { query, token });
}
public async execute(commandArg: IEmbyCommandRequest): Promise<unknown> {
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client?.execute) {
return this.config.client.execute(commandArg);
}
if (commandArg.action === 'refresh') {
return (await this.refresh()).snapshot;
}
if (!this.hasEndpoint()) {
throw new EmbyApiConnectionError('Emby commands require config.host/config.url with an API key, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
}
if (!this.accessToken()) {
throw new EmbyApiAuthorizationError('Emby commands require config.apiKey, apiToken, or accessToken.');
}
if (commandArg.action === 'playstate') {
if (!commandArg.sessionId || !commandArg.command) {
throw new EmbyUnsupportedCommandError('Emby playstate commands require sessionId and command.');
}
await this.sendPlaystateCommand(commandArg.sessionId, commandArg.command as TEmbyPlaystateCommand, commandArg.seekPositionTicks);
this.currentSnapshot = undefined;
return { command: commandArg };
}
if (commandArg.action === 'general_command') {
if (!commandArg.sessionId || !commandArg.command) {
throw new EmbyUnsupportedCommandError('Emby general commands require sessionId and command.');
}
await this.sendGeneralCommandToApi(commandArg.sessionId, commandArg.command, commandArg.arguments);
this.currentSnapshot = undefined;
return { command: commandArg };
}
if (commandArg.action === 'play_media') {
if (!commandArg.sessionId || !commandArg.itemIds?.length) {
throw new EmbyUnsupportedCommandError('Emby play_media requires sessionId and itemIds.');
}
await this.playMediaToApi(commandArg.sessionId, commandArg.itemIds, commandArg.playCommand, commandArg.startPositionTicks);
this.currentSnapshot = undefined;
return { command: commandArg };
}
throw new EmbyUnsupportedCommandError(`Unsupported Emby command action: ${commandArg.action}`);
}
public async pause(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'Pause' });
}
public async play(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'Unpause' });
}
public async playPause(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'PlayPause' });
}
public async stop(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'Stop' });
}
public async nextTrack(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'NextTrack' });
}
public async previousTrack(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'playstate', sessionId: sessionIdArg, command: 'PreviousTrack' });
}
public async seek(sessionIdArg: string, positionSecondsArg: number): Promise<void> {
await this.execute({
action: 'playstate',
sessionId: sessionIdArg,
command: 'Seek',
seekPositionTicks: Math.max(0, Math.round(positionSecondsArg * 10000000)),
});
}
public async setVolumeLevel(sessionIdArg: string, volumeLevelArg: number): Promise<void> {
const volume = Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100)));
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: 'SetVolume', arguments: { Volume: String(volume) } });
}
public async volumeUp(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: 'VolumeUp' });
}
public async volumeDown(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: 'VolumeDown' });
}
public async mute(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: 'Mute' });
}
public async unmute(sessionIdArg: string): Promise<void> {
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: 'Unmute' });
}
public async playMedia(sessionIdArg: string, itemIdsArg: string[], commandArg: TEmbyPlayCommand = 'PlayNow', startPositionTicksArg?: number): Promise<void> {
await this.execute({ action: 'play_media', sessionId: sessionIdArg, itemIds: itemIdsArg, playCommand: commandArg, startPositionTicks: startPositionTicksArg });
}
public async sendGeneralCommand(sessionIdArg: string, commandArg: TEmbyGeneralCommand, argumentsArg?: Record<string, string>): Promise<void> {
await this.execute({ action: 'general_command', sessionId: sessionIdArg, command: commandArg, arguments: argumentsArg });
}
public async subscribeToLiveEvents(): Promise<never> {
throw new EmbyUnsupportedLivePushError();
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async playMediaToApi(sessionIdArg: string, itemIdsArg: string[], commandArg: TEmbyPlayCommand = 'PlayNow', startPositionTicksArg?: number): Promise<void> {
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Playing`, {
method: 'POST',
token: await this.ensureAccessToken(),
query: {
ItemIds: itemIdsArg.join(','),
PlayCommand: commandArg,
StartPositionTicks: startPositionTicksArg,
},
body: {
ControllingUserId: this.config.userId,
},
});
}
private async sendGeneralCommandToApi(sessionIdArg: string, commandArg: TEmbyGeneralCommand, argumentsArg?: Record<string, string>): Promise<void> {
const body: IEmbyGeneralCommandRequest = {
Name: commandArg,
ControllingUserId: this.config.userId,
Arguments: argumentsArg,
};
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Command`, {
method: 'POST',
token: await this.ensureAccessToken(),
body,
});
}
private async sendPlaystateCommand(sessionIdArg: string, commandArg: TEmbyPlaystateCommand, seekPositionTicksArg?: number): Promise<void> {
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Playing/${encodeURIComponent(commandArg)}`, {
method: 'POST',
token: await this.ensureAccessToken(),
body: {
Command: commandArg,
SeekPositionTicks: seekPositionTicksArg,
ControllingUserId: this.config.userId,
},
});
}
private async snapshotFromClient(clientArg: IEmbyClientLike): Promise<IEmbySnapshot> {
if (clientArg.getSnapshot) {
const snapshot = await clientArg.getSnapshot();
return this.normalizeSnapshot({ ...this.cloneSnapshot(snapshot), source: 'client' });
}
if (clientArg.getSystemInfo || clientArg.getSessions) {
const [server, sessions] = await Promise.all([
clientArg.getSystemInfo ? clientArg.getSystemInfo() : Promise.resolve(this.serverInfoFromConfig()),
clientArg.getSessions ? clientArg.getSessions() : Promise.resolve(this.config.sessions || []),
]);
return this.normalizeSnapshot({
server,
sessions,
users: this.config.users,
online: true,
updatedAt: new Date().toISOString(),
source: 'client',
});
}
throw new EmbyApiConnectionError('Emby injected client must expose getSnapshot(), getSystemInfo(), getSessions(), or execute().');
}
private async ensureAccessToken(): Promise<string> {
const token = this.accessToken();
if (token) {
return token;
}
throw new EmbyApiAuthorizationError('Emby REST sessions and control require config.apiKey, apiToken, or accessToken.');
}
private accessToken(): string | undefined {
return this.config.apiKey || this.config.api_key || this.config.apiToken || this.config.accessToken;
}
private async fetchJson<T>(pathArg: string, optionsArg: IEmbyRequestOptions = {}): Promise<T> {
const response = await this.fetchResponse(pathArg, optionsArg);
const text = await response.text();
if (!response.ok) {
throw new EmbyApiConnectionError(`Emby request ${pathArg} failed with HTTP ${response.status}: ${text}`);
}
return text ? JSON.parse(text) as T : undefined as T;
}
private async fetchNoContent(pathArg: string, optionsArg: IEmbyRequestOptions): Promise<void> {
const response = await this.fetchResponse(pathArg, optionsArg);
const text = await response.text();
if (!response.ok) {
throw new EmbyApiConnectionError(`Emby request ${pathArg} failed with HTTP ${response.status}: ${text}`);
}
}
private async fetchResponse(pathArg: string, optionsArg: IEmbyRequestOptions): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || embyDefaultTimeoutMs);
try {
return await globalThis.fetch(this.requestUrl(pathArg, optionsArg.query), {
method: optionsArg.method || 'GET',
headers: this.headers(optionsArg.token || this.accessToken(), optionsArg.body !== undefined),
body: optionsArg.body !== undefined ? JSON.stringify(optionsArg.body) : undefined,
signal: abortController.signal,
});
} finally {
clearTimeout(timeout);
}
}
private requestUrl(pathArg: string, queryArg?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(pathArg, `${this.baseUrl()}/`);
for (const [key, value] of Object.entries(queryArg || {})) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
const token = this.accessToken();
if (token && !url.searchParams.has('api_key')) {
url.searchParams.set('api_key', token);
}
return url.toString();
}
private headers(tokenArg: string | undefined, hasBodyArg: boolean): Record<string, string> {
const headers: Record<string, string> = {
accept: 'application/json',
authorization: this.authorizationHeader(tokenArg),
};
if (hasBodyArg) {
headers['content-type'] = 'application/json';
}
if (tokenArg) {
headers['x-emby-token'] = tokenArg;
}
return headers;
}
private authorizationHeader(tokenArg: string | undefined): string {
const parts: Record<string, string> = {
Client: clientName,
Device: this.config.deviceName || 'smarthome.exchange',
DeviceId: this.clientDeviceId(),
Version: clientVersion,
};
if (tokenArg) {
parts.Token = tokenArg;
}
return `MediaBrowser ${Object.entries(parts).map(([key, value]) => `${key}=\"${value.replace(/\\/g, '\\\\').replace(/\"/g, '\\\"')}\"`).join(', ')}`;
}
private baseUrl(): string {
if (this.config.url) {
return this.config.url.replace(/\/+$/, '');
}
if (!this.config.host) {
throw new EmbyApiConnectionError('Emby host or url is required for REST calls.');
}
const ssl = this.config.ssl === true || this.config.protocol === 'https';
const protocol = ssl ? 'https' : 'http';
const port = this.config.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort);
return `${protocol}://${this.config.host}:${port}`;
}
private hasEndpoint(): boolean {
return Boolean(this.config.url || this.config.host);
}
private hasManualData(): boolean {
return Boolean(this.config.server || this.config.sessions?.length || this.config.users?.length);
}
private clientDeviceId(): string {
if (this.config.clientDeviceId) {
return this.config.clientDeviceId;
}
if (this.config.uniqueId) {
return this.config.uniqueId;
}
return 'smarthome-exchange-emby';
}
private normalizeSnapshot(snapshotArg: IEmbySnapshot): IEmbySnapshot {
const server = {
...this.serverInfoFromConfig(),
...snapshotArg.server,
};
const ownDeviceId = this.config.clientDeviceId;
const sessions = (snapshotArg.sessions || []).filter((sessionArg) => {
if (ownDeviceId && sessionArg.DeviceId === ownDeviceId) {
return false;
}
return sessionArg.Client !== clientName;
});
return {
...snapshotArg,
server,
sessions,
online: snapshotArg.online,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private serverInfoFromConfig(): IEmbyServerInfo {
const ssl = this.config.ssl === true || this.config.protocol === 'https';
const port = this.config.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort);
return {
...this.config.server,
Id: this.config.server?.Id || this.config.server?.ServerId || this.config.uniqueId || this.config.host || this.config.url || 'emby',
ServerName: this.config.server?.ServerName || this.config.server?.Name || this.config.name || this.config.host || embyDisplayName,
ProductName: this.config.server?.ProductName || 'Emby Server',
LocalAddress: this.config.server?.LocalAddress || this.config.url || (this.config.host ? `${ssl ? 'https' : 'http'}://${this.config.host}:${port}` : undefined),
HttpServerPortNumber: this.config.server?.HttpServerPortNumber || (!ssl ? port : undefined),
HttpsPortNumber: this.config.server?.HttpsPortNumber || (ssl ? port : undefined),
SupportsHttps: this.config.server?.SupportsHttps || ssl,
};
}
private offlineSnapshot(errorArg: string): IEmbySnapshot {
return this.normalizeSnapshot({
server: this.serverInfoFromConfig(),
sessions: this.config.sessions || [],
users: this.config.users,
online: false,
updatedAt: new Date().toISOString(),
source: 'runtime',
error: errorArg,
});
}
private cloneSnapshot(snapshotArg: IEmbySnapshot): IEmbySnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IEmbySnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
interface IEmbyRequestOptions {
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, string | number | boolean | undefined>;
token?: string;
body?: unknown;
}
@@ -0,0 +1,105 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IEmbyConfig, IEmbySnapshot } from './emby.types.js';
import { embyDefaultHttpPort, embyDefaultHttpsPort, embyDefaultTimeoutMs } from './emby.types.js';
export class EmbyConfigFlow implements IConfigFlow<IEmbyConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IEmbyConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Emby',
description: 'Configure a local Emby server endpoint. Session polling and media control require an Emby API key, injected client, or command executor.',
fields: [
{ name: 'url', label: 'Server URL', type: 'text', required: true },
{ name: 'apiKey', label: 'API key', type: 'password' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'userId', label: 'Controlling user ID', type: 'text' },
],
submit: async (valuesArg) => ({
kind: 'done',
title: 'Emby configured',
config: this.configFromValues(candidateArg, valuesArg),
}),
};
}
private configFromValues(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): IEmbyConfig {
const metadata = candidateArg.metadata || {};
const snapshot = this.snapshotMetadata(metadata);
const url = this.stringValue(valuesArg.url) || this.urlFromCandidate(candidateArg);
const parsedUrl = this.parseUrl(url);
const ssl = parsedUrl?.ssl ?? (candidateArg.metadata?.ssl === true);
return {
url,
host: parsedUrl?.host || candidateArg.host,
port: parsedUrl?.port || candidateArg.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort),
ssl,
apiKey: this.stringValue(valuesArg.apiKey) || this.stringMetadata(metadata, 'apiKey'),
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.server.ServerName || snapshot?.server.Name,
uniqueId: candidateArg.id || snapshot?.server.Id || snapshot?.server.ServerId,
userId: this.stringValue(valuesArg.userId) || this.stringMetadata(metadata, 'userId'),
clientDeviceId: `smarthome-exchange-${candidateArg.id || candidateArg.host || 'emby'}`,
timeoutMs: embyDefaultTimeoutMs,
snapshot,
server: snapshot?.server || this.serverMetadata(metadata),
sessions: snapshot?.sessions || this.sessionsMetadata(metadata),
client: metadata.client as IEmbyConfig['client'],
commandExecutor: metadata.commandExecutor as IEmbyConfig['commandExecutor'],
};
}
private urlFromCandidate(candidateArg: IDiscoveryCandidate): string {
const explicitUrl = candidateArg.metadata?.url;
if (typeof explicitUrl === 'string' && explicitUrl) {
return explicitUrl;
}
const ssl = candidateArg.metadata?.ssl === true;
const protocol = ssl ? 'https' : 'http';
const port = candidateArg.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort);
return candidateArg.host ? `${protocol}://${candidateArg.host}:${port}` : '';
}
private parseUrl(valueArg: string | undefined): { host: string; port?: number; ssl: boolean } | 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:',
};
} catch {
return undefined;
}
}
private snapshotMetadata(metadataArg: Record<string, unknown>): IEmbySnapshot | undefined {
const snapshot = metadataArg.snapshot;
if (snapshot && typeof snapshot === 'object' && 'server' in snapshot && 'sessions' in snapshot) {
return snapshot as IEmbySnapshot;
}
return undefined;
}
private serverMetadata(metadataArg: Record<string, unknown>): IEmbyConfig['server'] | undefined {
const server = metadataArg.server;
return server && typeof server === 'object' ? server as IEmbyConfig['server'] : undefined;
}
private sessionsMetadata(metadataArg: Record<string, unknown>): IEmbyConfig['sessions'] | undefined {
const sessions = metadataArg.sessions;
return Array.isArray(sessions) ? sessions as IEmbyConfig['sessions'] : undefined;
}
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
}
+293 -23
View File
@@ -1,26 +1,296 @@
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 { EmbyClient, EmbyUnsupportedLivePushError } from './emby.classes.client.js';
import { EmbyConfigFlow } from './emby.classes.configflow.js';
import { createEmbyDiscoveryDescriptor } from './emby.discovery.js';
import { EmbyMapper } from './emby.mapper.js';
import type { IEmbyConfig, IEmbySession, IEmbySnapshot, TEmbyPlayCommand } from './emby.types.js';
import { embyDefaultHttpPort, embyDefaultHttpsPort, embyDisplayName, embyDomain } from './emby.types.js';
export class HomeAssistantEmbyIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emby",
displayName: "Emby",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emby",
"upstreamDomain": "emby",
"iotClass": "local_push",
"qualityScale": "legacy",
"requirements": [
"pyEmby==1.10"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@mezz64"
]
},
});
export class EmbyIntegration extends BaseIntegration<IEmbyConfig> {
public readonly domain = embyDomain;
public readonly displayName = embyDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createEmbyDiscoveryDescriptor();
public readonly configFlow = new EmbyConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emby',
upstreamDomain: embyDomain,
integrationType: 'service',
iotClass: 'local_push',
qualityScale: 'legacy',
requirements: ['pyEmby==1.10'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@mezz64'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/emby',
discovery: {
manual: true,
mdns: 'Best-effort Emby HTTP/mDNS advertisements when present',
ssdp: 'Emby DLNA/UPnP advertisements containing Emby metadata',
},
runtime: {
type: 'control-runtime',
polling: `local Emby REST snapshots via http:${embyDefaultHttpPort}/https:${embyDefaultHttpsPort}, injected clients, or static snapshots`,
services: ['snapshot', 'refresh', 'media_player controls', 'remote.send_command', 'emby.play_media_shuffle'],
controls: true,
livePush: false,
},
localApi: {
implemented: [
'GET /System/Info/Public for server metadata without a token',
'GET /System/Info and GET /Sessions with an Emby API key/token',
'POST /Sessions/{Id}/Playing/{Command} for play, pause, stop, next, previous, and seek',
'POST /Sessions/{Id}/Command for general remote commands and volume',
'POST /Sessions/{Id}/Playing for play_media commands',
'static snapshot/manual sessions and injected client/executor operation',
],
explicitUnsupported: [
'pyemby websocket/live push event stream in this native runtime',
'cloud/remote Emby Connect discovery or authentication',
'pretending media/session commands succeeded without local API token, injected client, or commandExecutor',
],
},
};
public async setup(configArg: IEmbyConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new EmbyRuntime(new EmbyClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantEmbyIntegration extends EmbyIntegration {}
class EmbyRuntime implements IIntegrationRuntime {
public domain = embyDomain;
constructor(private readonly client: EmbyClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return EmbyMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return EmbyMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
void handlerArg;
throw new EmbyUnsupportedLivePushError();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'remote') {
return await this.callRemoteService(requestArg);
}
if (requestArg.domain === embyDomain) {
return await this.callEmbyService(requestArg);
}
return { success: false, error: `Unsupported Emby 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> {
const session = await this.targetSession(requestArg);
if (!session) {
return { success: false, error: 'Emby media_player service requires a target session when multiple sessions are active.' };
}
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
await this.client.play(session.Id);
return { success: true };
}
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
await this.client.pause(session.Id);
return { success: true };
}
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
await this.client.playPause(session.Id);
return { success: true };
}
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
await this.client.stop(session.Id);
return { success: true };
}
if (requestArg.service === 'next' || requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
await this.client.nextTrack(session.Id);
return { success: true };
}
if (requestArg.service === 'previous' || requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
await this.client.previousTrack(session.Id);
return { success: true };
}
if (requestArg.service === 'seek' || requestArg.service === 'media_seek') {
const position = requestArg.data?.seek_position ?? requestArg.data?.position;
if (typeof position !== 'number') {
return { success: false, error: 'Emby seek requires data.seek_position.' };
}
await this.client.seek(session.Id, position);
return { success: true };
}
if (requestArg.service === 'volume' || requestArg.service === 'volume_set') {
const level = requestArg.data?.volume_level ?? requestArg.data?.level ?? requestArg.data?.volume;
if (typeof level !== 'number') {
return { success: false, error: 'Emby volume_set requires data.volume_level.' };
}
await this.client.setVolumeLevel(session.Id, level);
return { success: true };
}
if (requestArg.service === 'volume_up') {
await this.client.volumeUp(session.Id);
return { success: true };
}
if (requestArg.service === 'volume_down') {
await this.client.volumeDown(session.Id);
return { success: true };
}
if (requestArg.service === 'volume_mute') {
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
if (typeof muted !== 'boolean') {
return { success: false, error: 'Emby volume_mute requires data.is_volume_muted.' };
}
if (muted) {
await this.client.mute(session.Id);
} else {
await this.client.unmute(session.Id);
}
return { success: true };
}
if (requestArg.service === 'play_media') {
return await this.playMedia(session, requestArg, this.playCommandFromRequest(requestArg));
}
return { success: false, error: `Unsupported Emby media_player service: ${requestArg.service}` };
}
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service !== 'send_command') {
return { success: false, error: `Unsupported Emby remote service: ${requestArg.service}` };
}
const session = await this.targetSession(requestArg);
if (!session) {
return { success: false, error: 'Emby remote.send_command requires a target session when multiple sessions are active.' };
}
if (session.SupportsRemoteControl === false) {
return { success: false, error: 'Target Emby session does not support remote control.' };
}
const commands = this.commandsFromRequest(requestArg);
if (!commands.length) {
return { success: false, error: 'Emby remote.send_command requires data.command.' };
}
const repeats = this.numberValue(requestArg.data?.num_repeats) || 1;
const delayMs = (this.numberValue(requestArg.data?.delay_secs) || 0) * 1000;
for (let index = 0; index < repeats; index++) {
for (const command of commands) {
await this.client.sendGeneralCommand(session.Id, command);
if (delayMs > 0) {
await new Promise((resolveArg) => setTimeout(resolveArg, delayMs));
}
}
}
return { success: true };
}
private async callEmbyService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot || result.data };
}
const session = await this.targetSession(requestArg);
if (!session) {
return { success: false, error: 'Emby service requires a target session when multiple sessions are active.' };
}
if (requestArg.service === 'play_media_shuffle') {
return await this.playMedia(session, requestArg, 'PlayShuffle');
}
if (requestArg.service === 'send_command') {
return await this.callRemoteService({ ...requestArg, domain: 'remote', service: 'send_command' });
}
return { success: false, error: `Unsupported Emby service: ${requestArg.service}` };
}
private async playMedia(sessionArg: IEmbySession, requestArg: IServiceCallRequest, commandArg: TEmbyPlayCommand): Promise<IServiceCallResult> {
const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.mediaId ?? requestArg.data?.itemId;
if (typeof mediaId !== 'string' || !mediaId) {
return { success: false, error: 'Emby play_media requires data.media_content_id.' };
}
await this.client.playMedia(sessionArg.Id, [mediaId], commandArg);
return { success: true };
}
private async targetSession(requestArg: IServiceCallRequest): Promise<IEmbySession | undefined> {
const snapshot = await this.client.getSnapshot();
const sessions = EmbyMapper.activeSessions(snapshot);
const requestedSessionId = typeof requestArg.data?.sessionId === 'string' ? requestArg.data.sessionId : undefined;
if (requestedSessionId) {
return sessions.find((sessionArg) => sessionArg.Id === requestedSessionId);
}
const entityId = requestArg.target.entityId;
const deviceId = requestArg.target.deviceId;
if (entityId || deviceId) {
return this.findSessionByTarget(snapshot, sessions, entityId, deviceId);
}
return sessions.length === 1 ? sessions[0] : undefined;
}
private findSessionByTarget(snapshotArg: IEmbySnapshot, sessionsArg: IEmbySession[], entityIdArg: string | undefined, deviceIdArg: string | undefined): IEmbySession | undefined {
const entities = EmbyMapper.toEntities(snapshotArg);
for (const session of sessionsArg) {
const sessionDeviceId = EmbyMapper.sessionDeviceId(session);
const entity = entities.find((entityArg) => entityArg.deviceId === sessionDeviceId && entityArg.platform === 'media_player');
if (deviceIdArg && deviceIdArg === sessionDeviceId) {
return session;
}
if (entityIdArg && entityIdArg === entity?.id) {
return session;
}
}
return undefined;
}
private commandsFromRequest(requestArg: IServiceCallRequest): string[] {
const command = requestArg.data?.command ?? requestArg.data?.commands;
if (typeof command === 'string') {
return [command];
}
if (Array.isArray(command)) {
return command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg));
}
return [];
}
private playCommandFromRequest(requestArg: IServiceCallRequest): TEmbyPlayCommand {
const playCommand = requestArg.data?.play_command ?? requestArg.data?.playCommand;
if (playCommand === 'PlayNow' || playCommand === 'PlayNext' || playCommand === 'PlayLast' || playCommand === 'PlayInstantMix' || playCommand === 'PlayShuffle') {
return playCommand;
}
const enqueue = requestArg.data?.enqueue ?? requestArg.data?.media_enqueue;
if (enqueue === 'next') {
return 'PlayNext';
}
if (enqueue === 'add') {
return 'PlayLast';
}
return 'PlayNow';
}
private numberValue(valueArg: unknown): number | undefined {
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
}
}
+210
View File
@@ -0,0 +1,210 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IEmbyManualEntry, IEmbyMdnsRecord, IEmbySsdpRecord } from './emby.types.js';
import { embyDefaultHttpPort, embyDefaultHttpsPort, embyDisplayName, embyDomain } from './emby.types.js';
export class EmbyManualMatcher implements IDiscoveryMatcher<IEmbyManualEntry> {
public id = 'emby-manual-match';
public source = 'manual' as const;
public description = 'Recognize manually supplied local Emby server endpoints, snapshots, clients, and executors.';
public async matches(inputArg: IEmbyManualEntry): Promise<IDiscoveryMatch> {
const parsedUrl = parseUrl(inputArg.url);
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot;
const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''} ${metadata.name || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || parsedUrl || inputArg.apiKey || inputArg.api_key || inputArg.apiToken || inputArg.accessToken || snapshot || inputArg.sessions || inputArg.client || inputArg.commandExecutor || metadata.emby || haystack.includes('emby'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Emby setup hints.' };
}
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? (inputArg.protocol === 'https');
const host = inputArg.host || parsedUrl?.host;
const port = inputArg.port || parsedUrl?.port || snapshot?.server.HttpServerPortNumber || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort);
const id = inputArg.id || inputArg.uniqueId || snapshot?.server.Id || snapshot?.server.ServerId || host;
return {
matched: true,
confidence: host || snapshot ? 'high' : 'medium',
reason: 'Manual entry can start Emby setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: embyDomain,
id,
host,
port,
name: inputArg.name || snapshot?.server.ServerName || snapshot?.server.Name,
manufacturer: inputArg.manufacturer || embyDisplayName,
model: inputArg.model || 'Emby Server',
metadata: {
...metadata,
url: inputArg.url,
ssl,
snapshot,
sessions: inputArg.sessions,
server: inputArg.server,
apiKey: inputArg.apiKey || inputArg.api_key || inputArg.apiToken || inputArg.accessToken,
client: inputArg.client,
commandExecutor: inputArg.commandExecutor,
},
},
};
}
}
export class EmbyMdnsMatcher implements IDiscoveryMatcher<IEmbyMdnsRecord> {
public id = 'emby-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Emby-like mDNS HTTP advertisements when present.';
public async matches(recordArg: IEmbyMdnsRecord): Promise<IDiscoveryMatch> {
const properties = { ...recordArg.txt, ...recordArg.properties };
const haystack = `${recordArg.type || ''} ${recordArg.name || ''} ${recordArg.hostname || ''} ${Object.values(properties).join(' ')}`.toLowerCase();
const matched = haystack.includes('emby') || properties.emby === 'true';
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not advertise Emby metadata.' };
}
const id = valueForKey(properties, 'id') || valueForKey(properties, 'serverid') || valueForKey(properties, 'serverId');
return {
matched: true,
confidence: id ? 'certain' : 'medium',
reason: 'mDNS record contains Emby metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: embyDomain,
id,
host: recordArg.host || recordArg.addresses?.[0],
port: recordArg.port || embyDefaultHttpPort,
name: cleanName(recordArg.name),
manufacturer: embyDisplayName,
model: 'Emby Server',
metadata: {
mdnsType: recordArg.type,
txt: properties,
},
},
};
}
}
export class EmbySsdpMatcher implements IDiscoveryMatcher<IEmbySsdpRecord> {
public id = 'emby-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize Emby DLNA/UPnP SSDP advertisements.';
public async matches(recordArg: IEmbySsdpRecord): Promise<IDiscoveryMatch> {
const headers = recordArg.headers || {};
const st = recordArg.st || headerValue(headers, 'st');
const usn = recordArg.usn || headerValue(headers, 'usn');
const location = recordArg.location || headerValue(headers, 'location');
const server = recordArg.server || headerValue(headers, 'server');
const friendlyName = recordArg.friendlyName || headerValue(headers, 'friendlyName') || headerValue(headers, 'friendlyname');
const model = headerValue(headers, 'modelName') || headerValue(headers, 'modelname') || headerValue(headers, 'model');
const manufacturer = headerValue(headers, 'manufacturer');
const haystack = `${st || ''} ${usn || ''} ${location || ''} ${server || ''} ${friendlyName || ''} ${model || ''} ${manufacturer || ''}`.toLowerCase();
const matched = haystack.includes('emby');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record does not advertise Emby metadata.' };
}
const parsedUrl = parseUrl(location);
const id = usn?.replace(/^uuid:/i, '').split('::')[0];
const ssl = parsedUrl?.ssl ?? false;
return {
matched: true,
confidence: id ? 'certain' : 'high',
reason: 'SSDP record matches Emby server metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: embyDomain,
id,
host: parsedUrl?.host,
port: parsedUrl?.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort),
name: friendlyName,
manufacturer: manufacturer || embyDisplayName,
model: model || 'Emby Server',
metadata: {
url: parsedUrl ? `${ssl ? 'https' : 'http'}://${parsedUrl.host}:${parsedUrl.port || (ssl ? embyDefaultHttpsPort : embyDefaultHttpPort)}` : location,
ssdpSt: st,
ssdpUsn: usn,
server,
ssl,
},
},
};
}
}
export class EmbyCandidateValidator implements IDiscoveryValidator {
public id = 'emby-candidate-validator';
public description = 'Validate Emby server candidate metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const haystack = `${candidateArg.integrationDomain || ''} ${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${metadata.url || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === embyDomain || haystack.includes('emby') || Boolean(metadata.emby || metadata.snapshot || metadata.server || metadata.sessions);
return {
matched,
confidence: matched && candidateArg.id ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Emby metadata.' : 'Candidate is not Emby.',
candidate: matched ? candidateArg : undefined,
normalizedDeviceId: candidateArg.id || candidateArg.host,
};
}
}
export const createEmbyDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: embyDomain, displayName: embyDisplayName })
.addMatcher(new EmbyManualMatcher())
.addMatcher(new EmbyMdnsMatcher())
.addMatcher(new EmbySsdpMatcher())
.addValidator(new EmbyCandidateValidator());
};
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean } | 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:',
};
} catch {
return undefined;
}
};
const headerValue = (headersArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(headersArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const valueForKey = (recordArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/\._http\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
+237
View File
@@ -0,0 +1,237 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IEmbyMediaItem, IEmbySession, IEmbySnapshot } from './emby.types.js';
import { embyDomain } from './emby.types.js';
const contentTypeMap: Record<string, string> = {
Audio: 'music',
Episode: 'tvshow',
Movie: 'movie',
Trailer: 'trailer',
Music: 'music',
Series: 'tvshow',
Season: 'season',
Video: 'video',
TvChannel: 'channel',
MusicAlbum: 'album',
MusicArtist: 'artist',
CollectionFolder: 'collection',
};
export class EmbyMapper {
public static toDevices(snapshotArg: IEmbySnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [
{
id: this.serverDeviceId(snapshotArg),
integrationDomain: embyDomain,
name: this.serverName(snapshotArg),
protocol: 'http',
manufacturer: 'Emby',
model: snapshotArg.server.ProductName || 'Emby Server',
online: snapshotArg.online,
features: [
{ id: 'active_clients', capability: 'sensor', name: 'Active clients', readable: true, writable: false },
{ id: 'playing_clients', capability: 'sensor', name: 'Playing clients', readable: true, writable: false },
],
state: [
{ featureId: 'active_clients', value: this.activeSessions(snapshotArg).length, updatedAt },
{ featureId: 'playing_clients', value: this.nowPlayingCount(snapshotArg), updatedAt },
],
metadata: {
serverId: this.serverId(snapshotArg),
version: snapshotArg.server.Version,
localAddress: snapshotArg.server.LocalAddress,
wanAddress: snapshotArg.server.WanAddress,
source: snapshotArg.source,
error: snapshotArg.error,
},
},
];
for (const session of this.activeSessions(snapshotArg)) {
const playState = session.PlayState;
const nowPlaying = session.NowPlayingItem;
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
];
if (this.supportsRemote(session)) {
features.push({ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true });
}
devices.push({
id: this.sessionDeviceId(session),
integrationDomain: embyDomain,
name: this.sessionName(session),
protocol: 'http',
manufacturer: 'Emby',
model: session.Client,
online: snapshotArg.online && session.IsActive !== false,
features,
state: [
{ featureId: 'playback', value: this.playbackState(session, snapshotArg.online), updatedAt: this.sessionUpdatedAt(session, updatedAt) },
{ featureId: 'volume', value: typeof playState?.VolumeLevel === 'number' ? playState.VolumeLevel : null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
{ featureId: 'muted', value: typeof playState?.IsMuted === 'boolean' ? playState.IsMuted : null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
{ featureId: 'current_title', value: nowPlaying?.Name || null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
],
metadata: {
serverId: this.serverId(snapshotArg),
sessionId: session.Id,
deviceId: session.DeviceId,
userId: session.UserId,
userName: session.UserName,
client: session.Client,
applicationVersion: session.ApplicationVersion,
supportsRemoteControl: this.supportsRemote(session),
supportedCommands: this.supportedCommands(session),
},
});
}
return devices;
}
public static toEntities(snapshotArg: IEmbySnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [
{
id: `sensor.${this.slug(this.serverName(snapshotArg))}_active_clients`,
uniqueId: `emby_${this.slug(this.serverId(snapshotArg))}_active_clients`,
integrationDomain: embyDomain,
deviceId: this.serverDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.serverName(snapshotArg)} Active clients`,
state: this.activeSessions(snapshotArg).length,
attributes: {
playingClients: this.nowPlayingCount(snapshotArg),
source: snapshotArg.source,
error: snapshotArg.error,
},
available: snapshotArg.online,
},
];
for (const session of this.activeSessions(snapshotArg)) {
const item = session.NowPlayingItem;
entities.push({
id: `media_player.${this.slug(this.sessionName(session))}`,
uniqueId: `emby_${this.slug(this.serverId(snapshotArg))}_${this.slug(session.Id || session.DeviceId || this.sessionName(session))}`,
integrationDomain: embyDomain,
deviceId: this.sessionDeviceId(session),
platform: 'media_player',
name: this.sessionName(session),
state: this.playbackState(session, snapshotArg.online),
attributes: {
sessionId: session.Id,
deviceId: session.DeviceId,
userName: session.UserName,
clientName: session.Client,
applicationVersion: session.ApplicationVersion,
supportsRemoteControl: this.supportsRemote(session),
supportedCommands: this.supportedCommands(session),
volumeLevel: typeof session.PlayState?.VolumeLevel === 'number' ? session.PlayState.VolumeLevel / 100 : undefined,
isVolumeMuted: session.PlayState?.IsMuted,
mediaContentId: item?.Id,
mediaContentType: this.mediaContentType(item),
mediaDuration: this.ticksToSeconds(item?.RunTimeTicks),
mediaPosition: this.ticksToSeconds(session.PlayState?.PositionTicks),
mediaPositionUpdatedAt: session.LastPlaybackCheckIn || session.LastActivityDate,
mediaImageUrl: this.mediaImageUrl(snapshotArg, item),
mediaTitle: item?.Name,
mediaSeriesTitle: item?.SeriesName,
mediaSeason: item?.ParentIndexNumber,
mediaEpisode: item?.IndexNumber,
mediaAlbumName: item?.Album,
mediaArtist: item?.Artists?.[0],
mediaAlbumArtist: item?.AlbumArtist,
mediaTrack: item?.IndexNumber,
},
available: snapshotArg.online && session.IsActive !== false,
});
}
return entities;
}
public static activeSessions(snapshotArg: IEmbySnapshot): IEmbySession[] {
return (snapshotArg.sessions || []).filter((sessionArg) => {
return Boolean(sessionArg.Id || sessionArg.DeviceId) && (sessionArg.IsActive !== false || Boolean(sessionArg.NowPlayingItem));
});
}
public static sessionDeviceId(sessionArg: IEmbySession): string {
return `emby.session.${this.slug(sessionArg.DeviceId || sessionArg.Id || this.sessionName(sessionArg))}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'emby';
}
private static serverDeviceId(snapshotArg: IEmbySnapshot): string {
return `emby.server.${this.slug(this.serverId(snapshotArg))}`;
}
private static serverId(snapshotArg: IEmbySnapshot): string {
return snapshotArg.server.Id || snapshotArg.server.ServerId || snapshotArg.server.ServerName || snapshotArg.server.Name || 'emby';
}
private static serverName(snapshotArg: IEmbySnapshot): string {
return snapshotArg.server.ServerName || snapshotArg.server.Name || 'Emby';
}
private static sessionName(sessionArg: IEmbySession): string {
return sessionArg.DeviceName || sessionArg.Client || sessionArg.DeviceId || sessionArg.Id || 'Emby Client';
}
private static playbackState(sessionArg: IEmbySession, serverOnlineArg: boolean): string {
if (!serverOnlineArg || sessionArg.IsActive === false) {
return 'off';
}
if (sessionArg.PlayState?.IsPaused) {
return 'paused';
}
if (sessionArg.NowPlayingItem) {
return 'playing';
}
return 'idle';
}
private static supportsRemote(sessionArg: IEmbySession): boolean {
return sessionArg.SupportsRemoteControl === true || Boolean(this.supportedCommands(sessionArg).length);
}
private static supportedCommands(sessionArg: IEmbySession): string[] {
return sessionArg.SupportedCommands || sessionArg.Capabilities?.SupportedCommands || [];
}
private static nowPlayingCount(snapshotArg: IEmbySnapshot): number {
return this.activeSessions(snapshotArg).filter((sessionArg) => Boolean(sessionArg.NowPlayingItem)).length;
}
private static mediaContentType(itemArg: IEmbyMediaItem | undefined): string | undefined {
if (!itemArg) {
return undefined;
}
const type = itemArg.Type || itemArg.MediaType;
return type ? contentTypeMap[type] || type.toLowerCase() : undefined;
}
private static ticksToSeconds(ticksArg: number | undefined): number | undefined {
return typeof ticksArg === 'number' ? Math.floor(ticksArg / 10000000) : undefined;
}
private static mediaImageUrl(snapshotArg: IEmbySnapshot, itemArg: IEmbyMediaItem | undefined): string | undefined {
const imageTag = itemArg?.PrimaryImageTag || itemArg?.ImageTags?.Primary;
if (!itemArg?.Id || !imageTag || !snapshotArg.server.LocalAddress) {
return undefined;
}
const baseUrl = snapshotArg.server.LocalAddress.replace(/\/+$/, '');
return `${baseUrl}/Items/${encodeURIComponent(itemArg.Id)}/Images/Primary?tag=${encodeURIComponent(imageTag)}`;
}
private static sessionUpdatedAt(sessionArg: IEmbySession, fallbackArg: string): string {
return sessionArg.LastPlaybackCheckIn || sessionArg.LastActivityDate || fallbackArg;
}
}
+249 -2
View File
@@ -1,4 +1,251 @@
export interface IHomeAssistantEmbyConfig {
// TODO: replace with the TypeScript-native config for emby.
import type { IServiceCallRequest } from '../../core/types.js';
export const embyDomain = 'emby';
export const embyDisplayName = 'Emby';
export const embyDefaultHttpPort = 8096;
export const embyDefaultHttpsPort = 8920;
export const embyDefaultTimeoutMs = 5000;
export type TEmbyProtocol = 'http' | 'https';
export type TEmbySnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime';
export interface IEmbyServerInfo {
Id?: string;
ServerId?: string;
Name?: string;
ServerName?: string;
Version?: string;
ProductName?: string;
OperatingSystem?: string;
OperatingSystemDisplayName?: string;
LocalAddress?: string;
WanAddress?: string;
HttpServerPortNumber?: number;
HttpsPortNumber?: number;
SupportsHttps?: boolean;
[key: string]: unknown;
}
export interface IEmbyUser {
Id: string;
Name?: string;
ServerId?: string;
PrimaryImageTag?: string;
HasPassword?: boolean;
HasConfiguredPassword?: boolean;
[key: string]: unknown;
}
export interface IEmbyPlayState {
PositionTicks?: number;
CanSeek?: boolean;
IsPaused?: boolean;
IsMuted?: boolean;
VolumeLevel?: number;
AudioStreamIndex?: number;
SubtitleStreamIndex?: number;
MediaSourceId?: string;
PlayMethod?: 'Transcode' | 'DirectStream' | 'DirectPlay' | string;
RepeatMode?: string;
Shuffle?: boolean;
PlaybackRate?: number;
[key: string]: unknown;
}
export interface IEmbyMediaSource {
Id?: string;
Path?: string;
Protocol?: string;
Container?: string;
Size?: number;
Bitrate?: number;
RunTimeTicks?: number;
VideoType?: string;
[key: string]: unknown;
}
export interface IEmbyMediaItem {
Id?: string;
Name?: string;
Type?: string;
MediaType?: string;
RunTimeTicks?: number;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
Album?: string;
AlbumArtist?: string;
Artists?: string[];
Overview?: string;
ProductionYear?: number;
PremiereDate?: string;
CommunityRating?: number;
OfficialRating?: string;
ImageTags?: Record<string, string | undefined>;
PrimaryImageTag?: string;
ParentBackdropItemId?: string;
AlbumId?: string;
AlbumPrimaryImageTag?: string;
MediaSources?: IEmbyMediaSource[];
ChannelId?: string;
ChannelName?: string;
[key: string]: unknown;
}
export interface IEmbySessionCapabilities {
SupportsMediaControl?: boolean;
SupportsPersistentIdentifier?: boolean;
SupportsSync?: boolean;
SupportsContentUploading?: boolean;
SupportedCommands?: TEmbyGeneralCommand[];
[key: string]: unknown;
}
export interface IEmbySession {
Id: string;
ServerId?: string;
UserId?: string;
UserName?: string;
Client?: string;
LastActivityDate?: string;
LastPlaybackCheckIn?: string;
DeviceName?: string;
DeviceId?: string;
DeviceType?: string;
ApplicationVersion?: string;
RemoteEndPoint?: string;
IsActive?: boolean;
SupportsRemoteControl?: boolean;
SupportedCommands?: TEmbyGeneralCommand[];
PlayState?: IEmbyPlayState;
NowPlayingItem?: IEmbyMediaItem;
NowViewingItem?: IEmbyMediaItem;
TranscodingInfo?: Record<string, unknown>;
Capabilities?: IEmbySessionCapabilities;
[key: string]: unknown;
}
export interface IEmbySnapshot {
server: IEmbyServerInfo;
sessions: IEmbySession[];
users?: IEmbyUser[];
online: boolean;
updatedAt?: string;
source?: TEmbySnapshotSource;
error?: string;
}
export type TEmbyPlaystateCommand =
| 'Stop'
| 'Pause'
| 'Unpause'
| 'NextTrack'
| 'PreviousTrack'
| 'Seek'
| 'Rewind'
| 'FastForward'
| 'PlayPause'
| 'SeekRelative';
export type TEmbyPlayCommand = 'PlayNow' | 'PlayNext' | 'PlayLast' | 'PlayInstantMix' | 'PlayShuffle';
export type TEmbyGeneralCommand = string;
export interface IEmbyGeneralCommandRequest {
Name: TEmbyGeneralCommand;
ControllingUserId?: string;
Arguments?: Record<string, string>;
}
export interface IEmbyClientLike {
getSnapshot?: () => Promise<IEmbySnapshot>;
getSystemInfo?: () => Promise<IEmbyServerInfo>;
getSessions?: () => Promise<IEmbySession[]>;
execute?: (commandArg: IEmbyCommandRequest) => Promise<unknown>;
destroy?: () => Promise<void>;
[key: string]: unknown;
}
export interface IEmbyCommandExecutor {
execute(commandArg: IEmbyCommandRequest): Promise<unknown>;
}
export type TEmbyCommandAction = 'refresh' | 'playstate' | 'general_command' | 'play_media';
export interface IEmbyCommandRequest {
action: TEmbyCommandAction;
sessionId?: string;
command?: TEmbyPlaystateCommand | TEmbyGeneralCommand;
arguments?: Record<string, string>;
itemIds?: string[];
playCommand?: TEmbyPlayCommand;
startPositionTicks?: number;
seekPositionTicks?: number;
controllingUserId?: string;
service?: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
}
export interface IEmbyRefreshResult {
success: boolean;
snapshot: IEmbySnapshot;
error?: string;
data?: unknown;
}
export interface IEmbyConfig {
url?: string;
host?: string;
port?: number;
ssl?: boolean;
protocol?: TEmbyProtocol;
name?: string;
uniqueId?: string;
apiKey?: string;
api_key?: string;
apiToken?: string;
accessToken?: string;
userId?: string;
deviceId?: string;
sessionId?: string;
clientDeviceId?: string;
deviceName?: string;
timeoutMs?: number;
online?: boolean;
server?: IEmbyServerInfo;
sessions?: IEmbySession[];
users?: IEmbyUser[];
snapshot?: IEmbySnapshot;
client?: IEmbyClientLike;
commandExecutor?: IEmbyCommandExecutor;
}
export interface IHomeAssistantEmbyConfig extends IEmbyConfig {}
export interface IEmbyManualEntry extends IEmbyConfig {
id?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export interface IEmbyMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IEmbySsdpRecord {
st?: string;
usn?: string;
location?: string;
server?: string;
friendlyName?: string;
headers?: Record<string, string | undefined>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './emby.classes.client.js';
export * from './emby.classes.configflow.js';
export * from './emby.classes.integration.js';
export * from './emby.discovery.js';
export * from './emby.mapper.js';
export * from './emby.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,396 @@
import type { IncomingHttpHeaders } from 'node:http';
import { FroniusMapper } from './fronius.mapper.js';
import type { IFroniusClientLike, IFroniusCommandRequest, IFroniusConfig, IFroniusRawData, IFroniusRefreshResult, IFroniusSnapshot, TFroniusApiVersion, TFroniusProtocol } from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDefaultHttpsPort, froniusDefaultTimeoutMs } from './fronius.types.js';
export class FroniusApiError extends Error {}
export class FroniusApiConnectionError extends FroniusApiError {}
export class FroniusApiInvalidResponseError extends FroniusApiError {}
export class FroniusApiUnsupportedError extends FroniusApiError {}
export class FroniusApiBadStatusError extends FroniusApiError {
public readonly response: unknown;
constructor(endpointArg: string, codeArg: number, reasonArg: string | undefined, responseArg: unknown) {
super(`Fronius Solar API bad status at ${endpointArg}. Code: ${codeArg}. Reason: ${reasonArg || 'unknown'}.`);
this.response = responseArg;
}
}
interface IFroniusEndpoint {
protocol: TFroniusProtocol;
host: string;
port: number;
}
interface IFroniusApiInfo {
apiVersion: Exclude<TFroniusApiVersion, 'auto'>;
baseUrl: string;
raw: Record<string, unknown>;
}
interface IFroniusHttpResponse {
status: number;
text: string;
headers: IncomingHttpHeaders;
}
export class FroniusClient {
private currentSnapshot?: IFroniusSnapshot;
constructor(private readonly config: IFroniusConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IFroniusSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return FroniusMapper.clone(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = FroniusMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return FroniusMapper.clone(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return FroniusMapper.clone(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.rawData) {
this.currentSnapshot = FroniusMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: this.config.online ?? true, source: 'manual' });
return FroniusMapper.clone(this.currentSnapshot);
}
if (this.endpoint()) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return FroniusMapper.clone(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('No Fronius local endpoint, injected client, snapshot, or raw data is configured.');
return FroniusMapper.clone(this.currentSnapshot);
}
public async refresh(): Promise<IFroniusRefreshResult> {
try {
this.currentSnapshot = undefined;
const liveCapable = Boolean(this.config.client || this.endpoint());
const snapshot = await this.getSnapshot(liveCapable);
const success = liveCapable && snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
return { success, snapshot, error: success ? undefined : snapshot.error || 'Fronius refresh requires a reachable local Solar API endpoint or injected client.', data: { source: snapshot.source } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = this.offlineSnapshot(error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: FroniusMapper.clone(snapshot), error };
}
}
public async fetchSnapshot(): Promise<IFroniusSnapshot> {
const endpoint = this.endpoint();
if (!endpoint) {
throw new FroniusApiConnectionError('Fronius local API snapshot requires config.host or config.url.');
}
const apiInfo = await this.resolveApiInfo(endpoint);
const rawData: Partial<IFroniusRawData> = { apiVersion: apiInfo.raw, errors: {}, inverterDeviceData: {}, fetchedAt: new Date().toISOString() };
rawData.loggerInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'loggerInfo', 'GetLoggerInfo.cgi');
rawData.inverterInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'inverterInfo', 'GetInverterInfo.cgi');
rawData.activeDeviceInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'activeDeviceInfo', 'GetActiveDeviceInfo.cgi?DeviceClass=System', true);
rawData.powerFlow = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'powerFlow', 'GetPowerFlowRealtimeData.fcgi', true);
rawData.systemMeter = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemMeter', 'GetMeterRealtimeData.cgi?Scope=System', true);
rawData.systemOhmpilot = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemOhmpilot', 'GetOhmPilotRealtimeData.cgi?Scope=System', true);
rawData.systemStorage = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemStorage', 'GetStorageRealtimeData.cgi?Scope=System', true);
for (const deviceId of FroniusMapper.inverterDeviceIds(rawData.inverterInfo)) {
const queryKey = apiInfo.apiVersion === 'v0' ? 'DeviceIndex' : 'DeviceId';
const path = `GetInverterRealtimeData.cgi?Scope=Device&${queryKey}=${encodeURIComponent(deviceId)}&DataCollection=CommonInverterData`;
const value = await this.optionalSolarResource(rawData, endpoint, apiInfo, `inverterDeviceData.${deviceId}`, path);
if (value !== undefined) {
rawData.inverterDeviceData = rawData.inverterDeviceData || {};
rawData.inverterDeviceData[deviceId] = value;
}
}
if (!Object.keys(rawData.errors || {}).length) {
delete rawData.errors;
}
if (!FroniusMapper.hasUsableRawData(rawData)) {
const error = Object.values(rawData.errors || {})[0] || 'Fronius Solar API did not return logger, inverter, power-flow, meter, ohmpilot, or storage data.';
throw new FroniusApiInvalidResponseError(error);
}
return FroniusMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http' });
}
public async execute(commandArg: IFroniusCommandRequest): Promise<unknown> {
if (this.config.commandExecutor) {
return this.config.commandExecutor.execute(commandArg);
}
if (this.config.client?.execute) {
return this.config.client.execute(commandArg);
}
if (commandArg.action === 'refresh') {
return (await this.refresh()).snapshot;
}
if (commandArg.action === 'snapshot') {
return this.getSnapshot();
}
throw new FroniusApiConnectionError('Fronius Solar API snapshots are read-only here. Writes require an injected client.execute or commandExecutor that represents the command explicitly.');
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async snapshotFromClient(clientArg: IFroniusClientLike): Promise<IFroniusSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (this.isSnapshot(result)) {
return FroniusMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return FroniusMapper.toSnapshot({ config: this.config, rawData: result as Partial<IFroniusRawData>, online: true, source: 'client' });
}
if (clientArg.getRawData) {
return FroniusMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client' });
}
const rawData = await this.rawDataFromClient(clientArg);
if (!FroniusMapper.hasUsableRawData(rawData)) {
throw new FroniusApiConnectionError('Fronius client did not return logger, inverter, power-flow, meter, ohmpilot, or storage data.');
}
return FroniusMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'client' });
}
private async rawDataFromClient(clientArg: IFroniusClientLike): Promise<Partial<IFroniusRawData>> {
const rawData: Partial<IFroniusRawData> = { errors: {}, inverterDeviceData: {}, fetchedAt: new Date().toISOString() };
rawData.loggerInfo = await this.callOptional(clientArg.currentLoggerInfo || clientArg.current_logger_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'loggerInfo', errorArg));
rawData.inverterInfo = await this.callOptional(clientArg.inverterInfo || clientArg.inverter_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'inverterInfo', errorArg));
rawData.activeDeviceInfo = await this.callOptional(clientArg.currentActiveDeviceInfo || clientArg.current_active_device_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'activeDeviceInfo', errorArg));
rawData.powerFlow = await this.callOptional(clientArg.currentPowerFlow || clientArg.current_power_flow, clientArg).catch((errorArg) => this.captureClientError(rawData, 'powerFlow', errorArg));
rawData.systemMeter = await this.callOptional(clientArg.currentSystemMeterData || clientArg.current_system_meter_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemMeter', errorArg));
rawData.systemOhmpilot = await this.callOptional(clientArg.currentSystemOhmpilotData || clientArg.current_system_ohmpilot_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemOhmpilot', errorArg));
rawData.systemStorage = await this.callOptional(clientArg.currentSystemStorageData || clientArg.current_system_storage_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemStorage', errorArg));
for (const deviceId of FroniusMapper.inverterDeviceIds(rawData.inverterInfo)) {
const value = await this.callOptional(clientArg.currentInverterData || clientArg.current_inverter_data, clientArg, deviceId).catch((errorArg) => this.captureClientError(rawData, `inverterDeviceData.${deviceId}`, errorArg));
if (value !== undefined) {
rawData.inverterDeviceData = rawData.inverterDeviceData || {};
rawData.inverterDeviceData[deviceId] = value;
}
}
if (!Object.keys(rawData.errors || {}).length) {
delete rawData.errors;
}
return rawData;
}
private captureClientError(rawDataArg: Partial<IFroniusRawData>, keyArg: string, errorArg: unknown): undefined {
rawDataArg.errors = rawDataArg.errors || {};
rawDataArg.errors[keyArg] = this.errorMessage(errorArg);
return undefined;
}
private async optionalSolarResource(rawDataArg: Partial<IFroniusRawData>, endpointArg: IFroniusEndpoint, apiInfoArg: IFroniusApiInfo, keyArg: string, pathArg: string, v1OnlyArg = false): Promise<unknown> {
try {
return await this.requestSolarJson(endpointArg, apiInfoArg, pathArg, v1OnlyArg);
} catch (errorArg) {
rawDataArg.errors = rawDataArg.errors || {};
rawDataArg.errors[keyArg] = this.errorMessage(errorArg);
return undefined;
}
}
private async resolveApiInfo(endpointArg: IFroniusEndpoint): Promise<IFroniusApiInfo> {
if (this.config.apiVersion === 'v0') {
return { apiVersion: 'v0', baseUrl: '/solar_api/', raw: { APIVersion: 0, BaseURL: '/solar_api/' } };
}
if (this.config.apiVersion === 'v1') {
return { apiVersion: 'v1', baseUrl: '/solar_api/v1/', raw: { APIVersion: 1, BaseURL: '/solar_api/v1/' } };
}
const response = await this.requestText(endpointArg, '/solar_api/GetAPIVersion.cgi');
if (response.status === 404) {
return { apiVersion: 'v0', baseUrl: '/solar_api/', raw: { APIVersion: 0, BaseURL: '/solar_api/' } };
}
this.assertHttpSuccess(response, '/solar_api/GetAPIVersion.cgi');
const raw = this.parseJson(response.text, '/solar_api/GetAPIVersion.cgi');
const apiVersion = Number(raw.APIVersion) === 0 ? 'v0' : 'v1';
const baseUrl = typeof raw.BaseURL === 'string' && raw.BaseURL ? raw.BaseURL : apiVersion === 'v0' ? '/solar_api/' : '/solar_api/v1/';
return { apiVersion, baseUrl, raw };
}
private async requestSolarJson(endpointArg: IFroniusEndpoint, apiInfoArg: IFroniusApiInfo, pathArg: string, v1OnlyArg: boolean): Promise<Record<string, unknown>> {
if (v1OnlyArg && apiInfoArg.apiVersion === 'v0') {
throw new FroniusApiUnsupportedError(`Fronius API v0 does not support ${pathArg}.`);
}
const path = joinSolarPath(apiInfoArg.baseUrl, pathArg);
const response = await this.requestText(endpointArg, path);
this.assertHttpSuccess(response, path);
const json = this.parseJson(response.text, path);
this.assertSolarStatus(json, path);
return json;
}
private async requestText(endpointArg: IFroniusEndpoint, pathArg: string, redirectLimitArg = 2): Promise<IFroniusHttpResponse> {
const response = await this.httpRequest(endpointArg, pathArg);
if (isRedirect(response.status) && response.headers.location && redirectLimitArg > 0) {
const redirected = redirectedEndpoint(endpointArg, pathArg, String(response.headers.location));
return this.requestText(redirected.endpoint, redirected.path, redirectLimitArg - 1);
}
return response;
}
private async httpRequest(endpointArg: IFroniusEndpoint, pathArg: string): Promise<IFroniusHttpResponse> {
const transport = endpointArg.protocol === 'https' ? await import('node:https') : await import('node:http');
const timeoutMs = this.config.timeoutMs || froniusDefaultTimeoutMs;
return await new Promise<IFroniusHttpResponse>((resolve, reject) => {
const requestOptions: Record<string, unknown> = {
method: 'GET',
hostname: endpointArg.host,
port: endpointArg.port,
path: pathArg,
headers: { Accept: 'application/json' },
};
if (endpointArg.protocol === 'https') {
requestOptions.rejectUnauthorized = this.config.verifyTls === true;
}
const request = transport.request(requestOptions, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunkArg: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(String(chunkArg)));
});
res.on('end', () => {
clearTimeout(timer);
resolve({ status: res.statusCode || 0, text: Buffer.concat(chunks).toString('utf8'), headers: res.headers });
});
});
const timer = setTimeout(() => {
request.destroy(new FroniusApiConnectionError(`Fronius API request timed out after ${timeoutMs}ms.`));
}, timeoutMs);
request.on('error', (errorArg: Error) => {
clearTimeout(timer);
reject(errorArg instanceof FroniusApiError ? errorArg : new FroniusApiConnectionError(this.errorMessage(errorArg)));
});
request.end();
});
}
private assertHttpSuccess(responseArg: IFroniusHttpResponse, pathArg: string): void {
if (responseArg.status === 404) {
throw new FroniusApiUnsupportedError(`Fronius Solar API endpoint is not available: ${pathArg}`);
}
if (responseArg.status < 200 || responseArg.status >= 300) {
throw new FroniusApiConnectionError(`Fronius Solar API request failed with HTTP ${responseArg.status}: ${responseArg.text}`);
}
}
private assertSolarStatus(responseArg: Record<string, unknown>, pathArg: string): void {
const status = objectValue(objectValue(responseArg.Head)?.Status);
const code = numberValue(status?.Code);
if (code !== undefined && code !== 0) {
throw new FroniusApiBadStatusError(pathArg, code, stringValue(status?.Reason), responseArg);
}
}
private parseJson(textArg: string, pathArg: string): Record<string, unknown> {
try {
const result = JSON.parse(textArg) as unknown;
const object = objectValue(result);
if (!object) {
throw new Error('JSON root is not an object.');
}
return object;
} catch (errorArg) {
throw new FroniusApiInvalidResponseError(`Fronius Solar API returned non-JSON data at ${pathArg}: ${this.errorMessage(errorArg)}`);
}
}
private endpoint(): IFroniusEndpoint | undefined {
const value = this.config.url || this.config.host;
if (!value) {
return undefined;
}
const defaultProtocol = this.config.protocol || 'http';
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `${defaultProtocol}://${value}`);
const protocol = url.protocol === 'https:' ? 'https' : 'http';
return {
protocol,
host: url.hostname,
port: this.config.port || (url.port ? Number(url.port) : protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort),
};
} catch {
const protocol = defaultProtocol;
return { protocol, host: value, port: this.config.port || (protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort) };
}
}
private isSnapshot(valueArg: unknown): valueArg is IFroniusSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'system' in valueArg && 'sensors' in valueArg && 'capabilities' in valueArg);
}
private offlineSnapshot(errorArg: string): IFroniusSnapshot {
return FroniusMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: false, source: 'runtime', error: errorArg });
}
private async callOptional<TValue>(methodArg: unknown, thisArg: unknown, ...args: unknown[]): Promise<TValue | undefined> {
if (typeof methodArg !== 'function') {
return undefined;
}
return await (methodArg as (...args: unknown[]) => Promise<TValue> | TValue).apply(thisArg, args);
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
function joinSolarPath(baseUrlArg: string, pathArg: string): string {
const base = baseUrlArg.endsWith('/') ? baseUrlArg : `${baseUrlArg}/`;
return `${base}${pathArg.replace(/^\//, '')}`;
}
function isRedirect(statusArg: number): boolean {
return statusArg === 301 || statusArg === 302 || statusArg === 303 || statusArg === 307 || statusArg === 308;
}
function redirectedEndpoint(endpointArg: IFroniusEndpoint, pathArg: string, locationArg: string): { endpoint: IFroniusEndpoint; path: string } {
const base = `${endpointArg.protocol}://${endpointArg.host}:${endpointArg.port}${pathArg}`;
const url = new URL(locationArg, base);
const protocol = url.protocol === 'https:' ? 'https' : 'http';
return {
endpoint: {
protocol,
host: url.hostname,
port: url.port ? Number(url.port) : protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort,
},
path: `${url.pathname}${url.search}`,
};
}
function objectValue(valueArg: unknown): Record<string, unknown> | undefined {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
function stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
function numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
@@ -0,0 +1,119 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { FroniusClient } from './fronius.classes.client.js';
import type { IFroniusConfig, IFroniusRawData, IFroniusSnapshot, TFroniusApiVersion, TFroniusProtocol } from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDefaultHttpsPort, froniusDefaultTimeoutMs, froniusDisplayName, froniusManufacturer } from './fronius.types.js';
export class FroniusConfigFlow implements IConfigFlow<IFroniusConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IFroniusConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Configure Fronius',
description: 'Configure a local Fronius Solar API endpoint. The runtime reads local snapshots from the device and keeps write calls delegated to an injected client or command executor.',
fields: [
{ name: 'host', label: 'Host or URL', type: 'text', required: false },
{ name: 'port', label: 'Port', type: 'number', required: false },
{ name: 'protocol', label: 'Protocol', type: 'select', required: false, options: [{ label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] },
{ name: 'apiVersion', label: 'Solar API version', type: 'select', required: false, options: [{ label: 'Auto', value: 'auto' }, { label: 'v1', value: 'v1' }, { label: 'v0', value: 'v0' }] },
{ name: 'name', label: 'Name', type: 'text', required: false },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IFroniusConfig>> {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || candidateArg.host || this.stringValue(metadata.url) || this.stringValue(metadata.host), this.protocolValue(valuesArg.protocol || metadata.protocol));
const protocol = parsed?.protocol || this.protocolValue(valuesArg.protocol || metadata.protocol) || snapshot?.protocol || 'http';
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.host || this.stringValue(metadata.host);
const port = this.numberValue(valuesArg.port) || parsed?.port || candidateArg.port || snapshot?.port || this.numberValue(metadata.port) || (protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort);
const hasManualData = Boolean(snapshot || rawData || metadata.client);
if (!host && !hasManualData) {
return { kind: 'error', title: 'Fronius setup failed', error: 'Fronius host, injected client, snapshot, or raw Solar API data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'Fronius setup failed', error: 'Fronius port must be between 1 and 65535.' };
}
const config: IFroniusConfig = {
host,
port,
protocol,
apiVersion: this.apiVersionValue(valuesArg.apiVersion || metadata.apiVersion || snapshot?.apiVersion),
timeoutMs: this.numberValue(metadata.timeoutMs) || froniusDefaultTimeoutMs,
verifyTls: metadata.verifyTls === true,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.system.name || this.stringValue(metadata.name) || froniusDisplayName,
uniqueId: candidateArg.id || snapshot?.system.uniqueId || this.stringValue(metadata.uniqueId),
serialNumber: candidateArg.serialNumber || snapshot?.system.serialNumber || this.stringValue(metadata.serialNumber),
isLogger: typeof metadata.isLogger === 'boolean' ? metadata.isLogger : snapshot?.loggerInfo ? true : undefined,
snapshot,
rawData,
client: metadata.client as IFroniusConfig['client'],
commandExecutor: metadata.commandExecutor as IFroniusConfig['commandExecutor'],
};
if (host && !hasManualData) {
const snapshotResult = await new FroniusClient(config).getSnapshot(true);
if (!snapshotResult.online || snapshotResult.error) {
return { kind: 'error', title: 'Fronius setup failed', error: snapshotResult.error || 'Fronius Solar API did not return a usable local snapshot.' };
}
if (!snapshotResult.loggerInfo && !snapshotResult.inverters.length) {
return { kind: 'error', title: 'Fronius setup failed', error: 'Fronius Solar API did not return logger or inverter identity data.' };
}
config.name = this.stringValue(valuesArg.name) || candidateArg.name || snapshotResult.system.name;
config.uniqueId = snapshotResult.loggerInfo?.uniqueIdentifier || snapshotResult.inverters[0]?.uniqueId || snapshotResult.system.uniqueId;
config.serialNumber = snapshotResult.system.serialNumber || snapshotResult.inverters[0]?.serialNumber;
config.isLogger = Boolean(snapshotResult.loggerInfo);
}
config.manufacturer = froniusManufacturer;
return { kind: 'done', title: 'Fronius configured', config };
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private protocolValue(valueArg: unknown): TFroniusProtocol | undefined {
return valueArg === 'https' || valueArg === 'http' ? valueArg : undefined;
}
private apiVersionValue(valueArg: unknown): TFroniusApiVersion {
return valueArg === 'v0' || valueArg === 'v1' ? valueArg : 'auto';
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
const snapshotValue = (valueArg: unknown): IFroniusSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'system' in valueArg && 'sensors' in valueArg ? valueArg as IFroniusSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IFroniusRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IFroniusRawData> : undefined;
const parseEndpoint = (valueArg: string | undefined, protocolArg: TFroniusProtocol | undefined): { protocol: TFroniusProtocol; host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
const protocol = protocolArg || 'http';
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `${protocol}://${valueArg}`);
return { protocol: url.protocol === 'https:' ? 'https' : 'http', host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
@@ -1,27 +1,106 @@
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 { FroniusClient } from './fronius.classes.client.js';
import { FroniusConfigFlow } from './fronius.classes.configflow.js';
import { createFroniusDiscoveryDescriptor } from './fronius.discovery.js';
import { FroniusMapper } from './fronius.mapper.js';
import type { IFroniusConfig } from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDhcpMacPrefix, froniusDisplayName, froniusDomain } from './fronius.types.js';
export class HomeAssistantFroniusIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "fronius",
displayName: "Fronius",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/fronius",
"upstreamDomain": "fronius",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"PyFronius==0.8.2"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@farmio"
]
},
});
export class FroniusIntegration extends BaseIntegration<IFroniusConfig> {
public readonly domain = froniusDomain;
public readonly displayName = froniusDisplayName;
public readonly status = 'read-only-runtime' as const;
public readonly discoveryDescriptor = createFroniusDiscoveryDescriptor();
public readonly configFlow = new FroniusConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/fronius',
upstreamDomain: froniusDomain,
integrationType: 'hub',
iotClass: 'local_polling',
qualityScale: 'platinum',
requirements: ['PyFronius==0.8.2'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@farmio'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/fronius',
platforms: ['sensor'],
dhcp: [{ macaddress: `${froniusDhcpMacPrefix}*` }],
discovery: {
dhcp: `MAC prefix ${froniusDhcpMacPrefix}* from the Home Assistant manifest`,
manual: true,
},
runtime: {
type: 'read-only-runtime',
polling: 'local Fronius Solar API snapshots over HTTP/HTTPS',
services: ['snapshot', 'status', 'refresh'],
controls: 'delegated only to an injected client.execute or commandExecutor',
},
localApi: {
implemented: [
`DHCP matching for Fronius MAC prefix ${froniusDhcpMacPrefix}*`,
'manual host/snapshot/raw-data/injected-client configuration',
`GET http://host:${froniusDefaultHttpPort}/solar_api/GetAPIVersion.cgi with v0 fallback`,
'GET Solar API logger, inverter info, inverter realtime, power-flow, meter, Ohmpilot, storage, and active-device snapshots when supported by the device',
'static snapshot/raw-data and injected client/executor operation',
],
explicitUnsupported: [
'Fronius Solar.web cloud APIs and historical cloud data',
'pretending writes succeeded without an injected executor or client implementation',
'non-Solar-API device administration endpoints',
],
},
};
public async setup(configArg: IFroniusConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new FroniusRuntime(new FroniusClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantFroniusIntegration extends FroniusIntegration {}
class FroniusRuntime implements IIntegrationRuntime {
public domain = froniusDomain;
constructor(private readonly client: FroniusClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return FroniusMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return FroniusMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === froniusDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === froniusDomain && requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, error: result.error, data: result.snapshot || result.data };
}
const snapshot = await this.client.getSnapshot();
const command = FroniusMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Fronius service or target: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
return { success: true, data: data ?? command };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,188 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IFroniusDhcpRecord, IFroniusManualEntry, IFroniusRawData, IFroniusSnapshot } from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDhcpMacPrefix, froniusDisplayName, froniusDomain, froniusManufacturer } from './fronius.types.js';
export class FroniusDhcpMatcher implements IDiscoveryMatcher<IFroniusDhcpRecord> {
public id = 'fronius-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize Fronius DHCP leases by the Home Assistant manifest MAC prefix 0003AC* and Fronius host metadata.';
public async matches(recordArg: IFroniusDhcpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = recordArg.metadata || {};
const mac = normalizeMac(recordArg.macaddress || recordArg.macAddress || metadata.macAddress || metadata.macaddress);
const hostname = stringValue(recordArg.hostname) || stringValue(recordArg.hostName) || stringValue(metadata.hostname) || '';
const text = [hostname, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(mac?.replace(/:/g, '').toUpperCase().startsWith(froniusDhcpMacPrefix) || metadata.fronius === true || text.includes('fronius') || text.includes('solarnet') || text.includes('solarweb'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP lease does not match Fronius MAC prefix 0003AC* or Fronius host metadata.' };
}
const host = stringValue(recordArg.ip) || stringValue(recordArg.address) || stringValue(metadata.ip) || stringValue(metadata.address);
const id = recordArg.id || mac || hostname || host;
return {
matched: true,
confidence: host && mac ? 'high' : 'medium',
reason: mac?.replace(/:/g, '').toUpperCase().startsWith(froniusDhcpMacPrefix) ? 'DHCP MAC address matches Home Assistant Fronius manifest prefix 0003AC*.' : 'DHCP lease contains Fronius metadata.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: froniusDomain,
id,
host,
port: froniusDefaultHttpPort,
name: hostname || froniusDisplayName,
manufacturer: froniusManufacturer,
macAddress: mac,
metadata: {
...metadata,
fronius: true,
discoveryProtocol: 'dhcp',
dhcpMacPrefix: `${froniusDhcpMacPrefix}*`,
hostname,
},
},
};
}
}
export class FroniusManualMatcher implements IDiscoveryMatcher<IFroniusManualEntry> {
public id = 'fronius-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Fronius host, snapshot, raw-data, injected-client, and command-executor setup entries.';
public async matches(inputArg: IFroniusManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const parsed = parseEndpoint(inputArg.url || inputArg.host || stringValue(metadata.url) || stringValue(metadata.host));
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(inputArg.host || inputArg.url || metadata.fronius === true || hasManualData || text.includes('fronius') || text.includes('solarnet') || text.includes('solarweb'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Fronius setup hints.' };
}
const host = parsed?.host || inputArg.host || snapshot?.host || stringValue(metadata.host);
const port = inputArg.port || parsed?.port || snapshot?.port || numberValue(metadata.port) || froniusDefaultHttpPort;
const id = inputArg.id || inputArg.uniqueId || inputArg.serialNumber || snapshot?.system.uniqueId || snapshot?.system.serialNumber || (host ? `${host}:${port}` : undefined);
const isLogger = inputArg.isLogger ?? (snapshot?.loggerInfo ? true : metadata.isLogger);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start Fronius setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: froniusDomain,
id,
host,
port,
name: inputArg.name || snapshot?.system.name || froniusDisplayName,
manufacturer: inputArg.manufacturer || snapshot?.system.manufacturer || froniusManufacturer,
model: inputArg.model || snapshot?.system.model,
serialNumber: inputArg.serialNumber || snapshot?.system.serialNumber,
metadata: {
...metadata,
fronius: true,
discoveryProtocol: 'manual',
protocol: inputArg.protocol || parsed?.protocol || snapshot?.protocol || metadata.protocol,
isLogger,
snapshot,
rawData,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
},
},
};
}
}
export class FroniusCandidateValidator implements IDiscoveryValidator {
public id = 'fronius-candidate-validator';
public description = 'Validate Fronius candidates from DHCP and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const mac = normalizeMac(candidateArg.macAddress || metadata.macAddress || metadata.macaddress);
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === froniusDomain
|| metadata.fronius === true
|| Boolean(mac?.replace(/:/g, '').toUpperCase().startsWith(froniusDhcpMacPrefix))
|| text.includes('fronius')
|| text.includes('solarnet')
|| text.includes('solarweb');
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
const normalizedDeviceId = candidateArg.id || snapshot?.system.uniqueId || snapshot?.system.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || froniusDefaultHttpPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Fronius candidate lacks a host, injected client, snapshot, or raw data.' : 'Candidate is not Fronius.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has Fronius metadata and a usable local endpoint, client, snapshot, or raw data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: froniusDomain,
port: candidateArg.port || froniusDefaultHttpPort,
manufacturer: candidateArg.manufacturer || froniusManufacturer,
metadata: {
...metadata,
fronius: true,
},
},
};
}
}
export const createFroniusDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: froniusDomain, displayName: froniusDisplayName })
.addMatcher(new FroniusDhcpMatcher())
.addMatcher(new FroniusManualMatcher())
.addValidator(new FroniusCandidateValidator());
};
const snapshotValue = (valueArg: unknown): IFroniusSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'system' in valueArg && 'sensors' in valueArg ? valueArg as IFroniusSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IFroniusRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IFroniusRawData> : undefined;
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : 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 normalizeMac = (valueArg: unknown): string | undefined => {
const normalized = typeof valueArg === 'string' ? valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase() : undefined;
return normalized && normalized.length === 12 ? normalized.match(/.{1,2}/g)?.join(':') : undefined;
};
const parseEndpoint = (valueArg: string | undefined): { protocol: 'http' | 'https'; host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`);
return { protocol: url.protocol === 'https:' ? 'https' : 'http', host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
+815
View File
@@ -0,0 +1,815 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IFroniusConfig,
IFroniusCommandRequest,
IFroniusDeviceSnapshot,
IFroniusInverterSnapshot,
IFroniusLoggerSnapshot,
IFroniusRawData,
IFroniusSensorSnapshot,
IFroniusSnapshot,
TFroniusDeviceKind,
TFroniusProtocol,
TFroniusSnapshotSource,
} from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDefaultHttpsPort, froniusDisplayName, froniusDomain, froniusManufacturer } from './fronius.types.js';
interface IFroniusSnapshotOptions {
config: IFroniusConfig;
rawData?: Partial<IFroniusRawData>;
online?: boolean;
source?: TFroniusSnapshotSource;
error?: string;
}
interface IFroniusEntityOptions {
platform: TEntityPlatform;
key: string;
name: string;
state: unknown;
available: boolean;
attributes?: Record<string, unknown>;
deviceId: string;
}
interface IEndpointInfo {
protocol?: TFroniusProtocol;
host?: string;
port?: number;
}
const inverterDeviceTypes: Record<number, { manufacturer: string; model: string }> = {
1: { manufacturer: froniusManufacturer, model: 'Gen24' },
72: { manufacturer: froniusManufacturer, model: 'Eco 27.0-3-S' },
73: { manufacturer: froniusManufacturer, model: 'Eco 25.0-3-S' },
75: { manufacturer: froniusManufacturer, model: 'Primo 6.0-1' },
76: { manufacturer: froniusManufacturer, model: 'Primo 5.0-1' },
77: { manufacturer: froniusManufacturer, model: 'Primo 4.6-1' },
78: { manufacturer: froniusManufacturer, model: 'Primo 4.0-1' },
79: { manufacturer: froniusManufacturer, model: 'Primo 3.6-1' },
80: { manufacturer: froniusManufacturer, model: 'Primo 3.5-1' },
81: { manufacturer: froniusManufacturer, model: 'Primo 3.0-1' },
82: { manufacturer: froniusManufacturer, model: 'Symo Hybrid 4.0-3-S' },
83: { manufacturer: froniusManufacturer, model: 'Symo Hybrid 3.0-3-S' },
99: { manufacturer: froniusManufacturer, model: 'Symo Hybrid 5.0-3-S' },
105: { manufacturer: froniusManufacturer, model: 'Symo 7.0-3-M' },
110: { manufacturer: froniusManufacturer, model: 'Symo 6.0-3-M' },
111: { manufacturer: froniusManufacturer, model: 'Symo 4.5-3-M' },
112: { manufacturer: froniusManufacturer, model: 'Symo 3.7-3-M' },
113: { manufacturer: froniusManufacturer, model: 'Symo 3.0-3-M' },
121: { manufacturer: froniusManufacturer, model: 'Symo 20.0-3-M' },
122: { manufacturer: froniusManufacturer, model: 'Symo 5.0-3-M' },
123: { manufacturer: froniusManufacturer, model: 'Symo 8.2-3-M' },
124: { manufacturer: froniusManufacturer, model: 'Symo 6.7-3-M' },
125: { manufacturer: froniusManufacturer, model: 'Symo 5.5-3-M' },
232: { manufacturer: froniusManufacturer, model: 'Symo 10.0-3-M' },
233: { manufacturer: froniusManufacturer, model: 'Symo 12.5-3-M' },
};
const sensorNames: Record<string, string> = {
battery_mode: 'Battery Mode',
backup_mode: 'Backup Mode',
battery_standby: 'Battery Standby',
co2_factor: 'CO2 Factor',
current_ac: 'AC Current',
current_ac_phase_1: 'AC Current Phase 1',
current_ac_phase_2: 'AC Current Phase 2',
current_ac_phase_3: 'AC Current Phase 3',
current_dc: 'DC Current',
energy_day: 'Energy Today',
energy_total: 'Energy Total',
energy_year: 'Energy Year',
energy_real_consumed: 'Energy Consumed',
energy_real_produced: 'Energy Produced',
error_code: 'Error Code',
error_message: 'Error Message',
frequency_ac: 'AC Frequency',
frequency_phase_average: 'Frequency Phase Average',
hardware_platform: 'Hardware Platform',
hardware_version: 'Hardware Version',
inverter_state: 'Inverter State',
led_color: 'LED Color',
led_state: 'LED State',
meter_location: 'Meter Location',
meter_mode: 'Meter Mode',
power_ac: 'AC Power',
power_apparent: 'Apparent Power',
power_battery: 'Battery Power',
power_grid: 'Grid Power',
power_load: 'Load Power',
power_photovoltaics: 'Photovoltaic Power',
power_reactive: 'Reactive Power',
power_real: 'Real Power',
product_type: 'Product Type',
relative_autonomy: 'Relative Autonomy',
relative_self_consumption: 'Relative Self Consumption',
software_version: 'Software Version',
state_of_charge: 'State Of Charge',
status_code: 'Status Code',
time_zone: 'Time Zone',
time_zone_location: 'Time Zone Location',
timestamp: 'Timestamp',
unique_identifier: 'Unique Identifier',
voltage_ac: 'AC Voltage',
voltage_ac_phase_1: 'AC Voltage Phase 1',
voltage_ac_phase_2: 'AC Voltage Phase 2',
voltage_ac_phase_3: 'AC Voltage Phase 3',
voltage_dc: 'DC Voltage',
};
const powerFlowMap: Record<string, string> = {
BackupMode: 'backup_mode',
BatteryStandby: 'battery_standby',
E_Day: 'energy_day',
E_Total: 'energy_total',
E_Year: 'energy_year',
Meter_Location: 'meter_location',
Mode: 'meter_mode',
P_Akku: 'power_battery',
P_Grid: 'power_grid',
P_Load: 'power_load',
P_PV: 'power_photovoltaics',
rel_Autonomy: 'relative_autonomy',
rel_SelfConsumption: 'relative_self_consumption',
};
const inverterDataMap: Record<string, string> = {
DAY_ENERGY: 'energy_day',
TOTAL_ENERGY: 'energy_total',
YEAR_ENERGY: 'energy_year',
FAC: 'frequency_ac',
IAC: 'current_ac',
IDC: 'current_dc',
PAC: 'power_ac',
UAC: 'voltage_ac',
UDC: 'voltage_dc',
};
const meterDataMap: Record<string, string> = {
Current_AC_Phase_1: 'current_ac_phase_1',
ACBRIDGE_CURRENT_ACTIVE_MEAN_01_F32: 'current_ac_phase_1',
Current_AC_Phase_2: 'current_ac_phase_2',
ACBRIDGE_CURRENT_ACTIVE_MEAN_02_F32: 'current_ac_phase_2',
Current_AC_Phase_3: 'current_ac_phase_3',
ACBRIDGE_CURRENT_ACTIVE_MEAN_03_F32: 'current_ac_phase_3',
EnergyReal_WAC_Sum_Consumed: 'energy_real_consumed',
SMARTMETER_ENERGYACTIVE_CONSUMED_SUM_F64: 'energy_real_consumed',
EnergyReal_WAC_Sum_Produced: 'energy_real_produced',
SMARTMETER_ENERGYACTIVE_PRODUCED_SUM_F64: 'energy_real_produced',
Frequency_Phase_Average: 'frequency_phase_average',
PowerApparent_S_Sum: 'power_apparent',
PowerReactive_Q_Sum: 'power_reactive',
PowerReal_P_Sum: 'power_real',
Voltage_AC_Phase_1: 'voltage_ac_phase_1',
Voltage_AC_Phase_2: 'voltage_ac_phase_2',
Voltage_AC_Phase_3: 'voltage_ac_phase_3',
Meter_Location_Current: 'meter_location',
};
export class FroniusMapper {
public static toSnapshot(optionsArg: IFroniusSnapshotOptions): IFroniusSnapshot {
if (optionsArg.config.snapshot && !optionsArg.rawData) {
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot', optionsArg.error);
}
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
const endpoint = parseEndpoint(optionsArg.config.url || optionsArg.config.host, optionsArg.config.port, optionsArg.config.protocol);
const loggerInfo = this.loggerInfo(rawData.loggerInfo);
const inverters = this.inverters(rawData.inverterInfo);
const system = this.systemDevice(optionsArg.config, rawData, loggerInfo, inverters, endpoint);
const devices: IFroniusDeviceSnapshot[] = [system];
const sensors: IFroniusSensorSnapshot[] = [];
for (const inverter of inverters) {
inverter.viaDeviceId = system.id;
devices.push(inverter);
}
devices.push(...this.childDevices(rawData.systemMeter, 'meter', 'Meter', system.id));
devices.push(...this.childDevices(rawData.systemOhmpilot, 'ohmpilot', 'Ohmpilot', system.id));
devices.push(...this.childDevices(rawData.systemStorage, 'storage', 'Storage', system.id));
sensors.push(...this.loggerSensors(system, loggerInfo, rawData.loggerInfo, Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
sensors.push(...this.powerFlowSensors(system, rawData.powerFlow, Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
for (const inverter of inverters) {
sensors.push(...this.inverterSensors(inverter, rawData.inverterDeviceData?.[inverter.solarNetId || inverter.id], Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
}
sensors.push(...this.systemCollectionSensors(devices, rawData.systemMeter, 'meter', Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
sensors.push(...this.systemCollectionSensors(devices, rawData.systemOhmpilot, 'ohmpilot', Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
sensors.push(...this.systemCollectionSensors(devices, rawData.systemStorage, 'storage', Boolean(optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData))));
const online = optionsArg.online ?? optionsArg.config.online ?? this.hasUsableRawData(rawData);
const source = optionsArg.source || (rawData.loggerInfo || rawData.inverterInfo || rawData.powerFlow || rawData.systemMeter ? 'http' : 'runtime');
return this.normalizeSnapshot({
system,
devices,
inverters,
sensors,
loggerInfo,
host: endpoint.host,
port: endpoint.port,
protocol: endpoint.protocol,
apiVersion: this.apiVersion(optionsArg.config, rawData),
rawData: this.hasUsableRawData(rawData) ? rawData : undefined,
capabilities: {
localRead: Boolean(optionsArg.config.client || endpoint.host || optionsArg.config.snapshot || optionsArg.config.rawData || this.hasUsableRawData(rawData)),
localControl: Boolean(optionsArg.config.commandExecutor || optionsArg.config.client?.execute),
readOnlySolarApi: true,
},
online,
updatedAt: rawData.fetchedAt || new Date().toISOString(),
source,
error: optionsArg.error,
}, optionsArg.config, source, optionsArg.error);
}
public static toDevices(snapshotArg: IFroniusSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
return snapshotArg.devices.map((deviceArg) => {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const deviceSensors = snapshotArg.sensors.filter((sensorArg) => sensorArg.deviceId === deviceArg.id);
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'online', capability: 'sensor', name: 'Online', readable: true, writable: false },
...deviceSensors.map((sensorArg) => ({
id: sensorArg.id,
capability: sensorArg.deviceClass === 'energy' ? 'energy' as const : 'sensor' as const,
name: sensorArg.name,
readable: true,
writable: false,
unit: sensorArg.unit,
})),
];
return {
id: deviceArg.id,
integrationDomain: froniusDomain,
name: deviceArg.name,
protocol: snapshotArg.host ? 'http' : 'unknown',
manufacturer: deviceArg.manufacturer,
model: deviceArg.model || deviceArg.kind,
online: snapshotArg.online,
features,
state: [
{ featureId: 'online', value: snapshotArg.online, updatedAt },
...deviceSensors.map((sensorArg) => ({ featureId: sensorArg.id, value: deviceStateValue(sensorArg.value), updatedAt })),
],
metadata: cleanAttributes({
...deviceArg.metadata,
kind: deviceArg.kind,
serialNumber: deviceArg.serialNumber,
uniqueId: deviceArg.uniqueId,
solarNetId: deviceArg.solarNetId,
viaDeviceId: deviceArg.viaDeviceId,
host: snapshotArg.host,
port: snapshotArg.port,
source: snapshotArg.source,
error: snapshotArg.error,
}),
};
});
}
public static toEntities(snapshotArg: IFroniusSnapshot): IIntegrationEntity[] {
const seen = new Map<string, number>();
return snapshotArg.sensors.map((sensorArg) => {
const device = snapshotArg.devices.find((deviceArg) => deviceArg.id === sensorArg.deviceId) || snapshotArg.system;
return this.entity(snapshotArg, {
platform: 'sensor',
key: sensorArg.id,
name: `${device.name} ${sensorArg.name}`,
state: sensorArg.value ?? null,
available: sensorArg.available && snapshotArg.online,
deviceId: sensorArg.deviceId,
attributes: cleanAttributes({
...sensorArg.attributes,
key: sensorArg.id,
kind: sensorArg.kind,
nativeUnitOfMeasurement: sensorArg.unit,
deviceClass: sensorArg.deviceClass,
stateClass: sensorArg.stateClass,
entityCategory: sensorArg.entityCategory,
}),
}, seen);
});
}
public static commandForService(snapshotArg: IFroniusSnapshot, requestArg: IServiceCallRequest): IFroniusCommandRequest | undefined {
void snapshotArg;
if (requestArg.domain !== froniusDomain) {
return undefined;
}
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return { action: 'snapshot' };
}
if (requestArg.service === 'refresh') {
return { action: 'refresh' };
}
return { action: requestArg.service, service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
public static hasUsableRawData(rawDataArg: Partial<IFroniusRawData> | undefined): boolean {
return Boolean(rawDataArg && (rawDataArg.loggerInfo || rawDataArg.inverterInfo || rawDataArg.powerFlow || rawDataArg.systemMeter || rawDataArg.systemOhmpilot || rawDataArg.systemStorage || Object.keys(rawDataArg.inverterDeviceData || {}).length));
}
public static inverterDeviceIds(inverterInfoArg: unknown): string[] {
return this.inverters(inverterInfoArg).map((inverterArg) => inverterArg.solarNetId || inverterArg.id).filter(Boolean);
}
public static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
private static rawData(configArg: IFroniusConfig, rawDataArg?: Partial<IFroniusRawData>): Partial<IFroniusRawData> {
return {
...configArg.rawData,
...rawDataArg,
inverterDeviceData: {
...(configArg.rawData?.inverterDeviceData || {}),
...(rawDataArg?.inverterDeviceData || {}),
},
errors: {
...(configArg.rawData?.errors || {}),
...(rawDataArg?.errors || {}),
},
};
}
private static normalizeSnapshot(snapshotArg: IFroniusSnapshot, configArg: IFroniusConfig, sourceArg: TFroniusSnapshotSource, errorArg?: string): IFroniusSnapshot {
const endpoint = parseEndpoint(configArg.url || configArg.host, configArg.port, configArg.protocol);
snapshotArg.host = snapshotArg.host || endpoint.host;
snapshotArg.port = snapshotArg.port || endpoint.port;
snapshotArg.protocol = snapshotArg.protocol || endpoint.protocol;
snapshotArg.source = sourceArg;
snapshotArg.error = errorArg || snapshotArg.error;
snapshotArg.capabilities = {
...snapshotArg.capabilities,
localRead: snapshotArg.capabilities.localRead || Boolean(configArg.client || endpoint.host || configArg.snapshot || configArg.rawData),
localControl: Boolean(configArg.commandExecutor || configArg.client?.execute || snapshotArg.capabilities.localControl),
readOnlySolarApi: true,
};
return snapshotArg;
}
private static systemDevice(configArg: IFroniusConfig, rawDataArg: Partial<IFroniusRawData>, loggerInfoArg: IFroniusLoggerSnapshot | undefined, invertersArg: IFroniusInverterSnapshot[], endpointArg: IEndpointInfo): IFroniusDeviceSnapshot {
const uniqueId = configArg.uniqueId || loggerInfoArg?.uniqueIdentifier || invertersArg[0]?.uniqueId || endpointArg.host || 'solar_net';
const hostLabel = endpointArg.host ? ` at ${endpointArg.host}` : '';
return {
id: `fronius.system.${slug(uniqueId)}`,
kind: 'system',
name: configArg.name || `SolarNet ${loggerInfoArg ? 'Datalogger' : 'System'}${hostLabel}`,
manufacturer: froniusManufacturer,
model: loggerInfoArg?.productType || (configArg.isLogger ? 'Datalogger Web' : froniusDisplayName),
serialNumber: configArg.serialNumber || loggerInfoArg?.uniqueIdentifier,
uniqueId,
metadata: cleanAttributes({
apiVersion: this.apiVersion(configArg, rawDataArg),
logger: Boolean(loggerInfoArg),
}),
};
}
private static loggerInfo(rawArg: unknown): IFroniusLoggerSnapshot | undefined {
const data = responseData(rawArg);
if (!data) {
return undefined;
}
return cleanAttributes({
uniqueIdentifier: stringValue(sensorValue(data.unique_identifier) ?? sensorValue(data.UniqueID)),
hardwareVersion: stringValue(sensorValue(data.hardware_version) ?? sensorValue(data.HWVersion)),
softwareVersion: stringValue(sensorValue(data.software_version) ?? sensorValue(data.SWVersion)),
productType: stringValue(sensorValue(data.product_type) ?? sensorValue(data.ProductID)) || 'Datalogger Web',
timeZone: stringValue(sensorValue(data.time_zone) ?? sensorValue(data.TimezoneName)),
raw: rawArg,
}) as IFroniusLoggerSnapshot;
}
private static inverters(rawArg: unknown): IFroniusInverterSnapshot[] {
const data = responseData(rawArg);
if (!data) {
return [];
}
const normalized = Array.isArray(data.inverters) ? data.inverters : undefined;
if (normalized) {
return normalized.map((inverterArg, indexArg) => this.inverterFromNormalized(inverterArg as Record<string, unknown>, indexArg));
}
return Object.entries(data).map(([deviceId, value], indexArg) => this.inverterFromSolarApi(deviceId, objectValue(value) || {}, indexArg));
}
private static inverterFromNormalized(inverterArg: Record<string, unknown>, indexArg: number): IFroniusInverterSnapshot {
const solarNetId = stringValue(sensorValue(inverterArg.device_id)) || String(indexArg + 1);
const uniqueId = stringValue(sensorValue(inverterArg.unique_id)) || solarNetId;
const deviceTypeValue = numberValue(sensorValue(inverterArg.device_type));
const knownType = deviceTypeValue === undefined ? undefined : inverterDeviceTypes[deviceTypeValue];
return {
id: `fronius.inverter.${slug(uniqueId || solarNetId)}`,
kind: 'inverter',
name: stringValue(sensorValue(inverterArg.custom_name)) || `Inverter ${solarNetId}`,
manufacturer: stringValue((objectValue(inverterArg.device_type) || {}).manufacturer) || knownType?.manufacturer || froniusManufacturer,
model: stringValue((objectValue(inverterArg.device_type) || {}).model) || knownType?.model || (deviceTypeValue === undefined ? undefined : `Device Type ${deviceTypeValue}`),
uniqueId,
solarNetId,
pvPower: numberValue(sensorValue(inverterArg.pv_power)),
statusCode: numberValue(sensorValue(inverterArg.status_code)),
errorCode: numberValue(sensorValue(inverterArg.error_code)),
metadata: cleanAttributes({ show: sensorValue(inverterArg.show), deviceType: deviceTypeValue }),
};
}
private static inverterFromSolarApi(deviceIdArg: string, dataArg: Record<string, unknown>, indexArg: number): IFroniusInverterSnapshot {
const deviceType = numberValue(dataArg.DT);
const knownType = deviceType === undefined ? undefined : inverterDeviceTypes[deviceType];
const uniqueId = stringValue(dataArg.UniqueID) || deviceIdArg || String(indexArg + 1);
return {
id: `fronius.inverter.${slug(uniqueId)}`,
kind: 'inverter',
name: stringValue(dataArg.CustomName) || `Inverter ${deviceIdArg || indexArg + 1}`,
manufacturer: knownType?.manufacturer || froniusManufacturer,
model: knownType?.model || (deviceType === undefined ? undefined : `Device Type ${deviceType}`),
uniqueId,
solarNetId: deviceIdArg || String(indexArg + 1),
pvPower: numberValue(dataArg.PVPower),
statusCode: numberValue(dataArg.StatusCode),
errorCode: numberValue(dataArg.ErrorCode),
metadata: cleanAttributes({ show: dataArg.Show, deviceType }),
};
}
private static loggerSensors(deviceArg: IFroniusDeviceSnapshot, loggerInfoArg: IFroniusLoggerSnapshot | undefined, rawArg: unknown, onlineArg: boolean): IFroniusSensorSnapshot[] {
const data = responseData(rawArg);
const sensors: IFroniusSensorSnapshot[] = [];
const entries: Record<string, unknown> = {
hardware_version: loggerInfoArg?.hardwareVersion,
software_version: loggerInfoArg?.softwareVersion,
product_type: loggerInfoArg?.productType,
time_zone: loggerInfoArg?.timeZone,
unique_identifier: loggerInfoArg?.uniqueIdentifier,
};
if (data) {
for (const key of ['co2_factor', 'cash_factor', 'delivery_factor', 'hardware_platform', 'time_zone_location', 'utc_offset']) {
entries[key] = sensorValue(data[key]) ?? sensorValue(data[pascalish(key)]);
}
}
for (const [key, value] of Object.entries(entries)) {
const sensor = this.sensor(deviceArg.id, 'logger', key, value, onlineArg, { entityCategory: 'diagnostic' });
if (sensor) {
sensors.push(sensor);
}
}
return sensors;
}
private static powerFlowSensors(deviceArg: IFroniusDeviceSnapshot, rawArg: unknown, onlineArg: boolean): IFroniusSensorSnapshot[] {
const data = responseData(rawArg);
if (!data) {
return [];
}
const site = objectValue(data.Site) || data;
const sensors: IFroniusSensorSnapshot[] = [];
for (const [rawKey, key] of Object.entries(powerFlowMap)) {
const value = site[rawKey] ?? sensorValue(site[key]);
const sensor = this.sensor(deviceArg.id, 'power_flow', key, withUnit(key, value), onlineArg);
if (sensor) {
sensors.push(sensor);
}
}
const inverters = objectValue(data.Inverters);
const inverterOne = objectValue(inverters?.['1']);
if (inverterOne?.SOC !== undefined) {
const sensor = this.sensor(deviceArg.id, 'power_flow', 'state_of_charge', { value: inverterOne.SOC, unit: '%' }, onlineArg);
if (sensor) {
sensors.push(sensor);
}
}
if (inverterOne?.Battery_Mode !== undefined) {
const sensor = this.sensor(deviceArg.id, 'power_flow', 'battery_mode', inverterOne.Battery_Mode, onlineArg);
if (sensor) {
sensors.push(sensor);
}
}
return sensors;
}
private static inverterSensors(deviceArg: IFroniusInverterSnapshot, rawArg: unknown, onlineArg: boolean): IFroniusSensorSnapshot[] {
const data = responseData(rawArg);
const values: Record<string, unknown> = cleanAttributes({
pv_power: deviceArg.pvPower === undefined ? undefined : { value: deviceArg.pvPower, unit: 'W' },
status_code: deviceArg.statusCode,
error_code: deviceArg.errorCode,
});
if (data) {
for (const [rawKey, key] of Object.entries(inverterDataMap)) {
values[key] = solarApiValue(data[rawKey]) ?? sensorValue(data[key]);
}
for (let index = 2; index < 10; index++) {
values[`current_dc_${index}`] = solarApiValue(data[`IDC_${index}`]) ?? sensorValue(data[`current_dc_${index}`]);
values[`voltage_dc_${index}`] = solarApiValue(data[`UDC_${index}`]) ?? sensorValue(data[`voltage_dc_${index}`]);
}
const status = objectValue(data.DeviceStatus);
values.inverter_state = status?.InverterState ?? sensorValue(data.inverter_state);
values.error_code = status?.ErrorCode ?? values.error_code;
values.status_code = status?.StatusCode ?? values.status_code;
values.led_state = status?.LEDState ?? sensorValue(data.led_state);
values.led_color = status?.LEDColor ?? sensorValue(data.led_color);
}
return Object.entries(values).map(([key, value]) => this.sensor(deviceArg.id, 'inverter', key, value, onlineArg, diagnosticOptions(key))).filter(Boolean) as IFroniusSensorSnapshot[];
}
private static childDevices(rawArg: unknown, kindArg: Exclude<TFroniusDeviceKind, 'system' | 'inverter'>, labelArg: string, viaDeviceIdArg: string): IFroniusDeviceSnapshot[] {
const data = collectionData(rawArg, kindArg);
return Object.entries(data).map(([deviceId, value]) => {
const details = objectValue(objectValue(value)?.Details) || objectValue(value) || {};
const serial = stringValue(details.Serial) || deviceId;
return {
id: `fronius.${kindArg}.${slug(serial || deviceId)}`,
kind: kindArg,
name: `${labelArg} ${deviceId}`,
manufacturer: stringValue(details.Manufacturer) || froniusManufacturer,
model: stringValue(details.Model) || labelArg,
serialNumber: stringValue(details.Serial),
solarNetId: deviceId,
viaDeviceId: viaDeviceIdArg,
};
});
}
private static systemCollectionSensors(devicesArg: IFroniusDeviceSnapshot[], rawArg: unknown, kindArg: Exclude<TFroniusDeviceKind, 'system' | 'inverter'>, onlineArg: boolean): IFroniusSensorSnapshot[] {
const data = collectionData(rawArg, kindArg);
const sensors: IFroniusSensorSnapshot[] = [];
for (const [deviceId, value] of Object.entries(data)) {
const device = devicesArg.find((deviceArg) => deviceArg.kind === kindArg && deviceArg.solarNetId === deviceId);
if (!device) {
continue;
}
const flat = kindArg === 'meter' ? knownMappedValues(objectValue(value) || {}, meterDataMap) : flattenScalars(objectValue(value) || {});
for (const [key, sensorValueArg] of Object.entries(flat)) {
const sensor = this.sensor(device.id, kindArg, key, sensorValueArg, onlineArg, diagnosticOptions(key));
if (sensor) {
sensors.push(sensor);
}
}
}
return sensors;
}
private static sensor(deviceIdArg: string, kindArg: string, keyArg: string, valueArg: unknown, onlineArg: boolean, optionsArg: { entityCategory?: string } = {}): IFroniusSensorSnapshot | undefined {
const normalized = normalizeSensorValue(keyArg, valueArg);
if (normalized.value === undefined) {
return undefined;
}
const unit = normalized.unit;
const meta = sensorMeta(keyArg, unit);
return {
id: keyArg,
deviceId: deviceIdArg,
kind: kindArg,
name: sensorNames[keyArg] || humanName(keyArg),
value: normalized.value,
unit,
deviceClass: meta.deviceClass,
stateClass: meta.stateClass,
entityCategory: optionsArg.entityCategory || meta.entityCategory,
available: onlineArg && normalized.value !== null,
attributes: cleanAttributes({ sourceKey: normalized.sourceKey }),
};
}
private static apiVersion(configArg: IFroniusConfig, rawDataArg: Partial<IFroniusRawData>): 'auto' | 'v0' | 'v1' {
if (configArg.apiVersion && configArg.apiVersion !== 'auto') {
return configArg.apiVersion;
}
const value = numberValue(rawDataArg.apiVersion?.APIVersion);
if (value === 0) {
return 'v0';
}
if (value === 1) {
return 'v1';
}
return configArg.apiVersion || 'auto';
}
private static entity(snapshotArg: IFroniusSnapshot, optionsArg: IFroniusEntityOptions, seenArg: Map<string, number>): IIntegrationEntity {
const device = snapshotArg.devices.find((deviceArg) => deviceArg.id === optionsArg.deviceId) || snapshotArg.system;
const baseId = `${optionsArg.platform}.${slug(device.name)}_${slug(optionsArg.key)}`;
const seenCount = seenArg.get(baseId) || 0;
seenArg.set(baseId, seenCount + 1);
const id = seenCount ? `${baseId}_${seenCount + 1}` : baseId;
return {
id,
uniqueId: `${froniusDomain}_${slug(device.id)}_${slug(optionsArg.key)}`,
integrationDomain: froniusDomain,
deviceId: optionsArg.deviceId,
platform: optionsArg.platform,
name: optionsArg.name,
state: optionsArg.state,
available: optionsArg.available,
attributes: optionsArg.attributes,
};
}
}
function responseData(valueArg: unknown): Record<string, unknown> | undefined {
const object = objectValue(valueArg);
if (!object) {
return undefined;
}
const body = objectValue(object.Body);
const bodyData = objectValue(body?.Data) || objectValue(body?.LoggerInfo);
if (bodyData) {
return bodyData;
}
return object;
}
function collectionData(rawArg: unknown, kindArg: Exclude<TFroniusDeviceKind, 'system' | 'inverter'>): Record<string, unknown> {
const data = responseData(rawArg);
if (!data) {
return {};
}
const collectionKey = kindArg === 'meter' ? 'meters' : kindArg === 'ohmpilot' ? 'ohmpilots' : 'storages';
return objectValue(data[collectionKey]) || data;
}
function knownMappedValues(dataArg: Record<string, unknown>, mapArg: Record<string, string>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [rawKey, key] of Object.entries(mapArg)) {
result[key] = solarApiValue(dataArg[rawKey]) ?? (dataArg[rawKey] === undefined ? undefined : withUnit(key, dataArg[rawKey])) ?? sensorValue(dataArg[key]);
}
for (const key of ['manufacturer', 'model', 'serial', 'enable', 'visible']) {
result[key] = sensorValue(dataArg[key]) ?? objectValue(dataArg.Details)?.[pascalish(key)];
}
return cleanAttributes(result);
}
function flattenScalars(dataArg: Record<string, unknown>, prefixArg = ''): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(dataArg)) {
const normalizedKey = `${prefixArg}${prefixArg ? '_' : ''}${slug(key).replace(/-/g, '_')}`;
const object = objectValue(value);
if (object && ('Value' in object || 'value' in object)) {
result[normalizedKey] = solarApiValue(object) ?? sensorValue(object);
continue;
}
if (object && key !== 'Modules') {
Object.assign(result, flattenScalars(object, normalizedKey));
continue;
}
if (typeof value !== 'object') {
result[normalizedKey] = withUnit(normalizedKey, value);
}
}
return cleanAttributes(result);
}
function normalizeSensorValue(keyArg: string, valueArg: unknown): { value: unknown; unit?: string; sourceKey?: string } {
const object = objectValue(valueArg);
if (object) {
const value = object.value ?? object.Value;
const unit = stringValue(object.unit ?? object.Unit) || defaultUnit(keyArg);
return { value, unit, sourceKey: stringValue(object.sourceKey) };
}
return { value: valueArg, unit: defaultUnit(keyArg) };
}
function solarApiValue(valueArg: unknown): { value: unknown; unit?: string } | undefined {
const object = objectValue(valueArg);
if (!object) {
return undefined;
}
if ('Value' in object || 'Unit' in object) {
return { value: object.Value, unit: stringValue(object.Unit) };
}
return undefined;
}
function sensorValue(valueArg: unknown): unknown {
const object = objectValue(valueArg);
if (object && 'value' in object) {
return object.value;
}
if (object && 'Value' in object) {
return object.Value;
}
return valueArg;
}
function withUnit(keyArg: string, valueArg: unknown): { value: unknown; unit?: string } {
return { value: valueArg, unit: defaultUnit(keyArg) };
}
function defaultUnit(keyArg: string): string | undefined {
if (keyArg.includes('energy')) {
return 'Wh';
}
if (keyArg.includes('power')) {
return keyArg.includes('reactive') ? 'VAr' : keyArg.includes('apparent') ? 'VA' : 'W';
}
if (keyArg.includes('current')) {
return 'A';
}
if (keyArg.includes('voltage')) {
return 'V';
}
if (keyArg.includes('frequency')) {
return 'Hz';
}
if (keyArg.includes('temperature')) {
return 'C';
}
if (keyArg.includes('state_of_charge') || keyArg.includes('relative_')) {
return '%';
}
return undefined;
}
function sensorMeta(keyArg: string, unitArg: string | undefined): { deviceClass?: string; stateClass?: string; entityCategory?: string } {
if (keyArg.includes('energy')) {
return { deviceClass: 'energy', stateClass: 'total_increasing' };
}
if (keyArg.includes('power')) {
return { deviceClass: unitArg === 'VA' ? 'apparent_power' : unitArg === 'VAr' ? 'reactive_power' : 'power', stateClass: 'measurement' };
}
if (keyArg.includes('current')) {
return { deviceClass: 'current', stateClass: 'measurement' };
}
if (keyArg.includes('voltage')) {
return { deviceClass: 'voltage', stateClass: 'measurement' };
}
if (keyArg.includes('frequency')) {
return { deviceClass: 'frequency', stateClass: 'measurement' };
}
if (keyArg.includes('temperature')) {
return { deviceClass: 'temperature', stateClass: 'measurement' };
}
if (keyArg === 'state_of_charge') {
return { deviceClass: 'battery', stateClass: 'measurement' };
}
if (keyArg.includes('status') || keyArg.includes('error') || keyArg.includes('version') || keyArg.includes('identifier') || keyArg.includes('led')) {
return { entityCategory: 'diagnostic' };
}
if (unitArg === '%') {
return { stateClass: 'measurement' };
}
return {};
}
function diagnosticOptions(keyArg: string): { entityCategory?: string } {
return sensorMeta(keyArg, undefined).entityCategory ? { entityCategory: 'diagnostic' } : {};
}
function parseEndpoint(valueArg: string | undefined, portArg?: number, protocolArg?: TFroniusProtocol): IEndpointInfo {
if (!valueArg) {
return { protocol: protocolArg, port: portArg };
}
const defaultProtocol = protocolArg || 'http';
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `${defaultProtocol}://${valueArg}`);
const protocol = url.protocol === 'https:' ? 'https' : 'http';
return { protocol, host: url.hostname, port: portArg || (url.port ? Number(url.port) : protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort) };
} catch {
return { protocol: defaultProtocol, host: valueArg, port: portArg || (defaultProtocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort) };
}
}
function objectValue(valueArg: unknown): Record<string, unknown> | undefined {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
function stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
function 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;
}
function humanName(valueArg: string): string {
return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
}
function slug(valueArg: unknown): string {
return String(valueArg || 'unknown').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'unknown';
}
function pascalish(valueArg: string): string {
return valueArg.split('_').map((partArg) => partArg.charAt(0).toUpperCase() + partArg.slice(1)).join('');
}
function cleanAttributes<TValue extends Record<string, unknown>>(valueArg: TValue): TValue {
return Object.fromEntries(Object.entries(valueArg).filter(([, value]) => value !== undefined)) as TValue;
}
function deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (valueArg === undefined) {
return null;
}
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) {
return valueArg;
}
return { value: valueArg };
}
+186 -3
View File
@@ -1,4 +1,187 @@
export interface IHomeAssistantFroniusConfig {
// TODO: replace with the TypeScript-native config for fronius.
[key: string]: unknown;
export const froniusDomain = 'fronius';
export const froniusDisplayName = 'Fronius';
export const froniusManufacturer = 'Fronius';
export const froniusDefaultHttpPort = 80;
export const froniusDefaultHttpsPort = 443;
export const froniusDefaultTimeoutMs = 5000;
export const froniusDhcpMacPrefix = '0003AC';
export type TFroniusProtocol = 'http' | 'https';
export type TFroniusApiVersion = 'auto' | 'v0' | 'v1';
export type TFroniusSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime';
export type TFroniusDeviceKind = 'system' | 'inverter' | 'meter' | 'ohmpilot' | 'storage';
export interface IFroniusCommandRequest {
action: string;
service?: string;
target?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface IFroniusCommandExecutor {
execute(commandArg: IFroniusCommandRequest): Promise<unknown> | unknown;
}
export interface IFroniusClientLike {
getSnapshot?(): Promise<IFroniusSnapshot | Partial<IFroniusRawData> | unknown> | IFroniusSnapshot | Partial<IFroniusRawData> | unknown;
getRawData?(): Promise<Partial<IFroniusRawData>> | Partial<IFroniusRawData>;
currentLoggerInfo?(): Promise<unknown> | unknown;
current_logger_info?(): Promise<unknown> | unknown;
inverterInfo?(): Promise<unknown> | unknown;
inverter_info?(): Promise<unknown> | unknown;
currentActiveDeviceInfo?(): Promise<unknown> | unknown;
current_active_device_info?(): Promise<unknown> | unknown;
currentPowerFlow?(): Promise<unknown> | unknown;
current_power_flow?(): Promise<unknown> | unknown;
currentSystemMeterData?(): Promise<unknown> | unknown;
current_system_meter_data?(): Promise<unknown> | unknown;
currentSystemOhmpilotData?(): Promise<unknown> | unknown;
current_system_ohmpilot_data?(): Promise<unknown> | unknown;
currentSystemStorageData?(): Promise<unknown> | unknown;
current_system_storage_data?(): Promise<unknown> | unknown;
currentInverterData?(deviceIdArg: string): Promise<unknown> | unknown;
current_inverter_data?(deviceIdArg: string): Promise<unknown> | unknown;
execute?(commandArg: IFroniusCommandRequest): Promise<unknown> | unknown;
destroy?(): Promise<void> | void;
}
export interface IFroniusConfig {
host?: string;
url?: string;
port?: number;
protocol?: TFroniusProtocol;
apiVersion?: TFroniusApiVersion;
timeoutMs?: number;
verifyTls?: boolean;
name?: string;
manufacturer?: string;
model?: string;
uniqueId?: string;
serialNumber?: string;
isLogger?: boolean;
online?: boolean;
snapshot?: IFroniusSnapshot;
rawData?: Partial<IFroniusRawData>;
client?: IFroniusClientLike;
commandExecutor?: IFroniusCommandExecutor;
}
export interface IHomeAssistantFroniusConfig extends IFroniusConfig {}
export interface IFroniusRawData {
apiVersion?: Record<string, unknown>;
loggerInfo?: unknown;
inverterInfo?: unknown;
activeDeviceInfo?: unknown;
powerFlow?: unknown;
systemMeter?: unknown;
systemOhmpilot?: unknown;
systemStorage?: unknown;
inverterDeviceData?: Record<string, unknown>;
errors?: Record<string, string>;
fetchedAt?: string;
}
export interface IFroniusDeviceSnapshot {
id: string;
kind: TFroniusDeviceKind;
name: string;
manufacturer: string;
model?: string;
serialNumber?: string;
uniqueId?: string;
solarNetId?: string;
viaDeviceId?: string;
metadata?: Record<string, unknown>;
}
export interface IFroniusSensorSnapshot {
id: string;
deviceId: string;
kind: string;
name: string;
value: unknown;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
available: boolean;
attributes?: Record<string, unknown>;
}
export interface IFroniusLoggerSnapshot {
uniqueIdentifier?: string;
hardwareVersion?: string;
softwareVersion?: string;
productType?: string;
timeZone?: string;
raw?: unknown;
}
export interface IFroniusInverterSnapshot extends IFroniusDeviceSnapshot {
kind: 'inverter';
pvPower?: number;
statusCode?: number;
errorCode?: number;
}
export interface IFroniusCapabilitiesSnapshot {
localRead: boolean;
localControl: boolean;
readOnlySolarApi: boolean;
}
export interface IFroniusSnapshot {
system: IFroniusDeviceSnapshot;
devices: IFroniusDeviceSnapshot[];
inverters: IFroniusInverterSnapshot[];
sensors: IFroniusSensorSnapshot[];
loggerInfo?: IFroniusLoggerSnapshot;
host?: string;
port?: number;
protocol?: TFroniusProtocol;
apiVersion?: TFroniusApiVersion;
rawData?: Partial<IFroniusRawData>;
capabilities: IFroniusCapabilitiesSnapshot;
online: boolean;
updatedAt: string;
source: TFroniusSnapshotSource;
error?: string;
}
export interface IFroniusRefreshResult {
success: boolean;
snapshot: IFroniusSnapshot;
error?: string;
data?: unknown;
}
export interface IFroniusManualEntry {
id?: string;
uniqueId?: string;
host?: string;
url?: string;
port?: number;
protocol?: TFroniusProtocol;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
isLogger?: boolean;
snapshot?: IFroniusSnapshot;
rawData?: Partial<IFroniusRawData>;
client?: IFroniusClientLike;
commandExecutor?: IFroniusCommandExecutor;
metadata?: Record<string, unknown>;
}
export interface IFroniusDhcpRecord {
id?: string;
ip?: string;
address?: string;
hostname?: string;
hostName?: string;
macaddress?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './fronius.classes.client.js';
export * from './fronius.classes.configflow.js';
export * from './fronius.classes.integration.js';
export * from './fronius.discovery.js';
export * from './fronius.mapper.js';
export * from './fronius.types.js';
+7 -13
View File
@@ -17,13 +17,11 @@ import { HomeAssistantAemetIntegration } from '../aemet/index.js';
import { HomeAssistantAepOhioIntegration } from '../aep_ohio/index.js';
import { HomeAssistantAepTexasIntegration } from '../aep_texas/index.js';
import { HomeAssistantAftershipIntegration } from '../aftership/index.js';
import { HomeAssistantAgentDvrIntegration } from '../agent_dvr/index.js';
import { HomeAssistantAiTaskIntegration } from '../ai_task/index.js';
import { HomeAssistantAirQualityIntegration } from '../air_quality/index.js';
import { HomeAssistantAirlyIntegration } from '../airly/index.js';
import { HomeAssistantAirnowIntegration } from '../airnow/index.js';
import { HomeAssistantAirobotIntegration } from '../airobot/index.js';
import { HomeAssistantAirosIntegration } from '../airos/index.js';
import { HomeAssistantAirpatrolIntegration } from '../airpatrol/index.js';
import { HomeAssistantAirqIntegration } from '../airq/index.js';
import { HomeAssistantAirthingsIntegration } from '../airthings/index.js';
@@ -159,7 +157,6 @@ import { HomeAssistantCasperGlowIntegration } from '../casper_glow/index.js';
import { HomeAssistantCcm15Integration } from '../ccm15/index.js';
import { HomeAssistantCertExpiryIntegration } from '../cert_expiry/index.js';
import { HomeAssistantChaconDioIntegration } from '../chacon_dio/index.js';
import { HomeAssistantChannelsIntegration } from '../channels/index.js';
import { HomeAssistantChessComIntegration } from '../chess_com/index.js';
import { HomeAssistantCiscoIosIntegration } from '../cisco_ios/index.js';
import { HomeAssistantCiscoMobilityExpressIntegration } from '../cisco_mobility_express/index.js';
@@ -217,7 +214,6 @@ import { HomeAssistantDecorquipIntegration } from '../decorquip/index.js';
import { HomeAssistantDefaultConfigIntegration } from '../default_config/index.js';
import { HomeAssistantDelijnIntegration } from '../delijn/index.js';
import { HomeAssistantDelmarvaIntegration } from '../delmarva/index.js';
import { HomeAssistantDelugeIntegration } from '../deluge/index.js';
import { HomeAssistantDemoIntegration } from '../demo/index.js';
import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js';
import { HomeAssistantDerivativeIntegration } from '../derivative/index.js';
@@ -284,7 +280,6 @@ import { HomeAssistantElkm1Integration } from '../elkm1/index.js';
import { HomeAssistantElmaxIntegration } from '../elmax/index.js';
import { HomeAssistantElvIntegration } from '../elv/index.js';
import { HomeAssistantElviaIntegration } from '../elvia/index.js';
import { HomeAssistantEmbyIntegration } from '../emby/index.js';
import { HomeAssistantEmoncmsIntegration } from '../emoncms/index.js';
import { HomeAssistantEmoncmsHistoryIntegration } from '../emoncms_history/index.js';
import { HomeAssistantEmonitorIntegration } from '../emonitor/index.js';
@@ -374,7 +369,6 @@ import { HomeAssistantFreshrIntegration } from '../freshr/index.js';
import { HomeAssistantFressnapfTrackerIntegration } from '../fressnapf_tracker/index.js';
import { HomeAssistantFritzboxIntegration } from '../fritzbox/index.js';
import { HomeAssistantFritzboxCallmonitorIntegration } from '../fritzbox_callmonitor/index.js';
import { HomeAssistantFroniusIntegration } from '../fronius/index.js';
import { HomeAssistantFrontendIntegration } from '../frontend/index.js';
import { HomeAssistantFujitsuAnywairIntegration } from '../fujitsu_anywair/index.js';
import { HomeAssistantFujitsuFglairIntegration } from '../fujitsu_fglair/index.js';
@@ -1383,13 +1377,11 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAepOhioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAepTexasIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAftershipIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAgentDvrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAiTaskIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirQualityIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirlyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirnowIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirobotIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirpatrolIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirqIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAirthingsIntegration());
@@ -1525,7 +1517,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantCasperGlowIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCcm15Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCertExpiryIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantChaconDioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantChannelsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantChessComIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCiscoIosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantCiscoMobilityExpressIntegration());
@@ -1583,7 +1574,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecorquipIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDefaultConfigIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelijnIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelmarvaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelugeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration());
@@ -1650,7 +1640,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantElkm1Integration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElmaxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElviaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmbyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmoncmsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmoncmsHistoryIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEmonitorIntegration());
@@ -1740,7 +1729,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreshrIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFressnapfTrackerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFritzboxCallmonitorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFrontendIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuAnywairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuFglairIntegration());
@@ -2732,10 +2720,12 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1364;
export const generatedHomeAssistantPortCount = 1358;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"agent_dvr",
"airgradient",
"airos",
"amcrest",
"android_ip_webcam",
"androidtv",
@@ -2753,8 +2743,10 @@ export const handwrittenHomeAssistantPortDomains = [
"broadlink",
"brother",
"cast",
"channels",
"daikin",
"deconz",
"deluge",
"denon",
"denonavr",
"devolo_home_network",
@@ -2766,10 +2758,12 @@ export const handwrittenHomeAssistantPortDomains = [
"dsmr",
"dunehd",
"elgato",
"emby",
"esphome",
"forked_daapd",
"foscam",
"fritz",
"fronius",
"frontier_silicon",
"fully_kiosk",
"glances",
+6
View File
@@ -1,7 +1,9 @@
// Generated by scripts/generate-homeassistant-ports.mjs. Do not edit manually.
export * from './generated/index.js';
export * from './adguard/index.js';
export * from './agent_dvr/index.js';
export * from './airgradient/index.js';
export * from './airos/index.js';
export * from './amcrest/index.js';
export * from './android_ip_webcam/index.js';
export * from './androidtv/index.js';
@@ -19,8 +21,10 @@ export * from './braviatv/index.js';
export * from './broadlink/index.js';
export * from './brother/index.js';
export * from './cast/index.js';
export * from './channels/index.js';
export * from './daikin/index.js';
export * from './deconz/index.js';
export * from './deluge/index.js';
export * from './denon/index.js';
export * from './denonavr/index.js';
export * from './devolo_home_network/index.js';
@@ -32,10 +36,12 @@ export * from './doorbird/index.js';
export * from './dsmr/index.js';
export * from './dunehd/index.js';
export * from './elgato/index.js';
export * from './emby/index.js';
export * from './esphome/index.js';
export * from './forked_daapd/index.js';
export * from './foscam/index.js';
export * from './fritz/index.js';
export * from './fronius/index.js';
export * from './frontier_silicon/index.js';
export * from './fully_kiosk/index.js';
export * from './glances/index.js';