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