Add native local appliance and energy integrations

This commit is contained in:
2026-05-07 16:51:28 +00:00
parent 7fc9c73ba6
commit b51d3fef2a
70 changed files with 12177 additions and 179 deletions
+12
View File
@@ -28,6 +28,7 @@ import { DenonIntegration } from './integrations/denon/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
import { DirectvIntegration } from './integrations/directv/index.js';
import { DlinkIntegration } from './integrations/dlink/index.js';
import { DlnaDmsIntegration } from './integrations/dlna_dms/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
import { DoorbirdIntegration } from './integrations/doorbird/index.js';
@@ -36,16 +37,21 @@ import { DunehdIntegration } from './integrations/dunehd/index.js';
import { ElgatoIntegration } from './integrations/elgato/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 { FullyKioskIntegration } from './integrations/fully_kiosk/index.js';
import { FritzIntegration } from './integrations/fritz/index.js';
import { GlancesIntegration } from './integrations/glances/index.js';
import { Go2rtcIntegration } from './integrations/go2rtc/index.js';
import { GoodweIntegration } from './integrations/goodwe/index.js';
import { HarmonyIntegration } from './integrations/harmony/index.js';
import { HeosIntegration } from './integrations/heos/index.js';
import { HikvisionIntegration } from './integrations/hikvision/index.js';
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
import { HomematicIntegration } from './integrations/homematic/index.js';
import { HomeWizardIntegration } from './integrations/homewizard/index.js';
import { HuaweiLteIntegration } from './integrations/huawei_lte/index.js';
import { HunterDouglasPowerViewIntegration } from './integrations/hunterdouglas_powerview/index.js';
import { HyperionIntegration } from './integrations/hyperion/index.js';
import { IppIntegration } from './integrations/ipp/index.js';
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
@@ -120,6 +126,7 @@ export const integrations = [
new DenonavrIntegration(),
new DevoloHomeNetworkIntegration(),
new DirectvIntegration(),
new DlinkIntegration(),
new DlnaDmsIntegration(),
new DlnaDmrIntegration(),
new DoorbirdIntegration(),
@@ -128,16 +135,21 @@ export const integrations = [
new ElgatoIntegration(),
new EsphomeIntegration(),
new ForkedDaapdIntegration(),
new FoscamIntegration(),
new FrontierSiliconIntegration(),
new FullyKioskIntegration(),
new FritzIntegration(),
new GlancesIntegration(),
new Go2rtcIntegration(),
new GoodweIntegration(),
new HarmonyIntegration(),
new HeosIntegration(),
new HikvisionIntegration(),
new HomekitControllerIntegration(),
new HomematicIntegration(),
new HomeWizardIntegration(),
new HuaweiLteIntegration(),
new HunterDouglasPowerViewIntegration(),
new HyperionIntegration(),
new HueIntegration(),
new IppIntegration(),
@@ -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,464 @@
import * as crypto from 'node:crypto';
import { DlinkMapper } from './dlink.mapper.js';
import { dlinkDefaultPort, dlinkDefaultTimeoutMs, dlinkDefaultUsername, dlinkDomain } from './dlink.constants.js';
import type { IDlinkCommand, IDlinkCommandResult, IDlinkConfig, IDlinkEvent, IDlinkNativeClient, IDlinkSnapshot, TDlinkPowerState } from './dlink.types.js';
type TDlinkEventHandler = (eventArg: IDlinkEvent) => void;
type TDlinkAuthSession = { privateKey: string; cookie: string };
export class DlinkClient {
private snapshot?: IDlinkSnapshot;
private readonly events: IDlinkEvent[] = [];
private readonly eventHandlers = new Set<TDlinkEventHandler>();
constructor(private readonly config: IDlinkConfig) {}
public async getSnapshot(): Promise<IDlinkSnapshot> {
if (!this.snapshot) {
const result = await this.refresh();
if (!result.success && result.data && typeof result.data === 'object' && 'snapshot' in result.data) {
return this.cloneSnapshot((result.data as { snapshot: IDlinkSnapshot }).snapshot);
}
}
return this.cloneSnapshot(this.snapshot || DlinkMapper.toSnapshot(this.config, {}, this.events));
}
public onEvent(handlerArg: TDlinkEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async refresh(): Promise<IDlinkCommandResult> {
const base = DlinkMapper.toSnapshot(this.config, {}, this.events);
try {
const snapshot = await this.readSnapshot(base);
this.snapshot = snapshot;
this.emit({ type: 'snapshot_refreshed', data: this.cloneSnapshot(snapshot), timestamp: Date.now() });
return { success: snapshot.connected, transmitted: false, data: { snapshot: this.cloneSnapshot(snapshot) } };
} catch (errorArg) {
const snapshot = DlinkMapper.mergeSnapshot(base, {
connected: false,
switch: { ...base.switch, state: 'unknown', available: false },
device: { ...base.device, available: false },
metadata: { ...base.metadata, lastError: errorArg instanceof Error ? errorArg.message : String(errorArg) },
});
this.snapshot = snapshot;
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
this.emit({ type: 'command_failed', data: { snapshot: this.cloneSnapshot(snapshot) }, error, timestamp: Date.now() });
return { success: false, transmitted: false, error, data: { snapshot: this.cloneSnapshot(snapshot) } };
}
}
public async sendCommand(commandArg: IDlinkCommand): Promise<IDlinkCommandResult> {
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: IDlinkCommandResult = { success: false, transmitted: false, error: errorArg instanceof Error ? errorArg.message : String(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 destroy(): Promise<void> {
this.eventHandlers.clear();
}
private async readSnapshot(baseArg: IDlinkSnapshot): Promise<IDlinkSnapshot> {
if (this.config.transport?.readSnapshot) {
return DlinkMapper.mergeSnapshot(baseArg, await this.config.transport.readSnapshot());
}
if (this.config.nativeClient) {
return this.readNativeClientSnapshot(baseArg, this.config.nativeClient);
}
if (this.canUseNativeHttp()) {
const snapshot = await this.hnapClient().readSnapshot();
return DlinkMapper.mergeSnapshot(baseArg, snapshot);
}
return baseArg;
}
private async readNativeClientSnapshot(baseArg: IDlinkSnapshot, clientArg: IDlinkNativeClient): Promise<IDlinkSnapshot> {
if (clientArg.getSnapshot) {
return DlinkMapper.mergeSnapshot(baseArg, await clientArg.getSnapshot());
}
const authenticated = clientArg.authenticate ? await clientArg.authenticate() : undefined;
const state = clientArg.getState ? DlinkMapper.normalizeState(await clientArg.getState()) : baseArg.switch.state;
const [modelName, temperature, currentConsumption, totalConsumption] = await Promise.all([
clientArg.getModelName?.(),
clientArg.getTemperature?.(),
clientArg.getCurrentConsumption?.(),
clientArg.getTotalConsumption?.(),
]);
const available = state !== 'unknown' || authenticated === true;
return DlinkMapper.mergeSnapshot(baseArg, {
connected: available,
device: { modelName, authenticated, available },
switch: {
...baseArg.switch,
state,
available,
temperature: DlinkMapper.numberValue(temperature) ?? null,
temperatureRaw: stringValue(temperature),
currentConsumption: DlinkMapper.numberValue(currentConsumption) ?? null,
currentConsumptionRaw: stringValue(currentConsumption),
totalConsumption: DlinkMapper.numberValue(totalConsumption) ?? null,
totalConsumptionRaw: stringValue(totalConsumption),
},
updatedAt: new Date().toISOString(),
});
}
private async executeCommand(commandArg: IDlinkCommand): Promise<IDlinkCommandResult> {
const executor = this.config.commandExecutor || this.config.transport?.execute.bind(this.config.transport);
if (executor) {
return this.commandResult(await executor({ ...commandArg, method: 'executor' }), commandArg, true);
}
if (this.config.nativeClient?.setState) {
return this.commandResult(await this.config.nativeClient.setState(commandArg.state), { ...commandArg, method: 'native_client' }, true);
}
if (this.canUseNativeHttp()) {
const response = await this.hnapClient().setState(commandArg.state);
if (response === undefined) {
return {
success: false,
transmitted: false,
state: commandArg.state,
error: 'D-Link HNAP SetSocketSettings did not return a result.',
data: { command: commandArg },
};
}
return this.commandResult(response, commandArg, true);
}
return {
success: false,
transmitted: false,
error: 'D-Link switch commands require host/password native HNAP HTTP, nativeClient.setState, commandExecutor, or transport.execute.',
data: { command: commandArg },
};
}
private commandResult(resultArg: unknown, commandArg: IDlinkCommand, transmittedDefaultArg: boolean): IDlinkCommandResult {
if (this.isCommandResult(resultArg)) {
return { transmitted: transmittedDefaultArg, state: commandArg.state, ...resultArg };
}
if (resultArg === false) {
return { success: false, transmitted: transmittedDefaultArg, state: commandArg.state, error: 'D-Link command executor reported failure.', data: { command: commandArg, response: resultArg } };
}
if (typeof resultArg === 'string' && resultArg.trim().toLowerCase() === 'error') {
return { success: false, transmitted: transmittedDefaultArg, state: commandArg.state, error: 'D-Link HNAP SetSocketSettings returned error.', data: { command: commandArg, response: resultArg } };
}
return { success: true, transmitted: transmittedDefaultArg, state: commandArg.state, data: { command: commandArg, response: resultArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IDlinkCommandResult {
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
}
private patchSnapshot(commandArg: IDlinkCommand, resultArg: IDlinkCommandResult): void {
const current = this.snapshot || DlinkMapper.toSnapshot(this.config, {}, this.events);
const snapshot = DlinkMapper.mergeSnapshot(current, {
connected: true,
device: { available: true },
switch: { ...current.switch, state: resultArg.state || commandArg.state, available: true },
updatedAt: new Date().toISOString(),
});
this.snapshot = snapshot;
this.emit({ type: 'state_changed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: this.cloneSnapshot(snapshot), timestamp: Date.now() });
}
private canUseNativeHttp(): boolean {
return this.config.nativeHttpEnabled !== false && Boolean(this.config.host && this.config.password);
}
private hnapClient(): DlinkHnapSmartPlugClient {
return new DlinkHnapSmartPlugClient({
host: this.config.host as string,
port: this.config.port || dlinkDefaultPort,
username: this.config.username || dlinkDefaultUsername,
password: this.config.password as string,
useLegacyProtocol: this.config.useLegacyProtocol ?? this.config.use_legacy_protocol ?? false,
timeoutMs: this.config.timeoutMs || dlinkDefaultTimeoutMs,
});
}
private emit(eventArg: IDlinkEvent): void {
this.events.push(eventArg);
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private cloneSnapshot(snapshotArg: IDlinkSnapshot): IDlinkSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IDlinkSnapshot;
}
}
export class DlinkHnapSmartPlugClient implements IDlinkNativeClient {
private authenticated?: TDlinkAuthSession;
constructor(private readonly options: {
host: string;
port?: number;
username?: string;
password: string;
useLegacyProtocol?: boolean;
timeoutMs?: number;
}) {}
public async authenticate(): Promise<boolean> {
this.authenticated = await this.authenticateInternal();
return Boolean(this.authenticated);
}
public async getSnapshot(): Promise<IDlinkSnapshot> {
return this.readSnapshot();
}
public async readSnapshot(): Promise<IDlinkSnapshot> {
const modelName = await this.getModelName().catch(() => undefined);
const state = await this.getState();
const available = state !== 'unknown';
const [temperature, currentConsumption, totalConsumption] = available ? await Promise.all([
this.getTemperature().catch(() => undefined),
this.getCurrentConsumption().catch(() => undefined),
this.getTotalConsumption().catch(() => undefined),
]) : [undefined, undefined, undefined];
const updatedAt = new Date().toISOString();
return {
connected: available,
device: {
host: this.options.host,
port: this.options.port || dlinkDefaultPort,
name: modelName,
modelName,
useLegacyProtocol: this.options.useLegacyProtocol ?? false,
authenticated: Boolean(this.authenticated),
available,
},
switch: {
state,
available,
temperature: DlinkMapper.numberValue(temperature) ?? null,
temperatureRaw: stringValue(temperature),
currentConsumption: DlinkMapper.numberValue(currentConsumption) ?? null,
currentConsumptionRaw: stringValue(currentConsumption),
totalConsumption: DlinkMapper.numberValue(totalConsumption) ?? null,
totalConsumptionRaw: stringValue(totalConsumption),
},
updatedAt,
events: [],
metadata: {
nativeHttpImplemented: true,
source: 'hnap',
},
};
}
public async getModelName(): Promise<string | undefined> {
return this.soapAction('GetDeviceSettings', 'ModelName');
}
public async getState(): Promise<TDlinkPowerState> {
const response = await this.soapAction('GetSocketSettings', 'OPStatus', this.moduleParameters('1'));
return DlinkMapper.normalizeState(response);
}
public async setState(stateArg: Exclude<TDlinkPowerState, 'unknown'>): Promise<string | undefined> {
return this.soapAction('SetSocketSettings', 'SetSocketSettingsResult', this.controlParameters('1', stateArg === 'ON' ? 'true' : 'false'));
}
public async getTemperature(): Promise<string | undefined> {
return this.soapAction('GetCurrentTemperature', 'CurrentTemperature', this.moduleParameters('3'));
}
public async getCurrentConsumption(): Promise<string | undefined> {
if (this.options.useLegacyProtocol) {
const legacyValues = await this.fetchLegacyCgi();
return legacyValues['Meter Watt'] || 'N/A';
}
return this.soapAction('GetCurrentPowerConsumption', 'CurrentConsumption', this.moduleParameters('2'));
}
public async getTotalConsumption(): Promise<string | undefined> {
if (this.options.useLegacyProtocol) {
return 'N/A';
}
return this.soapAction('GetPMWarningThreshold', 'TotalConsumption', this.moduleParameters('2'));
}
private moduleParameters(moduleArg: string): string {
return `<ModuleID>${escapeXml(moduleArg)}</ModuleID>`;
}
private controlParameters(moduleArg: string, statusArg: 'true' | 'false'): string {
const base = `${this.moduleParameters(moduleArg)}<NickName>Socket 1</NickName><Description>Socket 1</Description><OPStatus>${statusArg}</OPStatus>`;
return this.options.useLegacyProtocol ? `${base}<Controller>1</Controller>` : base;
}
private async soapAction(actionArg: string, responseElementArg: string, paramsArg = '', recursiveArg = false): Promise<string | undefined> {
if (!this.authenticated) {
this.authenticated = await this.authenticateInternal();
}
const auth = this.authenticated;
if (!this.options.useLegacyProtocol) {
this.authenticated = undefined;
}
if (!auth) {
return undefined;
}
const timestamp = hnapTimestamp();
const actionUrl = `"http://purenetworks.com/HNAP1/${actionArg}"`;
const authKey = `${hmacMd5(auth.privateKey, `${timestamp}${actionUrl}`).toUpperCase()} ${timestamp}`;
try {
const xml = await this.post('/HNAP1/', requestBody(actionArg, paramsArg), {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: actionUrl,
HNAP_AUTH: authKey,
Cookie: `uid=${auth.cookie}`,
});
return extractXmlElement(xml, responseElementArg);
} catch (errorArg) {
if (recursiveArg) {
throw errorArg;
}
this.authenticated = undefined;
return this.soapAction(actionArg, responseElementArg, paramsArg, true);
}
}
private async authenticateInternal(): Promise<TDlinkAuthSession | undefined> {
const initialXml = await this.post('/HNAP1/', initialAuthPayload(dlinkDefaultUsername), {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://purenetworks.com/HNAP1/Login"',
});
const challenge = extractXmlElement(initialXml, 'Challenge');
const cookie = extractXmlElement(initialXml, 'Cookie');
const publicKey = extractXmlElement(initialXml, 'PublicKey');
if (!challenge || !cookie || !publicKey) {
return undefined;
}
const privateKey = hmacMd5(`${publicKey}${this.options.password}`, challenge).toUpperCase();
const loginPassword = hmacMd5(privateKey, challenge).toUpperCase();
const loginXml = await this.post('/HNAP1/', loginPayload(this.options.username || dlinkDefaultUsername, loginPassword), {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://purenetworks.com/HNAP1/Login"',
HNAP_AUTH: `"${privateKey}"`,
Cookie: `uid=${cookie}`,
});
const loginStatus = extractXmlElement(loginXml, 'LoginResult');
return loginStatus?.toLowerCase() === 'success' ? { privateKey, cookie } : undefined;
}
private async fetchLegacyCgi(): Promise<Record<string, string>> {
const text = await this.post('/my_cgi.cgi', 'request=create_chklst', { 'Content-Type': 'application/x-www-form-urlencoded' });
const result: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) {
continue;
}
result[line.slice(0, separatorIndex).trim()] = line.slice(separatorIndex + 1).trim();
}
return result;
}
private async post(pathArg: string, bodyArg: string, headersArg: Record<string, string>): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs || dlinkDefaultTimeoutMs);
try {
const response = await fetch(this.url(pathArg), {
method: 'POST',
headers: headersArg,
body: bodyArg,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`D-Link HTTP ${response.status} for ${pathArg}`);
}
return await response.text();
} finally {
clearTimeout(timer);
}
}
private url(pathArg: string): string {
const base = /^https?:\/\//i.test(this.options.host) ? new URL(this.options.host) : new URL(`http://${this.options.host}`);
if (!/^https?:\/\//i.test(this.options.host) && this.options.port && this.options.port !== dlinkDefaultPort) {
base.port = String(this.options.port);
}
base.pathname = pathArg;
base.search = '';
return base.toString();
}
}
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 hnapTimestamp = (): string => String(Math.round(Date.now() / 1000 / 1_000_000));
const hmacMd5 = (keyArg: string, valueArg: string): string => {
return crypto.createHmac('md5', keyArg).update(valueArg).digest('hex');
};
const requestBody = (actionArg: string, paramsArg: string): string => `<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><${actionArg} xmlns="http://purenetworks.com/HNAP1/">${paramsArg}</${actionArg}></soap:Body>
</soap:Envelope>`;
const initialAuthPayload = (usernameArg: string): string => `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>request</Action><Username>${escapeXml(usernameArg)}</Username><LoginPassword/><Captcha/></Login></soap:Body>
</soap:Envelope>`;
const loginPayload = (usernameArg: string, loginPasswordArg: string): string => `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>login</Action><Username>${escapeXml(usernameArg)}</Username><LoginPassword>${escapeXml(loginPasswordArg)}</LoginPassword><Captcha/></Login></soap:Body>
</soap:Envelope>`;
const extractXmlElement = (xmlArg: string, elementArg: string): string | undefined => {
const escapedElement = elementArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`<(?:[\\w.-]+:)?${escapedElement}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${escapedElement}>`, 'i');
const match = regex.exec(xmlArg);
return match ? unescapeXml(match[1].trim()) : undefined;
};
const escapeXml = (valueArg: string): string => {
return valueArg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
};
const unescapeXml = (valueArg: string): string => {
return valueArg.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
};
export const dlinkTestInternals = {
domain: dlinkDomain,
extractXmlElement,
hmacMd5,
hnapTimestamp,
};
@@ -0,0 +1,144 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { dlinkDefaultName, dlinkDefaultPort, dlinkDefaultTimeoutMs, dlinkDefaultUsername } from './dlink.constants.js';
import type { IDlinkConfig, IDlinkSnapshot } from './dlink.types.js';
export class DlinkConfigFlow implements IConfigFlow<IDlinkConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDlinkConfig>> {
void contextArg;
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IDlinkSnapshot | undefined;
const host = candidateArg.host || this.stringValue(metadata.host) || snapshot?.device.host || '';
const username = this.stringValue(metadata.username) || dlinkDefaultUsername;
const useLegacyProtocol = this.booleanValue(metadata.useLegacyProtocol ?? metadata.use_legacy_protocol) ?? false;
return {
kind: 'form',
title: candidateArg.source === 'dhcp' ? 'Confirm D-Link smart plug' : 'Connect D-Link smart plug',
description: 'Provide local D-Link smart plug credentials. Native HNAP HTTP is used for live reads and switch commands unless an injected client/executor is configured.',
fields: [
{ name: 'host', label: host ? `Host (${host})` : 'Host', type: 'text', required: !snapshot },
{ name: 'username', label: `Username (${username})`, type: 'text' },
{ name: 'password', label: 'Password', type: 'password', required: !snapshot },
{ name: 'useLegacyProtocol', label: `Use legacy protocol (${useLegacyProtocol ? 'yes' : 'no'})`, type: 'boolean' },
{ name: 'nativeHttpEnabled', label: 'Enable native local HNAP HTTP', type: 'boolean' },
{ name: 'name', label: candidateArg.name ? `Name (${candidateArg.name})` : 'Name', type: 'text' },
{ name: 'port', label: `Port (${candidateArg.port || snapshot?.device.port || dlinkDefaultPort})`, type: 'number' },
{ name: 'timeoutMs', label: `Timeout ms (${dlinkDefaultTimeoutMs})`, type: 'number' },
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDlinkConfig>> {
const metadata = candidateArg.metadata || {};
const candidateSnapshot = metadata.snapshot as IDlinkSnapshot | undefined;
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson) || candidateSnapshot;
if (snapshot instanceof Error) {
return { kind: 'error', title: 'Invalid D-Link snapshot', error: snapshot.message };
}
const host = this.stringValue(valuesArg.host) || candidateArg.host || this.stringValue(metadata.host) || snapshot?.device.host;
if (!host && !snapshot) {
return { kind: 'error', title: 'D-Link setup failed', error: 'D-Link setup requires a host unless a snapshot is provided.' };
}
const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password);
if (!password && !snapshot) {
return { kind: 'error', title: 'D-Link setup failed', error: 'D-Link setup requires a password unless a snapshot is provided.' };
}
const port = this.numberValue(valuesArg.port) ?? candidateArg.port ?? snapshot?.device.port ?? dlinkDefaultPort;
if (port < 1 || port > 65535) {
return { kind: 'error', title: 'D-Link setup failed', error: 'Port must be between 1 and 65535.' };
}
const timeoutMs = this.numberValue(valuesArg.timeoutMs) ?? dlinkDefaultTimeoutMs;
if (timeoutMs < 1) {
return { kind: 'error', title: 'D-Link setup failed', error: 'Timeout must be greater than zero.' };
}
const config: IDlinkConfig = {
id: candidateArg.id || candidateArg.macAddress || snapshot?.device.id || host,
host,
port,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || dlinkDefaultName,
username: this.stringValue(valuesArg.username) || this.stringValue(metadata.username) || dlinkDefaultUsername,
password,
useLegacyProtocol: this.booleanValue(valuesArg.useLegacyProtocol) ?? this.booleanValue(metadata.useLegacyProtocol ?? metadata.use_legacy_protocol) ?? snapshot?.device.useLegacyProtocol ?? false,
nativeHttpEnabled: this.booleanValue(valuesArg.nativeHttpEnabled) ?? true,
timeoutMs,
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer,
model: candidateArg.model || snapshot?.device.modelName,
macAddress: candidateArg.macAddress || snapshot?.device.macAddress,
snapshot,
metadata: {
discoverySource: candidateArg.source,
discoveryMetadata: metadata,
nativeHttpImplemented: true,
configFlowConnectionVerified: false,
},
};
return {
kind: 'done',
title: 'D-Link smart plug configured',
config,
};
}
private snapshotFromInput(valueArg: unknown): IDlinkSnapshot | undefined | Error {
if (valueArg && typeof valueArg === 'object') {
const snapshot = valueArg as IDlinkSnapshot;
return this.validateSnapshot(snapshot);
}
const text = this.stringValue(valueArg);
if (!text) {
return undefined;
}
try {
const parsed = JSON.parse(text) as IDlinkSnapshot;
return this.validateSnapshot(parsed);
} catch (errorArg) {
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
}
}
private validateSnapshot(snapshotArg: IDlinkSnapshot): IDlinkSnapshot | Error {
if (!snapshotArg || typeof snapshotArg !== 'object' || !snapshotArg.device || !snapshotArg.switch) {
return new Error('Snapshot JSON must include device and switch objects.');
}
return snapshotArg;
}
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,26 +1,102 @@
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 { DlinkClient } from './dlink.classes.client.js';
import { DlinkConfigFlow } from './dlink.classes.configflow.js';
import { dlinkDefaultName, dlinkDomain } from './dlink.constants.js';
import { createDlinkDiscoveryDescriptor } from './dlink.discovery.js';
import { DlinkMapper } from './dlink.mapper.js';
import type { IDlinkConfig } from './dlink.types.js';
export class HomeAssistantDlinkIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "dlink",
displayName: "D-Link Wi-Fi Smart Plugs",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/dlink",
"upstreamDomain": "dlink",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pyW215==0.8.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@tkdrob"
]
},
});
export class DlinkIntegration extends BaseIntegration<IDlinkConfig> {
public readonly domain = dlinkDomain;
public readonly displayName = 'D-Link Wi-Fi Smart Plugs';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDlinkDiscoveryDescriptor();
public readonly configFlow = new DlinkConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/dlink',
upstreamDomain: dlinkDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['pyW215==0.8.0'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@tkdrob'],
documentation: 'https://www.home-assistant.io/integrations/dlink',
configFlow: true,
discovery: {
dhcp: ['hostname:dsp-w215'],
manual: true,
note: 'Home Assistant discovers dlink by DHCP hostname dsp-w215. This native port recognizes DHCP and manual host/snapshot candidates.',
},
runtime: {
type: 'control-runtime',
polling: 'local HNAP HTTP or supplied snapshot/nativeClient',
platforms: ['switch'],
services: ['switch.turn_on', 'switch.turn_off', 'dlink.set_state', 'dlink.refresh', 'dlink.snapshot'],
},
localApi: {
implemented: [
'DHCP hostname discovery for dsp-w215 and manual host/snapshot discovery',
'Config flow shape for host, username, password, legacy protocol, native HTTP, port, timeout, and snapshot JSON',
'Native HNAP SOAP authentication, switch state reads, switch commands, temperature, current consumption, and total consumption where firmware exposes them',
'Legacy my_cgi.cgi current-consumption read path used by pyW215',
'Snapshot-to-device/entity mapper for the Home Assistant smart plug switch representation',
],
explicitUnsupported: [
'D-Link cloud APIs',
'DSP-W218 and newer devices using a different protocol than pyW215',
'Claiming live command success without host/password native HTTP, nativeClient.setState, commandExecutor, or transport.execute',
],
},
};
public async setup(configArg: IDlinkConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DlinkRuntime(new DlinkClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDlinkIntegration extends DlinkIntegration {}
class DlinkRuntime implements IIntegrationRuntime {
public domain = dlinkDomain;
constructor(private readonly client: DlinkClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DlinkMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DlinkMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(DlinkMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.domain === dlinkDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === dlinkDomain && requestArg.service === 'refresh') {
return this.client.refresh();
}
const snapshot = await this.client.getSnapshot();
const command = DlinkMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported D-Link service or target: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
+9
View File
@@ -0,0 +1,9 @@
export const dlinkDomain = 'dlink';
export const dlinkDefaultName = 'D-Link Smart Plug W215';
export const dlinkDefaultUsername = 'admin';
export const dlinkDefaultPort = 80;
export const dlinkDefaultTimeoutMs = 5000;
export const dlinkManufacturer = 'D-Link';
export const dlinkAttribution = 'Data provided by D-Link';
export const dlinkDhcpHostnames = new Set(['dsp-w215']);
export const dlinkSupportedModels = new Set(['dsp-w215', 'dsp-w110', 'w215', 'w110']);
+198
View File
@@ -0,0 +1,198 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { dlinkDefaultName, dlinkDefaultPort, dlinkDhcpHostnames, dlinkDomain, dlinkManufacturer, dlinkSupportedModels } from './dlink.constants.js';
import type { IDlinkCandidateMetadata, IDlinkDhcpRecord, IDlinkManualEntry } from './dlink.types.js';
export class DlinkDhcpMatcher implements IDiscoveryMatcher<IDlinkDhcpRecord> {
public id = 'dlink-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize D-Link W215 DHCP leases using the Home Assistant manifest hostname matcher.';
public async matches(recordArg: IDlinkDhcpRecord): 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 model = recordArg.model || stringValue(metadata.model) || stringValue(metadata.modelName);
const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || stringValue(metadata.macAddress));
const normalizedHostname = normalizeHostname(hostname);
const hostnameMatched = dlinkDhcpHostnames.has(normalizedHostname) || [...dlinkDhcpHostnames].some((hostnameArg) => normalizedHostname.startsWith(`${hostnameArg}-`));
const modelMatched = isDlinkModel(model);
const textMatched = dlinkText(recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer).includes('d-link')
|| dlinkText(recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer).includes('dlink');
const matched = recordArg.integrationDomain === dlinkDomain || metadata.dlink === true || hostnameMatched || modelMatched || textMatched;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP record does not match the D-Link W215 hostname or metadata.' };
}
const id = macAddress || hostname || host;
return {
matched: true,
confidence: hostnameMatched && host ? 'certain' : host ? 'high' : 'medium',
reason: hostnameMatched ? 'DHCP hostname matches the Home Assistant dlink manifest rule dsp-w215.' : 'DHCP metadata identifies a D-Link smart plug.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: dlinkDomain,
id,
host,
port: dlinkDefaultPort,
name: hostname || dlinkDefaultName,
manufacturer: recordArg.manufacturer || dlinkManufacturer,
model: model || dlinkDefaultName,
macAddress,
metadata: {
...metadata,
dlink: true,
discoveryProtocol: 'dhcp',
hostname,
hostnameMatched,
useLegacyProtocol: booleanValue(metadata.useLegacyProtocol) ?? booleanValue(metadata.use_legacy_protocol) ?? false,
nativeHttpImplemented: true,
} satisfies IDlinkCandidateMetadata,
},
metadata: { hostnameMatched, modelMatched },
};
}
}
export class DlinkManualMatcher implements IDiscoveryMatcher<IDlinkManualEntry> {
public id = 'dlink-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual D-Link smart plug host, credential, and snapshot setup entries.';
public async matches(inputArg: IDlinkManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot as IDlinkManualEntry['snapshot'];
const host = inputArg.host || inputArg.ipAddress || inputArg.address || inputArg.ip || snapshot?.device.host || stringValue(metadata.host);
const model = inputArg.modelName || inputArg.model || snapshot?.device.modelName || stringValue(metadata.modelName) || stringValue(metadata.model);
const macAddress = normalizeMac(inputArg.macAddress || inputArg.mac || snapshot?.device.macAddress || stringValue(metadata.macAddress));
const hasCredentials = Boolean(inputArg.password || metadata.password);
const modelMatched = isDlinkModel(model);
const textMatched = dlinkText(inputArg.integrationDomain, inputArg.manufacturer, model, inputArg.name, metadata.brand, metadata.manufacturer).includes('d-link')
|| dlinkText(inputArg.integrationDomain, inputArg.manufacturer, model, inputArg.name, metadata.brand, metadata.manufacturer).includes('dlink');
const matched = inputArg.integrationDomain === dlinkDomain
|| metadata.dlink === true
|| Boolean(snapshot)
|| modelMatched
|| textMatched
|| Boolean(host && hasCredentials);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain D-Link smart plug setup data.' };
}
const id = inputArg.id || macAddress || snapshot?.device.id || host;
return {
matched: true,
confidence: snapshot ? 'certain' : host && hasCredentials ? 'high' : host ? 'medium' : 'low',
reason: snapshot ? 'Manual entry includes a D-Link smart plug snapshot.' : 'Manual entry can start D-Link smart plug setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: dlinkDomain,
id,
host,
port: inputArg.port || snapshot?.device.port || dlinkDefaultPort,
name: inputArg.name || snapshot?.device.name || dlinkDefaultName,
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || dlinkManufacturer,
model: model || dlinkDefaultName,
macAddress,
metadata: {
...metadata,
dlink: true,
discoveryProtocol: 'manual',
username: inputArg.username || stringValue(metadata.username),
useLegacyProtocol: booleanValue(inputArg.useLegacyProtocol ?? inputArg.use_legacy_protocol ?? metadata.useLegacyProtocol ?? metadata.use_legacy_protocol) ?? false,
snapshot,
nativeHttpImplemented: true,
} satisfies IDlinkCandidateMetadata,
},
metadata: { snapshotConfigured: Boolean(snapshot), credentialsConfigured: hasCredentials },
};
}
}
export class DlinkCandidateValidator implements IDiscoveryValidator {
public id = 'dlink-candidate-validator';
public description = 'Validate D-Link candidates from DHCP and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = metadata.snapshot as IDlinkManualEntry['snapshot'];
const hostname = stringValue(metadata.hostname) || candidateArg.name;
const hostnameMatched = dlinkDhcpHostnames.has(normalizeHostname(hostname));
const modelMatched = isDlinkModel(candidateArg.model || stringValue(metadata.modelName) || stringValue(metadata.model));
const textMatched = dlinkText(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer).includes('d-link')
|| dlinkText(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer).includes('dlink');
const matched = candidateArg.integrationDomain === dlinkDomain || metadata.dlink === true || Boolean(snapshot) || hostnameMatched || modelMatched || textMatched;
const usable = Boolean(candidateArg.host || snapshot?.device.host || snapshot);
if (!matched || !usable) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'D-Link candidate lacks a host or snapshot.' : 'Candidate is not D-Link.',
};
}
return {
matched: true,
confidence: hostnameMatched && candidateArg.host ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has D-Link smart plug metadata and a usable host or snapshot.',
candidate: { ...candidateArg, port: candidateArg.port || dlinkDefaultPort },
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host || snapshot?.device.id,
metadata: { hostnameMatched, modelMatched, snapshotConfigured: Boolean(snapshot) },
};
}
}
export const createDlinkDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: dlinkDomain, displayName: dlinkDefaultName })
.addMatcher(new DlinkDhcpMatcher())
.addMatcher(new DlinkManualMatcher())
.addValidator(new DlinkCandidateValidator());
};
export const normalizeDlinkMac = (valueArg?: string): string | undefined => normalizeMac(valueArg);
const dlinkText = (...valuesArg: unknown[]): string => {
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
};
const normalizeHostname = (valueArg?: string): string => {
return (valueArg || '').trim().toLowerCase().replace(/\.local\.?$/, '');
};
const isDlinkModel = (valueArg?: string): boolean => {
const normalized = (valueArg || '').trim().toLowerCase();
return dlinkSupportedModels.has(normalized) || [...dlinkSupportedModels].some((modelArg) => normalized.includes(modelArg));
};
const normalizeMac = (valueArg?: string): 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 stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : 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;
};
+288
View File
@@ -0,0 +1,288 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js';
import { dlinkAttribution, dlinkDefaultName, dlinkDefaultPort, dlinkDefaultUsername, dlinkDomain, dlinkManufacturer } from './dlink.constants.js';
import type { IDlinkCommand, IDlinkConfig, IDlinkEvent, IDlinkSnapshot, TDlinkPowerState } from './dlink.types.js';
export class DlinkMapper {
public static toSnapshot(configArg: IDlinkConfig, overridesArg: Partial<IDlinkSnapshot> = {}, eventsArg: IDlinkEvent[] = []): IDlinkSnapshot {
const source = configArg.snapshot;
const sourceSwitch = source?.switch;
const sourceDevice = source?.device;
const updatedAt = overridesArg.updatedAt || source?.updatedAt || new Date().toISOString();
const host = configArg.host || configArg.ipAddress || configArg.address || configArg.ip || sourceDevice?.host;
const port = configArg.port || sourceDevice?.port || dlinkDefaultPort;
const state = this.normalizeState(overridesArg.switch?.state ?? configArg.state ?? sourceSwitch?.state);
const hasRepresentedState = state !== 'unknown';
const available = overridesArg.switch?.available ?? sourceSwitch?.available ?? configArg.connected ?? hasRepresentedState;
const connected = overridesArg.connected ?? configArg.connected ?? source?.connected ?? available;
const temperatureRaw = this.stringValue(overridesArg.switch?.temperatureRaw) ?? this.stringValue(configArg.temperature) ?? sourceSwitch?.temperatureRaw;
const currentConsumptionRaw = this.stringValue(overridesArg.switch?.currentConsumptionRaw) ?? this.stringValue(configArg.currentConsumption) ?? sourceSwitch?.currentConsumptionRaw;
const totalConsumptionRaw = this.stringValue(overridesArg.switch?.totalConsumptionRaw) ?? this.stringValue(configArg.totalConsumption) ?? sourceSwitch?.totalConsumptionRaw;
const deviceId = configArg.id || sourceDevice?.id || configArg.macAddress || configArg.mac || host || 'dlink-smart-plug';
return {
connected,
device: {
id: deviceId,
name: overridesArg.device?.name || configArg.name || sourceDevice?.name || dlinkDefaultName,
host,
port,
macAddress: configArg.macAddress || configArg.mac || sourceDevice?.macAddress,
manufacturer: configArg.manufacturer || sourceDevice?.manufacturer || dlinkManufacturer,
modelName: overridesArg.device?.modelName || configArg.modelName || configArg.model || sourceDevice?.modelName || dlinkDefaultName,
useLegacyProtocol: overridesArg.device?.useLegacyProtocol ?? configArg.useLegacyProtocol ?? configArg.use_legacy_protocol ?? sourceDevice?.useLegacyProtocol ?? false,
authenticated: overridesArg.device?.authenticated ?? sourceDevice?.authenticated,
available: overridesArg.device?.available ?? connected,
metadata: this.cleanAttributes({
...sourceDevice?.metadata,
...configArg.metadata,
...overridesArg.device?.metadata,
username: configArg.username || dlinkDefaultUsername,
}),
},
switch: {
state,
available,
temperature: this.numberValue(overridesArg.switch?.temperature) ?? this.numberValue(configArg.temperature) ?? sourceSwitch?.temperature ?? this.numberValue(temperatureRaw) ?? null,
temperatureRaw,
currentConsumption: this.numberValue(overridesArg.switch?.currentConsumption) ?? this.numberValue(configArg.currentConsumption) ?? sourceSwitch?.currentConsumption ?? this.numberValue(currentConsumptionRaw) ?? null,
currentConsumptionRaw,
totalConsumption: this.numberValue(overridesArg.switch?.totalConsumption) ?? this.numberValue(configArg.totalConsumption) ?? sourceSwitch?.totalConsumption ?? this.numberValue(totalConsumptionRaw) ?? null,
totalConsumptionRaw,
attributes: this.cleanAttributes({
...sourceSwitch?.attributes,
...overridesArg.switch?.attributes,
}),
},
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),
}),
};
}
public static mergeSnapshot(baseArg: IDlinkSnapshot, patchArg: Partial<IDlinkSnapshot>): IDlinkSnapshot {
return {
...baseArg,
...patchArg,
device: {
...baseArg.device,
...patchArg.device,
metadata: this.cleanAttributes({ ...baseArg.device.metadata, ...patchArg.device?.metadata }),
},
switch: {
...baseArg.switch,
...patchArg.switch,
state: this.normalizeState(patchArg.switch?.state ?? baseArg.switch.state),
attributes: this.cleanAttributes({ ...baseArg.switch.attributes, ...patchArg.switch?.attributes }),
},
events: [...baseArg.events, ...(patchArg.events || [])],
metadata: this.cleanAttributes({ ...baseArg.metadata, ...patchArg.metadata }),
updatedAt: patchArg.updatedAt || baseArg.updatedAt || new Date().toISOString(),
};
}
public static toDevices(snapshotArg: IDlinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'power', capability: 'switch', name: 'Power', readable: true, writable: true },
{ id: 'temperature', capability: 'sensor', name: 'Temperature', readable: true, writable: false, unit: 'C' },
{ id: 'current_consumption', capability: 'energy', name: 'Current consumption', readable: true, writable: false, unit: 'W' },
{ id: 'total_consumption', capability: 'energy', name: 'Total consumption', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'power', value: snapshotArg.switch.state === 'unknown' ? null : snapshotArg.switch.state === 'ON', updatedAt },
{ featureId: 'temperature', value: snapshotArg.switch.temperature ?? null, updatedAt },
{ featureId: 'current_consumption', value: snapshotArg.switch.currentConsumption ?? null, updatedAt },
{ featureId: 'total_consumption', value: snapshotArg.switch.totalConsumption ?? null, updatedAt },
];
return [{
id: this.deviceId(snapshotArg),
integrationDomain: dlinkDomain,
name: snapshotArg.device.name || dlinkDefaultName,
protocol: 'http',
manufacturer: snapshotArg.device.manufacturer || dlinkManufacturer,
model: snapshotArg.device.modelName || dlinkDefaultName,
online: snapshotArg.connected && snapshotArg.switch.available,
features,
state,
metadata: this.cleanAttributes({
attribution: dlinkAttribution,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
macAddress: snapshotArg.device.macAddress,
useLegacyProtocol: snapshotArg.device.useLegacyProtocol,
authenticated: snapshotArg.device.authenticated,
...snapshotArg.metadata,
}),
}];
}
public static toEntities(snapshotArg: IDlinkSnapshot): IIntegrationEntity[] {
return [this.switchEntity(snapshotArg)];
}
public static commandForService(snapshotArg: IDlinkSnapshot, requestArg: IServiceCallRequest): IDlinkCommand | undefined {
const requestedState = this.requestedState(requestArg);
if (!requestedState) {
return undefined;
}
const targetMatches = this.targetMatches(snapshotArg, requestArg);
if (!targetMatches) {
return undefined;
}
const entity = this.switchEntity(snapshotArg);
const type: IDlinkCommand['type'] = requestArg.service === 'turn_on' ? 'switch_turn_on' : requestArg.service === 'turn_off' ? 'switch_turn_off' : 'set_state';
return {
type,
method: 'hnap.SetSocketSettings',
state: requestedState,
service: requestArg.service,
target: requestArg.target,
data: requestArg.data,
deviceId: entity.deviceId,
entityId: entity.id,
host: snapshotArg.device.host,
transmitted: false,
metadata: this.cleanAttributes({
useLegacyProtocol: snapshotArg.device.useLegacyProtocol,
modelName: snapshotArg.device.modelName,
}),
};
}
public static toIntegrationEvent(eventArg: IDlinkEvent): IIntegrationEvent {
return {
type: eventArg.type === 'command_failed' ? 'error' : 'state_changed',
integrationDomain: dlinkDomain,
deviceId: eventArg.deviceId,
entityId: eventArg.entityId,
data: eventArg,
timestamp: eventArg.timestamp || Date.now(),
};
}
public static deviceId(snapshotArg: IDlinkSnapshot): string {
return `dlink.device.${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name || 'smart_plug')}`;
}
public static switchEntityId(snapshotArg: IDlinkSnapshot): string {
return `switch.${this.slug(snapshotArg.device.name || snapshotArg.device.id || snapshotArg.device.host || 'dlink_smart_plug')}`;
}
public static normalizeState(valueArg: unknown): TDlinkPowerState {
if (typeof valueArg === 'boolean') {
return valueArg ? 'ON' : 'OFF';
}
if (typeof valueArg !== 'string') {
return 'unknown';
}
const normalized = valueArg.trim().toLowerCase();
if (normalized === 'on' || normalized === 'true' || normalized === '1') {
return 'ON';
}
if (normalized === 'off' || normalized === 'false' || normalized === '0') {
return 'OFF';
}
return 'unknown';
}
public static numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim() && valueArg.trim().toLowerCase() !== 'n/a') {
const parsed = Number(valueArg);
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 || 'dlink';
}
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;
}
private static switchEntity(snapshotArg: IDlinkSnapshot): IIntegrationEntity {
return {
id: this.switchEntityId(snapshotArg),
uniqueId: `dlink_${this.slug(snapshotArg.device.id || snapshotArg.device.macAddress || snapshotArg.device.host || snapshotArg.device.name)}_switch`,
integrationDomain: dlinkDomain,
deviceId: this.deviceId(snapshotArg),
platform: 'switch',
name: snapshotArg.device.name || dlinkDefaultName,
state: snapshotArg.switch.state === 'unknown' ? 'unknown' : snapshotArg.switch.state === 'ON' ? 'on' : 'off',
available: snapshotArg.connected && snapshotArg.switch.available,
attributes: this.cleanAttributes({
attribution: dlinkAttribution,
nativeType: 'smart_plug_switch',
writable: true,
temperature: snapshotArg.switch.temperature ?? null,
total_consumption: snapshotArg.switch.totalConsumption ?? null,
current_consumption: snapshotArg.switch.currentConsumption ?? null,
host: snapshotArg.device.host,
port: snapshotArg.device.port,
modelName: snapshotArg.device.modelName,
manufacturer: snapshotArg.device.manufacturer,
useLegacyProtocol: snapshotArg.device.useLegacyProtocol,
...snapshotArg.switch.attributes,
}),
};
}
private static requestedState(requestArg: IServiceCallRequest): Exclude<TDlinkPowerState, 'unknown'> | undefined {
if (requestArg.domain === 'switch' && requestArg.service === 'turn_on') {
return 'ON';
}
if (requestArg.domain === 'switch' && requestArg.service === 'turn_off') {
return 'OFF';
}
if (requestArg.domain === dlinkDomain && requestArg.service === 'set_state') {
const state = this.normalizeState(requestArg.data?.state ?? requestArg.data?.power);
return state === 'unknown' ? undefined : state;
}
if (requestArg.domain === dlinkDomain && requestArg.service === 'turn_on') {
return 'ON';
}
if (requestArg.domain === dlinkDomain && requestArg.service === 'turn_off') {
return 'OFF';
}
return undefined;
}
private static targetMatches(snapshotArg: IDlinkSnapshot, requestArg: IServiceCallRequest): boolean {
const target = requestArg.target || {};
if (!target.entityId && !target.deviceId) {
return true;
}
return target.entityId === this.switchEntityId(snapshotArg) || target.deviceId === this.deviceId(snapshotArg);
}
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;
}
}
+152 -3
View File
@@ -1,4 +1,153 @@
export interface IHomeAssistantDlinkConfig {
// TODO: replace with the TypeScript-native config for dlink.
[key: string]: unknown;
import type { IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
export type TDlinkPowerState = 'ON' | 'OFF' | 'unknown';
export type TDlinkCommandType = 'switch_turn_on' | 'switch_turn_off' | 'set_state';
export type TDlinkEventType = 'snapshot_refreshed' | 'command_mapped' | 'command_succeeded' | 'command_failed' | 'state_changed';
export interface IDlinkDeviceSnapshot {
id?: string;
name?: string;
host?: string;
port?: number;
macAddress?: string;
manufacturer?: string;
modelName?: string;
useLegacyProtocol?: boolean;
authenticated?: boolean;
available?: boolean;
metadata?: Record<string, unknown>;
}
export interface IDlinkSwitchSnapshot {
state: TDlinkPowerState;
available: boolean;
temperature?: number | null;
temperatureRaw?: string;
currentConsumption?: number | null;
currentConsumptionRaw?: string;
totalConsumption?: number | null;
totalConsumptionRaw?: string;
attributes?: Record<string, unknown>;
}
export interface IDlinkSnapshot {
connected: boolean;
device: IDlinkDeviceSnapshot;
switch: IDlinkSwitchSnapshot;
updatedAt?: string;
events: IDlinkEvent[];
metadata?: Record<string, unknown>;
}
export interface IDlinkCommand {
type: TDlinkCommandType;
method: 'hnap.SetSocketSettings' | 'native_client' | 'executor';
state: Exclude<TDlinkPowerState, 'unknown'>;
service: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
deviceId?: string;
entityId?: string;
host?: string;
transmitted?: boolean;
metadata?: Record<string, unknown>;
}
export interface IDlinkCommandResult extends IServiceCallResult {
transmitted?: boolean;
state?: TDlinkPowerState;
}
export interface IDlinkEvent {
type: TDlinkEventType;
command?: IDlinkCommand;
deviceId?: string;
entityId?: string;
data?: unknown;
error?: string;
timestamp: number;
}
export interface IDlinkNativeClient {
authenticate?(): Promise<boolean>;
getSnapshot?(): Promise<IDlinkSnapshot | Partial<IDlinkSnapshot>>;
getModelName?(): Promise<string | undefined>;
getState?(): Promise<TDlinkPowerState | string | boolean | undefined>;
setState?(stateArg: Exclude<TDlinkPowerState, 'unknown'>): Promise<IDlinkCommandResult | string | boolean | void>;
getTemperature?(): Promise<number | string | null | undefined>;
getCurrentConsumption?(): Promise<number | string | null | undefined>;
getTotalConsumption?(): Promise<number | string | null | undefined>;
}
export interface IDlinkTransport {
execute(commandArg: IDlinkCommand): Promise<IDlinkCommandResult | unknown>;
readSnapshot?(): Promise<IDlinkSnapshot | Partial<IDlinkSnapshot>>;
}
export interface IDlinkManualEntry {
integrationDomain?: string;
id?: string;
host?: string;
ip?: string;
ipAddress?: string;
address?: string;
port?: number;
name?: string;
username?: string;
password?: string;
useLegacyProtocol?: boolean;
use_legacy_protocol?: boolean;
manufacturer?: string;
model?: string;
modelName?: string;
mac?: string;
macAddress?: string;
snapshot?: IDlinkSnapshot;
state?: TDlinkPowerState | boolean | string;
temperature?: number | string | null;
currentConsumption?: number | string | null;
totalConsumption?: number | string | null;
metadata?: Record<string, unknown>;
}
export interface IDlinkDhcpRecord {
integrationDomain?: string;
host?: string;
ip?: string;
ipAddress?: string;
address?: string;
hostname?: string;
hostName?: string;
manufacturer?: string;
model?: string;
mac?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
export interface IDlinkCandidateMetadata extends Record<string, unknown> {
dlink?: boolean;
discoveryProtocol?: 'dhcp' | 'manual';
hostname?: string;
host?: string;
username?: string;
useLegacyProtocol?: boolean;
snapshot?: IDlinkSnapshot;
nativeHttpImplemented?: boolean;
}
export interface IDlinkConfig extends IDlinkManualEntry {
username?: string;
password?: string;
useLegacyProtocol?: boolean;
port?: number;
timeoutMs?: number;
nativeHttpEnabled?: boolean;
connected?: boolean;
snapshot?: IDlinkSnapshot;
nativeClient?: IDlinkNativeClient;
commandExecutor?: (commandArg: IDlinkCommand) => Promise<IDlinkCommandResult | unknown>;
transport?: IDlinkTransport;
}
export type IHomeAssistantDlinkConfig = IDlinkConfig;
+5
View File
@@ -1,2 +1,7 @@
export * from './dlink.classes.client.js';
export * from './dlink.classes.configflow.js';
export * from './dlink.classes.integration.js';
export * from './dlink.constants.js';
export * from './dlink.discovery.js';
export * from './dlink.mapper.js';
export * from './dlink.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,875 @@
import type {
IFoscamCamera,
IFoscamClientLike,
IFoscamCommandRequest,
IFoscamCommandResponse,
IFoscamConfig,
IFoscamDeviceInfo,
IFoscamHttpCommand,
IFoscamNumber,
IFoscamRefreshResult,
IFoscamSnapshot,
IFoscamSnapshotImage,
IFoscamSwitch,
TFoscamEntityKey,
TFoscamProtocol,
TFoscamPtzMovement,
TFoscamStream,
} from './foscam.types.js';
import {
foscamDefaultPort,
foscamDefaultRtspPort,
foscamDefaultSnapshotTimeoutMs,
foscamDefaultTimeoutMs,
foscamNumberDescriptions,
foscamSwitchDescriptions,
} from './foscam.types.js';
export const foscamSuccess = 0;
export const foscamAuthError = -2;
export const foscamCommandDeniedError = -3;
export const foscamUnavailableError = -8;
const ptzCommands: Record<TFoscamPtzMovement, string> = {
up: 'ptzMoveUp',
down: 'ptzMoveDown',
left: 'ptzMoveLeft',
right: 'ptzMoveRight',
top_left: 'ptzMoveTopLeft',
top_right: 'ptzMoveTopRight',
bottom_left: 'ptzMoveBottomLeft',
bottom_right: 'ptzMoveBottomRight',
};
export class FoscamApiError extends Error {}
export class FoscamApiConnectionError extends FoscamApiError {}
export class FoscamApiAuthorizationError extends FoscamApiError {}
export class FoscamApiCommandError extends FoscamApiError {
constructor(public readonly result: number, messageArg: string) {
super(messageArg);
this.name = 'FoscamApiCommandError';
}
}
export class FoscamClient {
private currentSnapshot?: IFoscamSnapshot;
constructor(private readonly config: IFoscamConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IFoscamSnapshot> {
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<IFoscamRefreshResult> {
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
return { success: this.currentSnapshot.connected && !this.currentSnapshot.error, snapshot: this.cloneSnapshot(this.currentSnapshot), data: { source: 'client' } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
this.currentSnapshot = this.offlineSnapshot(error);
return { success: false, snapshot: this.cloneSnapshot(this.currentSnapshot), error };
}
}
if (!this.hasLiveTarget()) {
const snapshot = await this.getSnapshot();
return {
success: false,
snapshot,
error: 'Foscam refresh requires a configured HTTP endpoint or injected client.',
};
}
try {
this.currentSnapshot = await this.fetchLiveSnapshot();
return { success: true, snapshot: this.cloneSnapshot(this.currentSnapshot), data: { source: 'http' } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
this.currentSnapshot = this.offlineSnapshot(error);
return { success: false, snapshot: this.cloneSnapshot(this.currentSnapshot), error };
}
}
public async validateConnection(): Promise<IFoscamSnapshot> {
const productInfo = await this.requireCommandData('getProductAllInfo');
const devInfo = await this.optionalCommandData('getDevInfo');
return this.normalizeSnapshot(this.snapshotFromLiveData({ productInfo, devInfo }), 'http');
}
public async getSnapshotImage(): Promise<IFoscamSnapshotImage> {
const response = await this.sendCommand('snapPicture2', {}, true, this.config.snapshotTimeoutMs || foscamDefaultSnapshotTimeoutMs);
if (!response.raw) {
throw new FoscamApiConnectionError('Foscam snapPicture2 did not return image data.');
}
return {
contentType: response.contentType || 'image/jpeg',
data: response.raw,
};
}
public async execute(commandArg: IFoscamCommandRequest): 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') {
return this.refresh();
}
if (commandArg.type === 'stream_source') {
const snapshot = await this.getSnapshot();
return {
cameraId: snapshot.camera.id,
stream: snapshot.camera.stream,
streamSourceUrl: snapshot.camera.streamSourceUrl,
snapshotUrl: snapshot.camera.snapshotUrl,
rtspUrl: snapshot.camera.rtspUrl,
verified: false,
};
}
if (!this.hasLiveTarget()) {
throw new FoscamApiConnectionError('Foscam commands require config.host, config.url, config.client, or commandExecutor.');
}
if (commandArg.type === 'snapshot_image') {
if (commandArg.filename) {
throw new FoscamApiError('Foscam snapshot file writes are not implemented; request image data without data.filename.');
}
const image = await this.getSnapshotImage();
return { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
if (commandArg.type === 'ptz') {
if (!commandArg.movement) {
throw new FoscamApiError('Foscam PTZ command requires a movement value.');
}
const responses = await this.performPtz(commandArg.movement, commandArg.travelTime);
return { ok: true, command: commandArg.type, responses };
}
if (commandArg.type === 'ptz_preset') {
if (!commandArg.presetName) {
throw new FoscamApiError('Foscam PTZ preset command requires presetName.');
}
const response = await this.requestOk(this.httpCommand('ptz_preset', 'ptzGotoPresetPoint', { name: commandArg.presetName }));
return { ok: true, command: commandArg.type, responses: [response] };
}
if (commandArg.type === 'enable_motion_detection' || commandArg.type === 'disable_motion_detection') {
const enabled = commandArg.type === 'enable_motion_detection';
const response = await this.setMotionDetectionField('isEnable', enabled);
this.patchCachedSwitch('motion_detection', enabled);
this.patchCachedCamera({ motionDetectionEnabled: enabled });
return { ok: true, command: commandArg.type, responses: [response] };
}
if (commandArg.type === 'set_switch') {
if (!commandArg.key || typeof commandArg.enabled !== 'boolean') {
throw new FoscamApiError('Foscam set_switch command requires key and enabled.');
}
const responses = await this.setSwitch(commandArg.key, commandArg.enabled);
this.patchCachedSwitch(commandArg.key, commandArg.enabled);
if (commandArg.key === 'motion_detection') {
this.patchCachedCamera({ motionDetectionEnabled: commandArg.enabled });
}
return { ok: true, command: commandArg.type, responses };
}
if (commandArg.type === 'set_number') {
if (!commandArg.key || typeof commandArg.value !== 'number') {
throw new FoscamApiError('Foscam set_number command requires key and numeric value.');
}
const response = await this.setNumber(commandArg.key, commandArg.value);
this.patchCachedNumber(commandArg.key, commandArg.value);
return { ok: true, command: commandArg.type, responses: [response] };
}
throw new FoscamApiError(`Unsupported Foscam command: ${commandArg.type}`);
}
public async destroy(): Promise<void> {}
private async fetchLiveSnapshot(): Promise<IFoscamSnapshot> {
const productInfo = await this.requireCommandData('getProductAllInfo');
const [devInfo, portInfo, infraLedConfig, mirrorFlipSetting, sleepState, whiteLight, sirenConfig, audioVolume, speakVolume, voiceEnableState, ledEnableState, swCapabilities, motionConfig] = await Promise.all([
this.optionalCommandData('getDevInfo'),
this.optionalCommandData('getPortInfo'),
this.optionalCommandData('getInfraLedConfig'),
this.optionalCommandData('getMirrorAndFlipSetting'),
this.optionalCommandData('getAlexaState'),
this.optionalCommandData('getWhiteLightBrightness'),
this.optionalCommandData('getSirenConfig'),
this.optionalCommandData('getAudioVolume'),
this.optionalCommandData('getSpeakVolume'),
this.optionalCommandData('getVoiceEnableState'),
this.optionalCommandData('getLedEnableState'),
this.optionalCommandData('getSWCapabilities'),
this.optionalCommandData('getMotionDetectConfig'),
]);
const reserve4 = numberValue(productInfo.reserve4) ?? 0;
const modelInt = numberValue(productInfo.model) ?? 7002;
const supportsWdr = modelInt > 7001 && Boolean(reserve4 & 256);
const supportsHdr = modelInt > 7001 && Boolean(reserve4 & 128);
const [wdrMode, hdrMode] = await Promise.all([
supportsWdr ? this.optionalCommandData('getWdrMode') : Promise.resolve({}),
supportsHdr ? this.optionalCommandData('getHdrMode') : Promise.resolve({}),
]);
return this.normalizeSnapshot(this.snapshotFromLiveData({
productInfo,
devInfo,
portInfo,
infraLedConfig,
mirrorFlipSetting,
sleepState,
whiteLight,
sirenConfig,
audioVolume,
speakVolume,
voiceEnableState,
ledEnableState,
swCapabilities,
motionConfig,
wdrMode,
hdrMode,
}), 'http');
}
private snapshotFromLiveData(inputArg: {
productInfo: Record<string, string | undefined>;
devInfo?: Record<string, string | undefined>;
portInfo?: Record<string, string | undefined>;
infraLedConfig?: Record<string, string | undefined>;
mirrorFlipSetting?: Record<string, string | undefined>;
sleepState?: Record<string, string | undefined>;
whiteLight?: Record<string, string | undefined>;
sirenConfig?: Record<string, string | undefined>;
audioVolume?: Record<string, string | undefined>;
speakVolume?: Record<string, string | undefined>;
voiceEnableState?: Record<string, string | undefined>;
ledEnableState?: Record<string, string | undefined>;
swCapabilities?: Record<string, string | undefined>;
motionConfig?: Record<string, string | undefined>;
wdrMode?: Record<string, string | undefined>;
hdrMode?: Record<string, string | undefined>;
}): IFoscamSnapshot {
const productInfo = inputArg.productInfo || {};
const devInfo = inputArg.devInfo || {};
const motionConfig = inputArg.motionConfig || {};
const reserve4 = numberValue(productInfo.reserve4) ?? 0;
const modelInt = numberValue(productInfo.model) ?? 7002;
const supportsWdr = modelInt > 7001 && Boolean(reserve4 & 256);
const supportsHdr = modelInt > 7001 && Boolean(reserve4 & 128);
const swCapabilities1 = numberValue(inputArg.swCapabilities?.swCapabilities1) ?? 0;
const swCapabilities2 = numberValue(inputArg.swCapabilities?.swCapabilities2) ?? 0;
const supportsSpeakVolume = Boolean(swCapabilities1 & 32);
const supportsPetDetection = Boolean(swCapabilities2 & 512);
const supportsCarDetection = Boolean(swCapabilities2 & 256);
const supportsHumanDetection = Boolean(swCapabilities2 & 128);
const currentSettings: Partial<Record<TFoscamEntityKey, unknown>> = {
motion_detection: booleanValue(motionConfig.isEnable) ?? false,
is_flip: inputArg.mirrorFlipSetting?.isFlip === '1',
is_mirror: inputArg.mirrorFlipSetting?.isMirror === '1',
is_open_ir: inputArg.infraLedConfig?.mode === '1',
sleep_switch: inputArg.sleepState?.state === '1',
is_open_white_light: inputArg.whiteLight?.enable === '1',
is_siren_alarm: inputArg.sirenConfig?.sirenEnable === '1',
is_turn_off_volume: !(inputArg.voiceEnableState?.isEnable === '1'),
is_turn_off_light: !(inputArg.ledEnableState?.isEnable === '0'),
is_open_wdr: supportsWdr ? booleanValue(inputArg.wdrMode?.mode) ?? false : undefined,
is_open_hdr: supportsHdr ? booleanValue(inputArg.hdrMode?.mode) ?? false : undefined,
pet_detection: supportsPetDetection ? motionConfig.petEnable === '1' : undefined,
car_detection: supportsCarDetection ? motionConfig.carEnable === '1' : undefined,
human_detection: supportsHumanDetection ? motionConfig.humanEnable === '1' : undefined,
device_volume: numberValue(inputArg.audioVolume?.volume) ?? 0,
speak_volume: numberValue(inputArg.speakVolume?.SpeakVolume) ?? 0,
};
const deviceInfo = this.deviceInfo(true, {
name: devInfo.devName || this.config.name,
manufacturer: 'Foscam',
model: devInfo.productName || productInfo.productName || productInfo.model || this.config.model,
productName: devInfo.productName || productInfo.productName,
firmwareVersion: devInfo.firmwareVer,
hardwareVersion: devInfo.hardwareVer,
rtspPort: numberValue(inputArg.portInfo?.rtspPort) || numberValue(inputArg.portInfo?.mediaPort) || this.config.rtspPort || foscamDefaultRtspPort,
productInfo,
rawDevInfo: devInfo,
});
return {
deviceInfo,
camera: this.camera(deviceInfo, true, { motionDetectionEnabled: Boolean(currentSettings.motion_detection) }),
switches: this.switchesFromSettings(currentSettings, true, {
is_open_wdr: supportsWdr,
is_open_hdr: supportsHdr,
pet_detection: supportsPetDetection,
car_detection: supportsCarDetection,
human_detection: supportsHumanDetection,
}),
numbers: this.numbersFromSettings(currentSettings, true, { speak_volume: supportsSpeakVolume }),
currentSettings,
connected: true,
updatedAt: new Date().toISOString(),
source: 'http',
metadata: {
supportsSpeakVolume,
supportsPetDetection,
supportsCarDetection,
supportsHumanDetection,
supportsWdr,
supportsHdr,
},
};
}
private snapshotFromConfig(connectedArg: boolean, errorArg?: string, sourceArg: IFoscamSnapshot['source'] = 'manual'): IFoscamSnapshot {
const currentSettings = { ...this.config.snapshot?.currentSettings, ...this.config.currentSettings };
const deviceInfo = this.deviceInfo(connectedArg);
return this.normalizeSnapshot({
deviceInfo,
camera: this.camera(deviceInfo, connectedArg, this.config.camera),
switches: this.config.switches || this.config.snapshot?.switches || this.switchesFromSettings(currentSettings, connectedArg),
numbers: this.config.numbers || this.config.snapshot?.numbers || this.numbersFromSettings(currentSettings, connectedArg),
currentSettings,
connected: connectedArg,
updatedAt: new Date().toISOString(),
source: sourceArg,
error: errorArg,
metadata: this.config.snapshot?.metadata,
}, sourceArg);
}
private normalizeSnapshot(snapshotArg: IFoscamSnapshot, sourceArg: IFoscamSnapshot['source'] = snapshotArg.source): IFoscamSnapshot {
const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
const deviceInfo = {
...this.deviceInfo(connected),
...snapshotArg.deviceInfo,
online: connected,
};
const currentSettings = snapshotArg.currentSettings || {};
return {
...snapshotArg,
deviceInfo,
camera: this.normalizeCamera(snapshotArg.camera, deviceInfo, connected),
switches: snapshotArg.switches || this.switchesFromSettings(currentSettings, connected),
numbers: snapshotArg.numbers || this.numbersFromSettings(currentSettings, connected),
currentSettings,
connected,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: sourceArg,
};
}
private normalizeCamera(cameraArg: IFoscamCamera | undefined, deviceInfoArg: IFoscamDeviceInfo, connectedArg: boolean): IFoscamCamera {
const baseCamera = this.camera(deviceInfoArg, connectedArg, cameraArg);
return {
...baseCamera,
...cameraArg,
stream: cameraArg?.stream || baseCamera.stream,
snapshotUrl: cameraArg?.snapshotUrl || baseCamera.snapshotUrl,
rtspUrl: cameraArg?.rtspUrl || baseCamera.rtspUrl,
streamSourceUrl: cameraArg?.streamSourceUrl || cameraArg?.rtspUrl || baseCamera.streamSourceUrl,
available: connectedArg && cameraArg?.available !== false,
};
}
private camera(deviceInfoArg: IFoscamDeviceInfo, connectedArg: boolean, overridesArg: Partial<IFoscamCamera> = {}): IFoscamCamera {
const stream = overridesArg.stream || this.stream();
const name = overridesArg.name || `${deviceInfoArg.name || 'Foscam'} Camera`;
return {
id: overridesArg.id || 'camera',
name,
stream,
snapshotUrl: overridesArg.snapshotUrl || this.commandUrl('snapPicture2'),
rtspUrl: overridesArg.rtspUrl || this.rtspUrl(stream),
streamSourceUrl: overridesArg.streamSourceUrl || overridesArg.rtspUrl || this.rtspUrl(stream),
available: connectedArg && overridesArg.available !== false,
supportsPtz: overridesArg.supportsPtz ?? true,
motionDetectionEnabled: overridesArg.motionDetectionEnabled,
attributes: overridesArg.attributes,
};
}
private deviceInfo(connectedArg: boolean, liveArg: Partial<IFoscamDeviceInfo> = {}): IFoscamDeviceInfo {
const endpoint = this.endpoint();
const serialNumber = liveArg.serialNumber || this.config.deviceInfo?.serialNumber || this.config.uniqueId;
return {
...this.config.deviceInfo,
...liveArg,
id: this.config.deviceInfo?.id || this.config.uniqueId || serialNumber || endpoint.host || 'manual-foscam',
name: liveArg.name || this.config.deviceInfo?.name || this.config.name || endpoint.host || 'Foscam Camera',
manufacturer: liveArg.manufacturer || this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Foscam',
model: liveArg.model || this.config.deviceInfo?.model || this.config.model,
serialNumber,
host: this.config.deviceInfo?.host || endpoint.host,
port: this.config.deviceInfo?.port || endpoint.port,
protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
rtspPort: liveArg.rtspPort || this.config.deviceInfo?.rtspPort || this.config.rtspPort || foscamDefaultRtspPort,
url: this.config.deviceInfo?.url || this.baseUrl(),
online: connectedArg,
};
}
private switchesFromSettings(settingsArg: Partial<Record<TFoscamEntityKey, unknown>>, connectedArg: boolean, existsArg: Partial<Record<TFoscamEntityKey, boolean>> = {}): IFoscamSwitch[] {
return foscamSwitchDescriptions
.filter((descriptionArg) => existsArg[descriptionArg.key] !== false)
.map((descriptionArg) => ({
key: descriptionArg.key,
name: descriptionArg.name,
isOn: booleanValue(settingsArg[descriptionArg.key]) ?? false,
available: connectedArg,
exists: true,
entityCategory: descriptionArg.entityCategory,
}));
}
private numbersFromSettings(settingsArg: Partial<Record<TFoscamEntityKey, unknown>>, connectedArg: boolean, existsArg: Partial<Record<TFoscamEntityKey, boolean>> = {}): IFoscamNumber[] {
return foscamNumberDescriptions
.filter((descriptionArg) => existsArg[descriptionArg.key] !== false)
.map((descriptionArg) => ({
key: descriptionArg.key,
name: descriptionArg.name,
value: numberValue(settingsArg[descriptionArg.key]) ?? 0,
min: descriptionArg.min,
max: descriptionArg.max,
step: descriptionArg.step,
available: connectedArg,
exists: true,
entityCategory: descriptionArg.entityCategory,
}));
}
private async executeWithClient(clientArg: IFoscamClientLike, commandArg: IFoscamCommandRequest): Promise<unknown> {
if (clientArg.execute) {
return clientArg.execute(commandArg);
}
if (commandArg.type === 'refresh') {
return this.refresh();
}
if (commandArg.type === 'snapshot_image' && clientArg.getSnapshotImage) {
const image = await clientArg.getSnapshotImage();
return { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
throw new FoscamApiConnectionError('Foscam command is not available on the injected client and no HTTP endpoint or commandExecutor is configured.');
}
private async snapshotFromClient(clientArg: IFoscamClientLike): Promise<IFoscamSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (isFoscamSnapshot(result)) {
return this.normalizeSnapshot(result, 'client');
}
return this.snapshotFromConfig(true, undefined, 'client');
}
const [productInfo, devInfo] = await Promise.all([
clientArg.getProductAllInfo ? clientArg.getProductAllInfo().catch(() => undefined) : undefined,
clientArg.getDevInfo ? clientArg.getDevInfo().catch(() => undefined) : undefined,
]);
if (!productInfo && !devInfo) {
throw new FoscamApiConnectionError('Foscam client must expose getSnapshot() or raw product/device info getters.');
}
return this.normalizeSnapshot(this.snapshotFromLiveData({
productInfo: recordStrings(productInfo),
devInfo: recordStrings(devInfo),
}), 'client');
}
private async setSwitch(keyArg: TFoscamEntityKey, enabledArg: boolean): Promise<IFoscamCommandResponse[]> {
if (keyArg === 'motion_detection') {
return [await this.setMotionDetectionField('isEnable', enabledArg)];
}
if (keyArg === 'is_flip') {
return [await this.requestOk(this.httpCommand('flip', 'flipVideo', { isFlip: enabledArg ? 1 : 0 }))];
}
if (keyArg === 'is_mirror') {
return [await this.requestOk(this.httpCommand('mirror', 'mirrorVideo', { isMirror: enabledArg ? 1 : 0 }))];
}
if (keyArg === 'is_open_ir') {
const mode = await this.requestOk(this.httpCommand('infrared_mode', 'setInfraLedConfig', { mode: enabledArg ? 1 : 0 }));
const led = await this.requestOk(this.httpCommand('infrared_led', enabledArg ? 'openInfraLed' : 'closeInfraLed'));
return [mode, led];
}
if (keyArg === 'sleep_switch') {
return [await this.requestOk(this.httpCommand('sleep', enabledArg ? 'alexaSleep' : 'alexaWakeUp'))];
}
if (keyArg === 'is_open_white_light') {
return [await this.requestOk(this.httpCommand('white_light', enabledArg ? 'openWhiteLight' : 'closeWhiteLight'))];
}
if (keyArg === 'is_siren_alarm') {
return [await this.requestOk(this.httpCommand('siren_alarm', 'setSirenConfig', { sirenEnable: enabledArg ? 1 : 0, sirenvolume: 100, reserved: 0 }))];
}
if (keyArg === 'is_turn_off_volume') {
return [await this.requestOk(this.httpCommand('volume_muted', 'setVoiceEnableState', { isEnable: enabledArg ? 0 : 1 }))];
}
if (keyArg === 'is_turn_off_light') {
return [await this.requestOk(this.httpCommand('light', 'setLedEnableState', { isEnable: enabledArg ? 1 : 0 }))];
}
if (keyArg === 'is_open_hdr') {
return [await this.requestOk(this.httpCommand('hdr', 'setHdrMode', { mode: enabledArg ? 1 : 0 }))];
}
if (keyArg === 'is_open_wdr') {
return [await this.requestOk(this.httpCommand('wdr', 'setWdrMode', { mode: enabledArg ? 1 : 0 }))];
}
if (keyArg === 'pet_detection') {
return [await this.setMotionDetectionField('petEnable', enabledArg)];
}
if (keyArg === 'car_detection') {
return [await this.setMotionDetectionField('carEnable', enabledArg)];
}
if (keyArg === 'human_detection') {
return [await this.setMotionDetectionField('humanEnable', enabledArg)];
}
throw new FoscamApiError(`Unsupported Foscam switch key: ${keyArg}`);
}
private async setNumber(keyArg: TFoscamEntityKey, valueArg: number): Promise<IFoscamCommandResponse> {
const value = Math.max(0, Math.min(100, Math.round(valueArg)));
if (keyArg === 'device_volume') {
return this.requestOk(this.httpCommand('device_volume', 'setAudioVolume', { volume: value }));
}
if (keyArg === 'speak_volume') {
return this.requestOk(this.httpCommand('speak_volume', 'setSpeakVolume', { SpeakVolume: value }));
}
throw new FoscamApiError(`Unsupported Foscam number key: ${keyArg}`);
}
private async setMotionDetectionField(fieldArg: string, enabledArg: boolean): Promise<IFoscamCommandResponse> {
const currentConfig = await this.requireCommandData('getMotionDetectConfig');
return this.requestOk(this.httpCommand('motion_detection', 'setMotionDetectConfig', cleanParams({ ...currentConfig, [fieldArg]: enabledArg ? 1 : 0 })));
}
private async performPtz(movementArg: TFoscamPtzMovement, travelTimeArg = 0.125): Promise<IFoscamCommandResponse[]> {
const command = ptzCommands[movementArg];
const start = await this.requestOk(this.httpCommand('ptz_start', command));
await sleep(Math.max(0, Math.min(1, travelTimeArg)) * 1000);
const stop = await this.requestOk(this.httpCommand('ptz_stop', 'ptzStopRun'));
return [start, stop];
}
private async requestOk(commandArg: IFoscamHttpCommand): Promise<IFoscamCommandResponse> {
const response = await this.sendCommand(commandArg.command, commandArg.params);
if (response.result !== foscamSuccess) {
this.throwForResult(response.result, commandArg.command);
}
return {
ok: true,
label: commandArg.label,
method: commandArg.method,
command: commandArg.command,
params: commandArg.params,
result: response.result,
data: response.data,
};
}
private async requireCommandData(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<Record<string, string | undefined>> {
const response = await this.sendCommand(commandArg, paramsArg);
if (response.result !== foscamSuccess) {
this.throwForResult(response.result, commandArg);
}
return response.data;
}
private async optionalCommandData(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<Record<string, string | undefined>> {
try {
const response = await this.sendCommand(commandArg, paramsArg);
return response.result === foscamSuccess ? response.data : {};
} catch {
return {};
}
}
private async sendCommand(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}, rawArg = false, timeoutMsArg = this.config.timeoutMs || foscamDefaultTimeoutMs): Promise<{
result: number;
data: Record<string, string | undefined>;
raw?: Uint8Array;
contentType?: string;
}> {
const url = this.commandUrl(commandArg, paramsArg);
if (!url) {
throw new FoscamApiConnectionError('Foscam live HTTP client requires config.host or config.url.');
}
const response = await this.fetchWithTimeout(url, timeoutMsArg);
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new FoscamApiAuthorizationError('Foscam authentication failed.');
}
const text = await response.text().catch(() => '');
throw new FoscamApiConnectionError(`Foscam command ${commandArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
if (rawArg) {
return {
result: foscamSuccess,
data: {},
raw: new Uint8Array(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || undefined,
};
}
return parseFoscamResponse(await response.text());
}
private async fetchWithTimeout(urlArg: string, timeoutMsArg: number): Promise<Response> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
try {
return await globalThis.fetch(urlArg, { signal: abortController.signal });
} finally {
clearTimeout(timeout);
}
}
private throwForResult(resultArg: number, commandArg: string): never {
if (resultArg === foscamAuthError) {
throw new FoscamApiAuthorizationError(`Foscam command ${commandArg} failed authentication.`);
}
if (resultArg === foscamUnavailableError) {
throw new FoscamApiConnectionError(`Foscam command ${commandArg} could not reach a camera.`);
}
if (resultArg === foscamCommandDeniedError) {
throw new FoscamApiCommandError(resultArg, `Foscam command ${commandArg} is not supported or access was denied.`);
}
throw new FoscamApiCommandError(resultArg, `Foscam command ${commandArg} failed with result ${resultArg}.`);
}
private httpCommand(labelArg: string, commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): IFoscamHttpCommand {
return { label: labelArg, method: 'GET', command: commandArg, params: paramsArg, expect: 'ok' };
}
private commandUrl(commandArg: string, paramsArg: Record<string, string | number | boolean> = {}): string | undefined {
const baseUrl = this.baseUrl();
if (!baseUrl) {
return undefined;
}
const url = new URL(`${baseUrl}/cgi-bin/CGIProxy.fcgi`);
url.searchParams.set('usr', this.config.username || '');
url.searchParams.set('pwd', this.config.password || '');
url.searchParams.set('cmd', commandArg);
for (const [key, value] of Object.entries(paramsArg)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
private rtspUrl(streamArg: TFoscamStream): string | undefined {
const endpoint = this.endpoint();
if (!endpoint.host) {
return undefined;
}
const credentials = this.rtspCredentials();
return `rtsp://${credentials}${endpoint.host}:${this.config.rtspPort || foscamDefaultRtspPort}/video${streamArg}`;
}
private baseUrl(): string | undefined {
const endpoint = this.endpoint();
if (!endpoint.host) {
return undefined;
}
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port}`;
}
private endpoint(): { protocol: TFoscamProtocol; host?: string; port: number } {
const url = safeUrl(this.config.url || this.config.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
return {
protocol,
host: url.hostname,
port: url.port ? Number(url.port) : protocol === 'https' ? 443 : foscamDefaultPort,
};
}
return {
protocol: this.config.protocol || 'http',
host: this.config.host,
port: this.config.port || foscamDefaultPort,
};
}
private rtspCredentials(): string {
if (!this.config.username || this.config.password === undefined) {
return '';
}
return `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}@`;
}
private stream(): TFoscamStream {
return this.config.stream || 'Main';
}
private hasLiveTarget(): boolean {
return Boolean(this.baseUrl());
}
private hasManualData(): boolean {
return Boolean(this.config.deviceInfo || this.config.camera || this.config.switches || this.config.numbers || this.config.currentSettings || this.config.snapshot);
}
private offlineSnapshot(errorArg?: string): IFoscamSnapshot {
return this.snapshotFromConfig(false, errorArg, 'offline');
}
private patchCachedCamera(valuesArg: Partial<IFoscamCamera>): void {
if (!this.currentSnapshot) {
return;
}
Object.assign(this.currentSnapshot.camera, valuesArg);
}
private patchCachedSwitch(keyArg: TFoscamEntityKey, isOnArg: boolean): void {
if (!this.currentSnapshot) {
return;
}
this.currentSnapshot.currentSettings[keyArg] = isOnArg;
for (const switchArg of this.currentSnapshot.switches) {
if (switchArg.key === keyArg) {
switchArg.isOn = isOnArg;
}
}
}
private patchCachedNumber(keyArg: TFoscamEntityKey, valueArg: number): void {
if (!this.currentSnapshot) {
return;
}
this.currentSnapshot.currentSettings[keyArg] = valueArg;
for (const numberArg of this.currentSnapshot.numbers) {
if (numberArg.key === keyArg) {
numberArg.value = valueArg;
}
}
}
private cloneSnapshot(snapshotArg: IFoscamSnapshot): IFoscamSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IFoscamSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
const sleep = (msArg: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, msArg));
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const parseFoscamResponse = (textArg: string): { result: number; data: Record<string, string | undefined> } => {
const content = textArg.replace(/^[\s\S]*?<CGI_Result[^>]*>/i, '').replace(/<\/CGI_Result>[\s\S]*$/i, '');
const data: Record<string, string | undefined> = {};
for (const match of content.matchAll(/<([A-Za-z0-9_]+)>([\s\S]*?)<\/\1>/g)) {
if (match[1] === 'result') {
continue;
}
data[match[1]] = xmlDecode(match[2]);
}
const resultMatch = content.match(/<result>(-?\d+)<\/result>/);
if (!resultMatch) {
throw new FoscamApiConnectionError('Foscam response did not contain a CGI result code.');
}
return { result: Number(resultMatch[1]), data };
};
const xmlDecode = (valueArg: string): string => {
return valueArg
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
};
const 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>;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
if (typeof valueArg === 'string') {
if (['true', 'yes', 'on', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['false', 'no', 'off', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
};
const isFoscamSnapshot = (valueArg: unknown): valueArg is IFoscamSnapshot => {
const value = recordValue(valueArg);
return Boolean(value?.deviceInfo && value?.camera && Array.isArray(value?.switches) && Array.isArray(value?.numbers));
};
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
};
const recordStrings = (valueArg: unknown): Record<string, string | undefined> => {
const record = recordValue(valueArg);
if (!record) {
return {};
}
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, value === undefined || value === null ? undefined : String(value)]));
};
@@ -0,0 +1,133 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { FoscamApiAuthorizationError, FoscamApiConnectionError, FoscamClient } from './foscam.classes.client.js';
import { foscamDefaultConfigFromCandidate, foscamRtspPortFromMetadata, foscamStreamFromMetadata } from './foscam.discovery.js';
import type { IFoscamConfig, TFoscamProtocol, TFoscamStream } from './foscam.types.js';
import { foscamDefaultPort, foscamDefaultRtspPort, foscamDefaultTimeoutMs } from './foscam.types.js';
export class FoscamConfigFlow implements IConfigFlow<IFoscamConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IFoscamConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Foscam camera',
description: 'Configure the local Foscam HTTP CGI endpoint. Use the camera HTTP port and optional RTSP port for stream source URLs.',
fields: [
{ name: 'url', label: 'Base URL', type: 'text' },
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'username', label: 'Username', type: 'text', required: true },
{ name: 'password', label: 'Password', type: 'password', required: true },
{ name: 'stream', label: 'Stream', type: 'select', options: [{ label: 'Main', value: 'Main' }, { label: 'Sub', value: 'Sub' }] },
{ name: 'rtspPort', label: 'RTSP port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'validateConnection', label: 'Validate connection now', type: 'boolean' },
],
submit: async (valuesArg) => {
const defaults = foscamDefaultConfigFromCandidate(candidateArg);
const urlValue = this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'url');
const endpoint = this.endpoint(urlValue, this.stringValue(valuesArg.host) || candidateArg.host || defaults.host, this.numberValue(valuesArg.port) || candidateArg.port || defaults.port, this.protocolMetadata(candidateArg) || defaults.protocol);
if (!endpoint.host) {
return { kind: 'error', error: 'Foscam requires a base URL or host.' };
}
const username = this.stringValue(valuesArg.username) || this.stringMetadata(candidateArg, 'username');
const password = this.stringValue(valuesArg.password) || this.stringMetadata(candidateArg, 'password');
if (!username || password === undefined) {
return { kind: 'error', error: 'Foscam requires username and password.' };
}
const config: IFoscamConfig = {
protocol: endpoint.protocol,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
username,
password,
stream: this.streamValue(valuesArg.stream) || foscamStreamFromMetadata(candidateArg) || 'Main',
rtspPort: this.numberValue(valuesArg.rtspPort) || foscamRtspPortFromMetadata(candidateArg) || foscamDefaultRtspPort,
name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host,
uniqueId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || endpoint.host,
manufacturer: candidateArg.manufacturer || 'Foscam',
model: candidateArg.model,
timeoutMs: foscamDefaultTimeoutMs,
};
if (this.booleanValue(valuesArg.validateConnection)) {
try {
const snapshot = await new FoscamClient(config).validateConnection();
config.name = config.name || snapshot.deviceInfo.name;
config.model = config.model || snapshot.deviceInfo.model;
} catch (errorArg) {
if (errorArg instanceof FoscamApiAuthorizationError) {
return { kind: 'error', error: 'Invalid Foscam username or password.' };
}
if (errorArg instanceof FoscamApiConnectionError) {
return { kind: 'error', error: 'Could not connect to the Foscam camera.' };
}
return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
return {
kind: 'done',
title: 'Foscam camera configured',
config,
};
},
};
}
private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TFoscamProtocol | undefined): { protocol: TFoscamProtocol; host?: string; port: number; url?: string } {
const url = safeUrl(urlArg || hostArg);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : foscamDefaultPort;
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
}
const protocol = protocolArg || 'http';
const port = portArg || foscamDefaultPort;
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): TFoscamProtocol | undefined {
const protocol = candidateArg.metadata?.protocol;
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
}
private streamValue(valueArg: unknown): TFoscamStream | undefined {
return valueArg === 'Main' || valueArg === 'Sub' ? valueArg : undefined;
}
}
const safeUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
@@ -1,26 +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 { FoscamClient } from './foscam.classes.client.js';
import { FoscamConfigFlow } from './foscam.classes.configflow.js';
import { createFoscamDiscoveryDescriptor } from './foscam.discovery.js';
import { FoscamMapper } from './foscam.mapper.js';
import type { IFoscamConfig } from './foscam.types.js';
import { foscamDomain } from './foscam.types.js';
export class HomeAssistantFoscamIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "foscam",
displayName: "Foscam",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/foscam",
"upstreamDomain": "foscam",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"libpyfoscamcgi==0.0.9"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@Foscam-wangzhengyu"
]
},
});
export class FoscamIntegration extends BaseIntegration<IFoscamConfig> {
public readonly domain = foscamDomain;
public readonly displayName = 'Foscam';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createFoscamDiscoveryDescriptor();
public readonly configFlow = new FoscamConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/foscam',
upstreamDomain: foscamDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['libpyfoscamcgi==0.0.9'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@Foscam-wangzhengyu'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/foscam',
nativePort: {
manualLocalDiscovery: true,
mdnsMetadataMatching: true,
ssdpMetadataMatching: true,
snapshotMapping: true,
liveHttpCgiCommands: true,
liveEvents: false,
rtspProxying: false,
ffmpegProxying: false,
},
localApi: {
implemented: [
'Foscam CGIProxy.fcgi local HTTP commands used by libpyfoscamcgi 0.0.9 and the Home Assistant foscam integration',
'manual/local host config flow with Main/Sub stream and RTSP port options',
'camera snapshot image retrieval through snapPicture2 and RTSP stream source URL mapping',
'PTZ movement, PTZ preset, motion detection, upstream switch commands, and volume number commands where exposed by the camera',
],
explicitUnsupported: [
'live video proxying, RTSP proxying, and ffmpeg transcoding',
'fake live event or command success without a configured HTTP endpoint, injected client, or command executor',
],
},
};
public async setup(configArg: IFoscamConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new FoscamRuntime(new FoscamClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantFoscamIntegration extends FoscamIntegration {}
class FoscamRuntime implements IIntegrationRuntime {
public domain = foscamDomain;
constructor(private readonly client: FoscamClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return FoscamMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return FoscamMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
void handlerArg;
throw new Error('Foscam live event subscription is not implemented in this TypeScript port; use polling or explicit refreshes.');
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === foscamDomain && 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 = FoscamMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Foscam service: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute(command);
const ok = data && typeof data === 'object' && 'ok' in data ? Boolean((data as { ok?: unknown }).ok) : true;
return { success: ok, data };
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
+282
View File
@@ -0,0 +1,282 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IFoscamManualEntry, IFoscamMdnsRecord, IFoscamSsdpRecord, TFoscamProtocol, TFoscamStream } from './foscam.types.js';
import { foscamDefaultPort, foscamDefaultRtspPort, foscamDomain } from './foscam.types.js';
export class FoscamManualMatcher implements IDiscoveryMatcher<IFoscamManualEntry> {
public id = 'foscam-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Foscam local camera host or base URL entries.';
public async matches(inputArg: IFoscamManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const endpoint = endpointFromInput(inputArg);
const hint = hasFoscamHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.foscam);
if (!endpoint.host && !hint) {
return { matched: false, confidence: 'low', reason: 'Manual Foscam entry requires host, url, or Foscam metadata.' };
}
const normalizedDeviceId = normalizeMac(inputArg.macAddress || inputArg.id || inputArg.serialNumber) || inputArg.id || inputArg.serialNumber || endpoint.host || endpoint.url;
return {
matched: true,
confidence: endpoint.host ? 'high' : 'medium',
reason: endpoint.host ? 'Manual entry contains a local Foscam camera endpoint.' : 'Manual entry contains Foscam metadata.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: foscamDomain,
id: normalizedDeviceId,
host: endpoint.host,
port: endpoint.port,
name: inputArg.name || endpoint.host,
manufacturer: inputArg.manufacturer || 'Foscam',
model: inputArg.model,
serialNumber: inputArg.serialNumber,
macAddress: normalizeMac(inputArg.macAddress) || undefined,
metadata: {
...inputArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
username: inputArg.username,
password: inputArg.password,
stream: inputArg.stream,
rtspPort: inputArg.rtspPort,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export class FoscamMdnsMatcher implements IDiscoveryMatcher<IFoscamMdnsRecord> {
public id = 'foscam-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize local Foscam-like mDNS records by host, name, and TXT metadata.';
public async matches(recordArg: IFoscamMdnsRecord, 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, 'serialNumber');
const mac = normalizeMac(valueForKey(properties, 'mac') || valueForKey(properties, 'macAddress') || serial);
const name = cleanMdnsName(recordArg.name || recordArg.hostname);
const matched = hasFoscamHint(name, manufacturer, model, recordArg.type) || Boolean(valueForKey(properties, 'foscam'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Foscam camera hints.' };
}
return {
matched: true,
confidence: 'high',
reason: 'mDNS record contains Foscam camera metadata.',
normalizedDeviceId: mac || serial || recordArg.host || recordArg.addresses?.[0],
candidate: {
source: 'mdns',
integrationDomain: foscamDomain,
id: mac || serial || recordArg.host || recordArg.addresses?.[0],
host: recordArg.host || recordArg.addresses?.[0],
port: recordArg.port || foscamDefaultPort,
name: name || undefined,
manufacturer: manufacturer || 'Foscam',
model,
serialNumber: serial,
macAddress: mac || undefined,
metadata: {
mdnsName: recordArg.name,
mdnsType: recordArg.type,
txt: properties,
protocol: 'http' satisfies TFoscamProtocol,
},
},
};
}
}
export class FoscamSsdpMatcher implements IDiscoveryMatcher<IFoscamSsdpRecord> {
public id = 'foscam-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize local Foscam cameras from SSDP manufacturer and UPnP metadata.';
public async matches(recordArg: IFoscamSsdpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const upnp = { ...recordArg.headers, ...recordArg.upnp };
const manufacturer = recordArg.manufacturer || valueForKey(upnp, 'manufacturer') || '';
const model = valueForKey(upnp, 'modelName') || valueForKey(upnp, 'modelNumber');
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, 'serial') || recordArg.usn;
const mac = normalizeMac(serial);
const matched = hasFoscamHint(friendlyName, manufacturer, model, recordArg.server, recordArg.st);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record is not published by a Foscam-like camera.' };
}
return {
matched: true,
confidence: 'high',
reason: 'SSDP record contains Foscam camera metadata.',
normalizedDeviceId: mac || serial || url?.hostname,
candidate: {
source: 'ssdp',
integrationDomain: foscamDomain,
id: mac || serial || url?.hostname,
host: url?.hostname,
port: url?.port ? Number(url.port) : foscamDefaultPort,
name: friendlyName,
manufacturer: manufacturer || 'Foscam',
model,
serialNumber: serial,
macAddress: mac || undefined,
metadata: {
protocol: url?.protocol === 'https:' ? 'https' : 'http',
location,
ssdp: upnp,
},
},
};
}
}
export class FoscamCandidateValidator implements IDiscoveryValidator {
public id = 'foscam-candidate-validator';
public description = 'Validate that a candidate can be configured as a local Foscam camera.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== foscamDomain) {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Foscam.` };
}
const endpoint = endpointFromCandidate(candidateArg);
const mac = normalizeMac(candidateArg.macAddress || candidateArg.id || candidateArg.serialNumber);
const hasHint = candidateArg.integrationDomain === foscamDomain
|| candidateArg.source === 'manual'
|| hasFoscamHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model)
|| Boolean(candidateArg.metadata?.foscam);
if (!hasHint || !endpoint.host) {
return { matched: false, confidence: 'low', reason: 'Foscam candidates require a host plus manual or Foscam camera metadata.' };
}
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) {
return { matched: false, confidence: 'low', reason: 'Foscam candidate has an invalid port.' };
}
return {
matched: true,
confidence: candidateArg.source === 'manual' ? 'high' : 'medium',
reason: 'Candidate has enough local Foscam metadata to start configuration.',
normalizedDeviceId: candidateArg.id || mac || endpoint.host,
candidate: {
...candidateArg,
integrationDomain: foscamDomain,
id: candidateArg.id || mac || endpoint.host,
host: endpoint.host,
port: endpoint.port,
manufacturer: candidateArg.manufacturer || 'Foscam',
macAddress: candidateArg.macAddress || mac || undefined,
metadata: {
...candidateArg.metadata,
protocol: endpoint.protocol,
url: endpoint.url,
},
},
metadata: {
manualSupported: candidateArg.source === 'manual',
protocol: endpoint.protocol,
url: endpoint.url,
},
};
}
}
export const createFoscamDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: foscamDomain, displayName: 'Foscam' })
.addMatcher(new FoscamManualMatcher())
.addMatcher(new FoscamMdnsMatcher())
.addMatcher(new FoscamSsdpMatcher())
.addValidator(new FoscamCandidateValidator());
};
const endpointFromInput = (inputArg: IFoscamManualEntry): { protocol: TFoscamProtocol; host?: string; port: number; url?: string } => {
const url = safeUrl(inputArg.url || inputArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : foscamDefaultPort;
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
}
const protocol = inputArg.protocol || 'http';
const port = inputArg.port || foscamDefaultPort;
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TFoscamProtocol; host?: string; port: number; url?: string } => {
const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
const metadataProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
const url = safeUrl(metadataUrl || candidateArg.host);
if (url) {
const protocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : foscamDefaultPort;
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
}
const port = candidateArg.port || foscamDefaultPort;
return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}` : metadataUrl };
};
export const foscamStreamFromMetadata = (candidateArg: IDiscoveryCandidate): TFoscamStream | undefined => {
return candidateArg.metadata?.stream === 'Main' || candidateArg.metadata?.stream === 'Sub' ? candidateArg.metadata.stream : undefined;
};
export const foscamRtspPortFromMetadata = (candidateArg: IDiscoveryCandidate): number | undefined => {
const value = candidateArg.metadata?.rtspPort;
return typeof value === 'number' && Number.isInteger(value) ? value : typeof value === 'string' && value.trim() ? Number(value) || undefined : undefined;
};
export const foscamDefaultConfigFromCandidate = (candidateArg: IDiscoveryCandidate) => {
const endpoint = endpointFromCandidate(candidateArg);
return {
protocol: endpoint.protocol,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
stream: foscamStreamFromMetadata(candidateArg) || 'Main' as TFoscamStream,
rtspPort: foscamRtspPortFromMetadata(candidateArg) || foscamDefaultRtspPort,
};
};
const hasFoscamHint = (...valuesArgs: Array<string | undefined>): boolean => {
const haystack = valuesArgs.filter(Boolean).join(' ').toLowerCase();
return haystack.includes('foscam');
};
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 {
return new URL(valueArg);
} catch {
return undefined;
}
};
+353
View File
@@ -0,0 +1,353 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IFoscamCommandRequest,
IFoscamNumber,
IFoscamSnapshot,
IFoscamSwitch,
TFoscamEntityKey,
TFoscamPtzMovement,
} from './foscam.types.js';
import { foscamDomain, foscamPtzMovements } from './foscam.types.js';
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
const serviceBooleanKeys = ['enabled', 'enable', 'on', 'state', 'value'];
export class FoscamMapper {
public static toDevices(snapshotArg: IFoscamSnapshot): 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: 'camera', capability: 'camera', name: snapshotArg.camera.name || 'Camera', readable: true, writable: Boolean(snapshotArg.camera.supportsPtz) },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt },
{ featureId: 'camera', value: this.deviceStateValue({ snapshotUrl: snapshotArg.camera.snapshotUrl || null, rtspUrl: snapshotArg.camera.rtspUrl || null, stream: snapshotArg.camera.stream, motionDetectionEnabled: snapshotArg.camera.motionDetectionEnabled ?? null }), updatedAt },
];
for (const switchArg of snapshotArg.switches) {
features.push({ id: `switch_${this.slug(switchArg.key)}`, capability: 'switch', name: switchArg.name, readable: true, writable: true });
state.push({ featureId: `switch_${this.slug(switchArg.key)}`, value: switchArg.isOn, updatedAt });
}
for (const numberArg of snapshotArg.numbers) {
features.push({ id: `number_${this.slug(numberArg.key)}`, capability: 'sensor', name: numberArg.name, readable: true, writable: true });
state.push({ featureId: `number_${this.slug(numberArg.key)}`, value: numberArg.value, updatedAt });
}
return [{
id: this.deviceId(snapshotArg),
integrationDomain: foscamDomain,
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Foscam',
model: snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.productName,
online: snapshotArg.connected,
features,
state,
metadata: {
serialNumber: snapshotArg.deviceInfo.serialNumber,
macAddress: snapshotArg.deviceInfo.macAddress,
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
hardwareVersion: snapshotArg.deviceInfo.hardwareVersion,
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
protocol: snapshotArg.deviceInfo.protocol,
rtspPort: snapshotArg.deviceInfo.rtspPort,
stream: snapshotArg.camera.stream,
source: snapshotArg.source,
error: snapshotArg.error,
},
}];
}
public static toEntities(snapshotArg: IFoscamSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
const deviceId = this.deviceId(snapshotArg);
entities.push(this.entity('camera' as TEntityPlatform, snapshotArg.camera.name || `${this.deviceName(snapshotArg)} Camera`, deviceId, `${foscamDomain}_${this.uniqueBase(snapshotArg)}_camera`, snapshotArg.connected && snapshotArg.camera.available !== false ? 'idle' : 'unavailable', usedIds, {
cameraId: snapshotArg.camera.id,
stream: snapshotArg.camera.stream,
streamSourceUrl: snapshotArg.camera.streamSourceUrl,
snapshotUrl: snapshotArg.camera.snapshotUrl,
stillImageUrl: snapshotArg.camera.snapshotUrl,
rtspUrl: snapshotArg.camera.rtspUrl,
supportedFeatures: snapshotArg.camera.supportsPtz ? ['stream', 'snapshot', 'ptz', 'motion_detection'] : ['stream', 'snapshot', 'motion_detection'],
motionDetectionEnabled: snapshotArg.camera.motionDetectionEnabled,
serviceMappings: {
snapshot: 'camera.snapshot',
streamSource: 'camera.stream_source',
ptz: 'foscam.ptz',
ptzPreset: 'foscam.ptz_preset',
},
...snapshotArg.camera.attributes,
}, snapshotArg.connected && snapshotArg.camera.available !== false));
for (const switchArg of snapshotArg.switches) {
entities.push(this.entity('switch', switchArg.name, deviceId, `${foscamDomain}_${this.uniqueBase(snapshotArg)}_${this.slug(switchArg.key)}`, switchArg.isOn ? 'on' : 'off', usedIds, {
key: switchArg.key,
entityCategory: switchArg.entityCategory,
deviceClass: switchArg.deviceClass,
...switchArg.attributes,
}, snapshotArg.connected && switchArg.available !== false));
}
for (const numberArg of snapshotArg.numbers) {
entities.push(this.entity('number', numberArg.name, deviceId, `${foscamDomain}_${this.uniqueBase(snapshotArg)}_${this.slug(numberArg.key)}`, numberArg.value, usedIds, {
key: numberArg.key,
nativeMinValue: numberArg.min,
nativeMaxValue: numberArg.max,
nativeStep: numberArg.step,
unit: numberArg.unit,
entityCategory: numberArg.entityCategory,
...numberArg.attributes,
}, snapshotArg.connected && numberArg.available !== false));
}
return entities;
}
public static commandForService(snapshotArg: IFoscamSnapshot, requestArg: IServiceCallRequest): IFoscamCommandRequest | undefined {
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
return { type: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: snapshotArg.camera.id };
}
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
return { type: 'snapshot_image', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: snapshotArg.camera.id, filename: this.stringValue(requestArg.data?.filename), httpCommands: [{ label: 'snapshot', method: 'GET', command: 'snapPicture2', expect: 'image' }] };
}
if (requestArg.domain === 'camera' && (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection')) {
return { type: requestArg.service === 'enable_motion_detection' ? 'enable_motion_detection' : 'disable_motion_detection', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: 'motion_detection' };
}
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
const switchEntity = this.findSwitch(snapshotArg, requestArg);
if (!switchEntity) {
return undefined;
}
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !switchEntity.isOn;
return { type: 'set_switch', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: switchEntity.key, enabled, httpCommands: this.switchHttpCommands(switchEntity.key, enabled) };
}
if (requestArg.domain === 'number' && ['set_value', 'set_native_value'].includes(requestArg.service)) {
const numberEntity = this.findNumber(snapshotArg, requestArg);
const value = this.numberValue(requestArg.data?.value ?? requestArg.data?.native_value ?? requestArg.data?.nativeValue);
if (!numberEntity || value === undefined) {
return undefined;
}
return { type: 'set_number', service: requestArg.service, target: requestArg.target, data: requestArg.data, key: numberEntity.key, value, httpCommands: this.numberHttpCommands(numberEntity.key, value) };
}
if (requestArg.domain === foscamDomain) {
return this.foscamCommand(snapshotArg, requestArg);
}
return undefined;
}
public static deviceId(snapshotArg: IFoscamSnapshot): string {
return `${foscamDomain}.device.${this.uniqueBase(snapshotArg)}`;
}
private static foscamCommand(snapshotArg: IFoscamSnapshot, requestArg: IServiceCallRequest): IFoscamCommandRequest | undefined {
if (requestArg.service === 'refresh') {
return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'snapshot' || cameraSnapshotServices.has(requestArg.service)) {
return this.commandForService(snapshotArg, { ...requestArg, domain: 'camera' });
}
if (requestArg.service === 'stream_source' || cameraStreamServices.has(requestArg.service)) {
return this.commandForService(snapshotArg, { ...requestArg, domain: 'camera' });
}
if (requestArg.service === 'ptz') {
const movement = this.ptzMovement(requestArg.data?.movement);
if (!movement) {
return undefined;
}
return { type: 'ptz', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: snapshotArg.camera.id, movement, travelTime: this.numberValue(requestArg.data?.travel_time ?? requestArg.data?.travelTime) ?? 0.125 };
}
if (requestArg.service === 'ptz_preset') {
const presetName = this.stringValue(requestArg.data?.preset_name ?? requestArg.data?.presetName);
return presetName ? { type: 'ptz_preset', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: snapshotArg.camera.id, presetName, httpCommands: [{ label: 'ptz_preset', method: 'GET', command: 'ptzGotoPresetPoint', params: { name: presetName }, expect: 'ok' }] } : undefined;
}
if (requestArg.service === 'set_switch') {
const key = this.entityKey(requestArg.data?.key);
const enabled = this.booleanFromData(requestArg.data);
return key && enabled !== undefined ? { type: 'set_switch', service: requestArg.service, target: requestArg.target, data: requestArg.data, key, enabled, httpCommands: this.switchHttpCommands(key, enabled) } : undefined;
}
if (requestArg.service === 'set_number') {
const key = this.entityKey(requestArg.data?.key);
const value = this.numberValue(requestArg.data?.value);
return key && value !== undefined ? { type: 'set_number', service: requestArg.service, target: requestArg.target, data: requestArg.data, key, value, httpCommands: this.numberHttpCommands(key, value) } : undefined;
}
return undefined;
}
private static switchHttpCommands(keyArg: TFoscamEntityKey, enabledArg: boolean) {
if (keyArg === 'motion_detection') {
return [{ label: 'motion_detection', method: 'GET' as const, command: 'setMotionDetectConfig', params: { isEnable: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_flip') {
return [{ label: 'flip', method: 'GET' as const, command: 'flipVideo', params: { isFlip: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_mirror') {
return [{ label: 'mirror', method: 'GET' as const, command: 'mirrorVideo', params: { isMirror: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_open_ir') {
return [
{ label: 'infrared_mode', method: 'GET' as const, command: 'setInfraLedConfig', params: { mode: enabledArg ? 1 : 0 }, expect: 'ok' as const },
{ label: 'infrared_led', method: 'GET' as const, command: enabledArg ? 'openInfraLed' : 'closeInfraLed', expect: 'ok' as const },
];
}
if (keyArg === 'sleep_switch') {
return [{ label: 'sleep', method: 'GET' as const, command: enabledArg ? 'alexaSleep' : 'alexaWakeUp', expect: 'ok' as const }];
}
if (keyArg === 'is_open_white_light') {
return [{ label: 'white_light', method: 'GET' as const, command: enabledArg ? 'openWhiteLight' : 'closeWhiteLight', expect: 'ok' as const }];
}
if (keyArg === 'is_siren_alarm') {
return [{ label: 'siren_alarm', method: 'GET' as const, command: 'setSirenConfig', params: { sirenEnable: enabledArg ? 1 : 0, sirenvolume: 100, reserved: 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_turn_off_volume') {
return [{ label: 'volume_muted', method: 'GET' as const, command: 'setVoiceEnableState', params: { isEnable: enabledArg ? 0 : 1 }, expect: 'ok' as const }];
}
if (keyArg === 'is_turn_off_light') {
return [{ label: 'light', method: 'GET' as const, command: 'setLedEnableState', params: { isEnable: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_open_hdr') {
return [{ label: 'hdr', method: 'GET' as const, command: 'setHdrMode', params: { mode: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'is_open_wdr') {
return [{ label: 'wdr', method: 'GET' as const, command: 'setWdrMode', params: { mode: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
if (keyArg === 'pet_detection' || keyArg === 'car_detection' || keyArg === 'human_detection') {
const field = keyArg === 'pet_detection' ? 'petEnable' : keyArg === 'car_detection' ? 'carEnable' : 'humanEnable';
return [{ label: keyArg, method: 'GET' as const, command: 'setMotionDetectConfig', params: { [field]: enabledArg ? 1 : 0 }, expect: 'ok' as const }];
}
return [];
}
private static numberHttpCommands(keyArg: TFoscamEntityKey, valueArg: number) {
const value = Math.max(0, Math.min(100, Math.round(valueArg)));
if (keyArg === 'device_volume') {
return [{ label: 'device_volume', method: 'GET' as const, command: 'setAudioVolume', params: { volume: value }, expect: 'ok' as const }];
}
if (keyArg === 'speak_volume') {
return [{ label: 'speak_volume', method: 'GET' as const, command: 'setSpeakVolume', params: { SpeakVolume: value }, expect: 'ok' as const }];
}
return [];
}
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: foscamDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static findSwitch(snapshotArg: IFoscamSnapshot, requestArg: IServiceCallRequest): IFoscamSwitch | undefined {
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key);
if (!target) {
return snapshotArg.switches[0];
}
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch');
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target);
const key = this.stringValue(entity?.attributes?.key) || target;
return snapshotArg.switches.find((switchArg) => switchArg.key === key || switchArg.name === target || this.deviceId(snapshotArg) === target);
}
private static findNumber(snapshotArg: IFoscamSnapshot, requestArg: IServiceCallRequest): IFoscamNumber | undefined {
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key);
if (!target) {
return snapshotArg.numbers[0];
}
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'number');
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target);
const key = this.stringValue(entity?.attributes?.key) || target;
return snapshotArg.numbers.find((numberArg) => numberArg.key === key || numberArg.name === target || this.deviceId(snapshotArg) === target);
}
private static deviceName(snapshotArg: IFoscamSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Foscam Camera';
}
private static uniqueBase(snapshotArg: IFoscamSnapshot): string {
return this.slug(snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || 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 booleanFromData(dataArg: Record<string, unknown> | undefined): boolean | undefined {
for (const key of serviceBooleanKeys) {
const value = this.booleanValue(dataArg?.[key]);
if (value !== undefined) {
return value;
}
}
return undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
}
private static 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 stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
}
private static ptzMovement(valueArg: unknown): TFoscamPtzMovement | undefined {
const value = this.stringValue(valueArg)?.toLowerCase();
return value && foscamPtzMovements.includes(value as TFoscamPtzMovement) ? value as TFoscamPtzMovement : undefined;
}
private static entityKey(valueArg: unknown): TFoscamEntityKey | undefined {
const value = this.stringValue(valueArg) as TFoscamEntityKey | undefined;
return value ? value : undefined;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || foscamDomain;
}
}
+283 -3
View File
@@ -1,4 +1,284 @@
export interface IHomeAssistantFoscamConfig {
// TODO: replace with the TypeScript-native config for foscam.
[key: string]: unknown;
export const foscamDomain = 'foscam';
export const foscamDefaultPort = 88;
export const foscamDefaultRtspPort = 88;
export const foscamDefaultTimeoutMs = 10000;
export const foscamDefaultSnapshotTimeoutMs = 20000;
export const foscamStreams = ['Main', 'Sub'] as const;
export const foscamPtzMovements = [
'up',
'down',
'left',
'right',
'top_left',
'top_right',
'bottom_left',
'bottom_right',
] as const;
export type TFoscamProtocol = 'http' | 'https';
export type TFoscamStream = typeof foscamStreams[number];
export type TFoscamPtzMovement = typeof foscamPtzMovements[number];
export type TFoscamEntityKey =
| 'motion_detection'
| 'is_flip'
| 'is_mirror'
| 'is_open_ir'
| 'sleep_switch'
| 'is_open_white_light'
| 'is_siren_alarm'
| 'is_turn_off_volume'
| 'is_turn_off_light'
| 'is_open_hdr'
| 'is_open_wdr'
| 'pet_detection'
| 'car_detection'
| 'human_detection'
| 'device_volume'
| 'speak_volume';
export type TFoscamCommandType =
| 'refresh'
| 'stream_source'
| 'snapshot_image'
| 'ptz'
| 'ptz_preset'
| 'set_switch'
| 'set_number'
| 'enable_motion_detection'
| 'disable_motion_detection';
export interface IFoscamConfig {
protocol?: TFoscamProtocol;
host?: string;
port?: number;
url?: string;
rtspPort?: number;
username?: string;
password?: string;
stream?: TFoscamStream;
timeoutMs?: number;
snapshotTimeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
connected?: boolean;
deviceInfo?: IFoscamDeviceInfo;
camera?: Partial<IFoscamCamera>;
switches?: IFoscamSwitch[];
numbers?: IFoscamNumber[];
currentSettings?: Partial<Record<TFoscamEntityKey, unknown>>;
snapshot?: IFoscamSnapshot;
client?: IFoscamClientLike;
commandExecutor?: IFoscamCommandExecutor;
}
export interface IHomeAssistantFoscamConfig extends IFoscamConfig {}
export interface IFoscamDeviceInfo {
id?: string;
name?: string;
manufacturer?: string;
model?: string;
productName?: string;
serialNumber?: string;
macAddress?: string;
firmwareVersion?: string;
hardwareVersion?: string;
host?: string;
port?: number;
protocol?: TFoscamProtocol;
rtspPort?: number;
url?: string;
online?: boolean;
productInfo?: Record<string, unknown>;
rawDevInfo?: Record<string, unknown>;
}
export interface IFoscamCamera {
id: string;
name?: string;
stream: TFoscamStream;
snapshotUrl?: string;
rtspUrl?: string;
streamSourceUrl?: string;
available?: boolean;
supportsPtz?: boolean;
motionDetectionEnabled?: boolean;
attributes?: Record<string, unknown>;
}
export interface IFoscamSwitch {
key: Exclude<TFoscamEntityKey, 'device_volume' | 'speak_volume'>;
name: string;
isOn: boolean;
available?: boolean;
exists?: boolean;
entityCategory?: string;
deviceClass?: string;
attributes?: Record<string, unknown>;
}
export interface IFoscamNumber {
key: Extract<TFoscamEntityKey, 'device_volume' | 'speak_volume'>;
name: string;
value: number;
min: number;
max: number;
step: number;
available?: boolean;
exists?: boolean;
entityCategory?: string;
unit?: string;
attributes?: Record<string, unknown>;
}
export interface IFoscamSnapshot {
deviceInfo: IFoscamDeviceInfo;
camera: IFoscamCamera;
switches: IFoscamSwitch[];
numbers: IFoscamNumber[];
currentSettings: Partial<Record<TFoscamEntityKey, unknown>>;
connected: boolean;
updatedAt?: string;
source?: 'http' | 'snapshot' | 'client' | 'manual' | 'offline';
error?: string;
metadata?: Record<string, unknown>;
}
export interface IFoscamSnapshotImage {
contentType: string;
data: Uint8Array;
}
export interface IFoscamCommandRequest {
type: TFoscamCommandType;
service: string;
target?: {
entityId?: string;
deviceId?: string;
};
data?: Record<string, unknown>;
cameraId?: string;
key?: TFoscamEntityKey;
enabled?: boolean;
value?: number;
movement?: TFoscamPtzMovement;
travelTime?: number;
presetName?: string;
filename?: string;
httpCommands?: IFoscamHttpCommand[];
}
export interface IFoscamHttpCommand {
label: string;
method: 'GET';
command: string;
params?: Record<string, string | number | boolean>;
expect?: 'ok' | 'image' | 'text';
}
export interface IFoscamCommandResponse {
ok: boolean;
label: string;
method: 'GET';
command: string;
params?: Record<string, string | number | boolean>;
result: number;
data?: Record<string, string | undefined>;
}
export interface IFoscamRefreshResult {
success: boolean;
snapshot: IFoscamSnapshot;
error?: string;
data?: Record<string, unknown>;
}
export interface IFoscamClientLike {
getSnapshot?(): Promise<IFoscamSnapshot | Record<string, unknown>>;
getSnapshotImage?(): Promise<IFoscamSnapshotImage>;
execute?(commandArg: IFoscamCommandRequest): Promise<unknown>;
getProductAllInfo?(): Promise<Record<string, unknown>>;
getDevInfo?(): Promise<Record<string, unknown>>;
}
export interface IFoscamCommandExecutor {
execute(commandArg: IFoscamCommandRequest): Promise<unknown>;
}
export interface IFoscamManualEntry {
host?: string;
port?: number;
url?: string;
protocol?: TFoscamProtocol;
username?: string;
password?: string;
stream?: TFoscamStream;
rtspPort?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
export interface IFoscamMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IFoscamSsdpRecord {
manufacturer?: string;
server?: string;
st?: string;
usn?: string;
location?: string;
upnp?: Record<string, string | undefined>;
headers?: Record<string, string | undefined>;
}
export interface IFoscamSwitchDescription {
key: IFoscamSwitch['key'];
name: string;
entityCategory?: string;
}
export interface IFoscamNumberDescription {
key: IFoscamNumber['key'];
name: string;
min: number;
max: number;
step: number;
entityCategory?: string;
}
export const foscamSwitchDescriptions: IFoscamSwitchDescription[] = [
{ key: 'motion_detection', name: 'Motion Detection' },
{ key: 'is_flip', name: 'Flip', entityCategory: 'config' },
{ key: 'is_mirror', name: 'Mirror', entityCategory: 'config' },
{ key: 'is_open_ir', name: 'Infrared Mode', entityCategory: 'config' },
{ key: 'sleep_switch', name: 'Sleep Mode', entityCategory: 'config' },
{ key: 'is_open_white_light', name: 'White Light', entityCategory: 'config' },
{ key: 'is_siren_alarm', name: 'Siren Alarm', entityCategory: 'config' },
{ key: 'is_turn_off_volume', name: 'Volume Muted', entityCategory: 'config' },
{ key: 'is_turn_off_light', name: 'Light', entityCategory: 'config' },
{ key: 'is_open_hdr', name: 'HDR', entityCategory: 'config' },
{ key: 'is_open_wdr', name: 'WDR', entityCategory: 'config' },
{ key: 'pet_detection', name: 'Pet Detection', entityCategory: 'config' },
{ key: 'car_detection', name: 'Car Detection', entityCategory: 'config' },
{ key: 'human_detection', name: 'Human Detection', entityCategory: 'config' },
];
export const foscamNumberDescriptions: IFoscamNumberDescription[] = [
{ key: 'device_volume', name: 'Device Volume', min: 0, max: 100, step: 1, entityCategory: 'config' },
{ key: 'speak_volume', name: 'Speak Volume', min: 0, max: 100, step: 1, entityCategory: 'config' },
];
+4
View File
@@ -1,2 +1,6 @@
export * from './foscam.classes.client.js';
export * from './foscam.classes.configflow.js';
export * from './foscam.classes.integration.js';
export * from './foscam.discovery.js';
export * from './foscam.mapper.js';
export * from './foscam.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,458 @@
import type {
IFullyKioskClientCommand,
IFullyKioskConfig,
IFullyKioskDeviceInfo,
IFullyKioskRestObject,
IFullyKioskSettings,
IFullyKioskSnapshot,
IFullyKioskSnapshotImage,
TFullyKioskImageKind,
TFullyKioskProtocol,
} from './fully_kiosk.types.js';
import { fullyKioskDefaultPort, fullyKioskDefaultTimeoutMs } from './fully_kiosk.types.js';
export class FullyKioskApiError extends Error {}
export class FullyKioskConnectionError extends FullyKioskApiError {}
export class FullyKioskClient {
private currentSnapshot?: IFullyKioskSnapshot;
constructor(private readonly config: IFullyKioskConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IFullyKioskSnapshot> {
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();
} catch (errorArg) {
this.currentSnapshot = this.snapshotFromConfig(false, errorMessage(errorArg), 'client');
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasLiveTarget()) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.snapshotFromConfig(false, errorMessage(errorArg), 'runtime');
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasManualData()) {
this.currentSnapshot = this.snapshotFromConfig(this.config.online ?? true, undefined, 'manual');
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.snapshotFromConfig(false, 'No Fully Kiosk host/password, injected client, or snapshot/manual data is configured.', 'runtime');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<{ success: boolean; snapshot: IFullyKioskSnapshot; data?: unknown; error?: string }> {
if (this.config.commandExecutor) {
const data = await this.execute({ type: 'refresh', service: 'refresh' });
const snapshot = await this.getSnapshot(true);
return { success: true, snapshot, data };
}
if (!this.config.client && !this.hasLiveTarget()) {
const snapshot = await this.getSnapshot();
return {
success: false,
snapshot,
error: 'Fully Kiosk refresh requires config.host plus config.password, config.client, or commandExecutor.',
};
}
const snapshot = await this.getSnapshot(true);
return {
success: snapshot.online && !snapshot.error,
snapshot,
error: snapshot.error,
data: { source: snapshot.source },
};
}
public async validateConnection(): Promise<IFullyKioskDeviceInfo> {
return this.getDeviceInfo();
}
public async getDeviceInfo(): Promise<IFullyKioskDeviceInfo> {
const data = await this.sendCommand<IFullyKioskDeviceInfo>('deviceInfo');
if (!isRecord(data)) {
throw new FullyKioskConnectionError('Fully Kiosk deviceInfo response was not a JSON object.');
}
return data as IFullyKioskDeviceInfo;
}
public async getSettings(): Promise<IFullyKioskSettings> {
const data = await this.sendCommand<IFullyKioskSettings>('listSettings');
if (!isRecord(data)) {
throw new FullyKioskConnectionError('Fully Kiosk listSettings response was not a JSON object.');
}
return data as IFullyKioskSettings;
}
public async sendCommand<TResponse = unknown>(cmdArg: string, paramsArg: Record<string, unknown> = {}): Promise<TResponse> {
const result = await this.requestCommand<TResponse>(cmdArg, paramsArg);
return result as TResponse;
}
public async getImage(kindArg: TFullyKioskImageKind): Promise<IFullyKioskSnapshotImage> {
const result = await this.requestCommand<unknown>(kindArg === 'screenshot' ? 'getScreenshot' : 'getCamshot');
if (isSnapshotImage(result)) {
return result;
}
throw new FullyKioskConnectionError(`Fully Kiosk ${kindArg} response was not image data.`);
}
public async execute(commandArg: IFullyKioskClientCommand): Promise<unknown> {
if (this.config.commandExecutor) {
return typeof this.config.commandExecutor === 'function'
? this.config.commandExecutor(commandArg)
: this.config.commandExecutor.execute(commandArg);
}
if (this.config.client?.execute) {
return this.config.client.execute(commandArg);
}
if (commandArg.type === 'refresh') {
return this.refresh();
}
if (this.config.client) {
return this.executeWithClient(commandArg);
}
if (!this.hasLiveTarget()) {
throw new FullyKioskConnectionError('Fully Kiosk commands require config.host plus config.password, config.client, or commandExecutor.');
}
if (commandArg.type === 'snapshot_image') {
const image = await this.getImage(commandArg.imageKind || 'screenshot');
return { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') };
}
if (commandArg.type === 'stream_source') {
return {
baseUrl: this.baseUrl(),
remoteAdminUrl: this.baseUrl(),
verified: false,
};
}
if (commandArg.type === 'set_string_setting') {
if (!commandArg.settingKey) {
throw new FullyKioskApiError('Fully Kiosk set_string_setting requires settingKey.');
}
return this.sendCommand('setStringSetting', { key: commandArg.settingKey, value: String(commandArg.value ?? '') });
}
if (commandArg.type === 'set_boolean_setting') {
if (!commandArg.settingKey) {
throw new FullyKioskApiError('Fully Kiosk set_boolean_setting requires settingKey.');
}
return this.sendCommand('setBooleanSetting', { key: commandArg.settingKey, value: Boolean(commandArg.value) });
}
if (commandArg.type === 'send_command' && commandArg.cmd) {
return this.sendCommand(commandArg.cmd, commandArg.params || {});
}
throw new FullyKioskApiError(`Unsupported Fully Kiosk command: ${commandArg.type}`);
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async fetchSnapshot(): Promise<IFullyKioskSnapshot> {
const [deviceInfo, settings] = await Promise.all([
this.getDeviceInfo(),
this.getSettings().catch(() => ({} as IFullyKioskSettings)),
]);
return this.normalizeSnapshot({
deviceInfo: { ...deviceInfo, settings },
settings,
online: true,
updatedAt: new Date().toISOString(),
source: 'http',
host: this.config.host,
port: this.config.port || fullyKioskDefaultPort,
protocol: this.protocol(),
ssl: this.protocol() === 'https',
verifySsl: this.config.verifySsl,
name: this.config.name,
uniqueId: this.config.uniqueId,
macAddress: this.config.macAddress,
}, 'http');
}
private async snapshotFromClient(): Promise<IFullyKioskSnapshot> {
const client = this.config.client;
if (!client) {
throw new FullyKioskConnectionError('Fully Kiosk injected client is not configured.');
}
if (client.getSnapshot) {
return this.normalizeSnapshot(await client.getSnapshot(), 'client');
}
if (!client.getDeviceInfo) {
throw new FullyKioskConnectionError('Fully Kiosk injected client must expose getSnapshot or getDeviceInfo.');
}
const [deviceInfo, settings] = await Promise.all([
client.getDeviceInfo(),
client.getSettings ? client.getSettings().catch(() => ({} as IFullyKioskSettings)) : Promise.resolve({} as IFullyKioskSettings),
]);
return this.normalizeSnapshot({
deviceInfo: { ...deviceInfo, settings },
settings,
online: true,
updatedAt: new Date().toISOString(),
source: 'client',
host: this.config.host,
port: this.config.port || fullyKioskDefaultPort,
protocol: this.protocol(),
ssl: this.protocol() === 'https',
verifySsl: this.config.verifySsl,
name: this.config.name,
uniqueId: this.config.uniqueId,
macAddress: this.config.macAddress,
}, 'client');
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string, sourceArg: IFullyKioskSnapshot['source'] = 'runtime'): IFullyKioskSnapshot {
const snapshot = this.config.snapshot;
const settings = this.config.settings || snapshot?.settings || record(this.config.deviceInfo?.settings) || {};
const deviceInfo: IFullyKioskDeviceInfo = {
deviceID: this.config.uniqueId || snapshot?.uniqueId || snapshot?.deviceInfo.deviceID || snapshot?.deviceInfo.deviceId || this.config.macAddress || this.hostId(),
deviceName: this.config.name || snapshot?.name || snapshot?.deviceInfo.deviceName || 'Fully Kiosk Browser',
deviceManufacturer: this.config.manufacturer || snapshot?.deviceInfo.deviceManufacturer || 'Fully Kiosk',
deviceModel: this.config.model || snapshot?.deviceInfo.deviceModel || 'Android kiosk',
Mac: this.config.macAddress || snapshot?.macAddress || snapshot?.deviceInfo.Mac || snapshot?.deviceInfo.mac,
ip4: this.config.host || snapshot?.host || snapshot?.deviceInfo.ip4,
...snapshot?.deviceInfo,
...this.config.deviceInfo,
settings,
};
return this.normalizeSnapshot({
deviceInfo,
settings,
online: onlineArg,
updatedAt: new Date().toISOString(),
source: sourceArg,
host: this.config.host || snapshot?.host,
port: this.config.port || snapshot?.port || (this.config.host || snapshot?.host ? fullyKioskDefaultPort : undefined),
protocol: this.protocol(snapshot),
ssl: this.protocol(snapshot) === 'https',
verifySsl: this.config.verifySsl ?? snapshot?.verifySsl,
name: this.config.name || snapshot?.name,
uniqueId: this.config.uniqueId || snapshot?.uniqueId,
macAddress: this.config.macAddress || snapshot?.macAddress,
error: errorArg,
metadata: {
...snapshot?.metadata,
...this.config.metadata,
},
}, sourceArg);
}
private normalizeSnapshot(snapshotArg: IFullyKioskSnapshot, sourceArg: IFullyKioskSnapshot['source']): IFullyKioskSnapshot {
const settings = snapshotArg.settings || record(snapshotArg.deviceInfo.settings) || {};
const deviceInfo = {
...snapshotArg.deviceInfo,
settings,
};
const macAddress = normalizeMac(snapshotArg.macAddress || deviceInfo.Mac || deviceInfo.mac);
const protocol = snapshotArg.protocol || this.protocol(snapshotArg);
return {
...snapshotArg,
deviceInfo,
settings,
online: Boolean(snapshotArg.online),
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
host: snapshotArg.host || this.config.host || deviceInfo.ip4 || deviceInfo.hostname4,
port: snapshotArg.port || this.config.port || (snapshotArg.host || this.config.host ? fullyKioskDefaultPort : undefined),
protocol,
ssl: protocol === 'https',
verifySsl: snapshotArg.verifySsl ?? this.config.verifySsl,
name: snapshotArg.name || this.config.name || deviceInfo.deviceName,
uniqueId: snapshotArg.uniqueId || this.config.uniqueId || deviceInfo.deviceID || deviceInfo.deviceId || macAddress,
macAddress,
};
}
private async executeWithClient(commandArg: IFullyKioskClientCommand): Promise<unknown> {
const client = this.config.client;
if (!client) {
throw new FullyKioskConnectionError('Fully Kiosk injected client is not configured.');
}
if (commandArg.type === 'refresh') {
return this.refresh();
}
if (commandArg.type === 'snapshot_image') {
const imageData = commandArg.imageKind === 'camshot'
? await client.getCamshot?.()
: await client.getScreenshot?.();
if (!imageData) {
throw new FullyKioskApiError('Fully Kiosk injected client cannot return the requested image.');
}
return { contentType: 'image/png', dataBase64: Buffer.from(toUint8Array(imageData)).toString('base64') };
}
if (commandArg.type === 'set_string_setting') {
if (!commandArg.settingKey || !client.sendCommand) {
throw new FullyKioskApiError('Fully Kiosk injected client cannot set string settings.');
}
return client.sendCommand('setStringSetting', { key: commandArg.settingKey, value: String(commandArg.value ?? '') });
}
if (commandArg.type === 'set_boolean_setting') {
if (!commandArg.settingKey || !client.sendCommand) {
throw new FullyKioskApiError('Fully Kiosk injected client cannot set boolean settings.');
}
return client.sendCommand('setBooleanSetting', { key: commandArg.settingKey, value: Boolean(commandArg.value) });
}
if (commandArg.type === 'send_command' && commandArg.cmd && client.sendCommand) {
return client.sendCommand(commandArg.cmd, commandArg.params || {});
}
throw new FullyKioskApiError('Fully Kiosk command is not available on the injected client and no HTTP endpoint or commandExecutor is configured.');
}
private async requestCommand<TResponse>(cmdArg: string, paramsArg: Record<string, unknown> = {}): Promise<TResponse | IFullyKioskSnapshotImage> {
if (!this.hasLiveTarget()) {
throw new FullyKioskConnectionError('Fully Kiosk HTTP requests require config.host and config.password.');
}
const url = new URL(this.baseUrl());
url.searchParams.set('cmd', cmdArg);
url.searchParams.set('password', this.config.password || '');
url.searchParams.set('type', 'json');
for (const [key, value] of Object.entries(paramsArg)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, paramValue(value));
}
}
let response: Response;
try {
response = await globalThis.fetch(url, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(this.config.timeoutMs || fullyKioskDefaultTimeoutMs),
});
} catch (errorArg) {
throw new FullyKioskConnectionError(errorMessage(errorArg));
}
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
throw new FullyKioskConnectionError(`Fully Kiosk request ${cmdArg} failed with HTTP ${response.status}: ${await response.text()}`);
}
if (contentType.startsWith('image/') || contentType === 'application/octet-stream') {
return {
contentType: contentType || 'application/octet-stream',
data: new Uint8Array(await response.arrayBuffer()),
};
}
const text = await response.text();
if (!text) {
return {} as TResponse;
}
let data: unknown;
try {
data = JSON.parse(text);
} catch (errorArg) {
throw new FullyKioskConnectionError(`Fully Kiosk request ${cmdArg} returned invalid JSON: ${errorMessage(errorArg)}`);
}
if (isErrorResponse(data)) {
throw new FullyKioskApiError(String(data.statustext || 'Fully Kiosk API returned Error.'));
}
return data as TResponse;
}
private hasLiveTarget(): boolean {
return Boolean(this.config.host && typeof this.config.password === 'string');
}
private hasManualData(): boolean {
return Boolean(this.config.deviceInfo || this.config.settings || this.config.snapshot);
}
private baseUrl(): string {
if (!this.config.host) {
throw new FullyKioskConnectionError('Fully Kiosk host is required for HTTP API access.');
}
return `${this.protocol()}://${this.config.host}:${this.config.port || fullyKioskDefaultPort}`;
}
private protocol(snapshotArg?: Pick<IFullyKioskSnapshot, 'protocol' | 'ssl'>): TFullyKioskProtocol {
if (this.config.protocol) {
return this.config.protocol;
}
if (typeof this.config.ssl === 'boolean') {
return this.config.ssl ? 'https' : 'http';
}
if (snapshotArg?.protocol) {
return snapshotArg.protocol;
}
if (snapshotArg?.ssl) {
return 'https';
}
return 'http';
}
private hostId(): string | undefined {
return this.config.host ? `${this.config.host}:${this.config.port || fullyKioskDefaultPort}` : undefined;
}
private cloneSnapshot(snapshotArg: IFullyKioskSnapshot): IFullyKioskSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IFullyKioskSnapshot;
}
}
export const normalizeMac = (valueArg: unknown): string | undefined => {
if (typeof valueArg !== 'string') {
return undefined;
}
const hex = valueArg.toLowerCase().replace(/[^a-f0-9]/g, '');
if (hex.length !== 12) {
return valueArg.trim().toLowerCase() || undefined;
}
return hex.match(/.{1,2}/g)?.join(':');
};
const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg);
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
const record = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isErrorResponse = (valueArg: unknown): valueArg is IFullyKioskRestObject => {
return isRecord(valueArg) && valueArg.status === 'Error';
};
const isSnapshotImage = (valueArg: unknown): valueArg is IFullyKioskSnapshotImage => {
return isRecord(valueArg) && typeof valueArg.contentType === 'string' && valueArg.data instanceof Uint8Array;
};
const paramValue = (valueArg: unknown): string => {
if (typeof valueArg === 'boolean') {
return valueArg ? 'true' : 'false';
}
return String(valueArg);
};
const toUint8Array = (valueArg: Uint8Array | ArrayBuffer | Buffer): Uint8Array => {
if (valueArg instanceof Uint8Array) {
return valueArg;
}
return new Uint8Array(valueArg);
};
@@ -0,0 +1,141 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IFullyKioskConfig, IFullyKioskDeviceInfo, IFullyKioskSettings, IFullyKioskSnapshot, TFullyKioskProtocol } from './fully_kiosk.types.js';
import { fullyKioskDefaultPort, fullyKioskDefaultTimeoutMs } from './fully_kiosk.types.js';
export class FullyKioskConfigFlow implements IConfigFlow<IFullyKioskConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IFullyKioskConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Fully Kiosk Browser',
description: 'Configure a local Fully Kiosk REST endpoint. Remote Admin must be enabled in Fully Kiosk and live control requires the Remote Admin password.',
fields: [
{ name: 'host', label: 'Host or URL', type: 'text' },
{ name: 'port', label: 'Remote Admin port', type: 'number' },
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
{ name: 'password', label: 'Remote Admin 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<IFullyKioskConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || stringMetadata(metadata, 'url') || candidateArg.host);
const snapshot = snapshotValue(metadata.snapshot);
const deviceInfo = deviceInfoValue(metadata.deviceInfo);
const settings = settingsValue(metadata.settings);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.host || snapshot?.deviceInfo.ip4;
const protocol = parsed?.protocol || (this.booleanValue(valuesArg.ssl) ?? booleanMetadata(metadata, 'ssl') ? 'https' : 'http');
const port = this.numberValue(valuesArg.port) || candidateArg.port || parsed?.port || snapshot?.port || fullyKioskDefaultPort;
const password = this.stringValue(valuesArg.password) || stringMetadata(metadata, 'password');
const hasManualData = Boolean(snapshot || deviceInfo || settings || metadata.client);
if (!host && !hasManualData) {
return { kind: 'error', title: 'Fully Kiosk setup failed', error: 'Fully Kiosk setup requires a host, injected client, snapshot, or device info.' };
}
if (!validPort(port)) {
return { kind: 'error', title: 'Fully Kiosk setup failed', error: 'Fully Kiosk port must be between 1 and 65535.' };
}
if (host && !password && !metadata.client) {
return { kind: 'error', title: 'Fully Kiosk setup failed', error: 'Fully Kiosk live local REST access requires the Remote Admin password.' };
}
return {
kind: 'done',
title: 'Fully Kiosk configured',
config: {
host,
port,
protocol,
ssl: protocol === 'https',
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? booleanMetadata(metadata, 'verifySsl') ?? false,
password,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.name || deviceInfo?.deviceName,
uniqueId: candidateArg.id || snapshot?.uniqueId || deviceInfo?.deviceID || deviceInfo?.deviceId || candidateArg.macAddress || (host ? `${host}:${port}` : undefined),
macAddress: candidateArg.macAddress || snapshot?.macAddress || deviceInfo?.Mac || deviceInfo?.mac || stringMetadata(metadata, 'macAddress') || stringMetadata(metadata, 'macaddress'),
manufacturer: candidateArg.manufacturer || snapshot?.deviceInfo.deviceManufacturer || deviceInfo?.deviceManufacturer || 'Fully Kiosk',
model: candidateArg.model || snapshot?.deviceInfo.deviceModel || deviceInfo?.deviceModel,
timeoutMs: fullyKioskDefaultTimeoutMs,
snapshot,
deviceInfo,
settings,
client: metadata.client as IFullyKioskConfig['client'],
commandExecutor: metadata.commandExecutor as IFullyKioskConfig['commandExecutor'],
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
}
return undefined;
}
private booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
}
}
const parseEndpoint = (valueArg: string | undefined): { protocol: TFullyKioskProtocol; host: string; port?: number } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
host: url.hostname,
port: url.port ? Number(url.port) : undefined,
};
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IFullyKioskSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'deviceInfo' in valueArg && 'settings' in valueArg ? valueArg as IFullyKioskSnapshot : undefined;
};
const deviceInfoValue = (valueArg: unknown): IFullyKioskDeviceInfo | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IFullyKioskDeviceInfo : undefined;
};
const settingsValue = (valueArg: unknown): IFullyKioskSettings | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IFullyKioskSettings : undefined;
};
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const booleanMetadata = (metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'boolean' ? value : undefined;
};
const validPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
@@ -1,29 +1,88 @@
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 { FullyKioskClient } from './fully_kiosk.classes.client.js';
import { FullyKioskConfigFlow } from './fully_kiosk.classes.configflow.js';
import { createFullyKioskDiscoveryDescriptor } from './fully_kiosk.discovery.js';
import { FullyKioskMapper } from './fully_kiosk.mapper.js';
import type { IFullyKioskConfig } from './fully_kiosk.types.js';
export class HomeAssistantFullyKioskIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "fully_kiosk",
displayName: "Fully Kiosk Browser",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/fully_kiosk",
"upstreamDomain": "fully_kiosk",
"integrationType": "device",
"iotClass": "local_polling",
"qualityScale": "bronze",
"requirements": [
"python-fullykiosk==0.0.15"
],
"dependencies": [],
"afterDependencies": [
"mqtt"
],
"codeowners": [
"@cgarwood"
]
},
});
export class FullyKioskIntegration extends BaseIntegration<IFullyKioskConfig> {
public readonly domain = 'fully_kiosk';
public readonly displayName = 'Fully Kiosk Browser';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createFullyKioskDiscoveryDescriptor();
public readonly configFlow = new FullyKioskConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/fully_kiosk',
upstreamDomain: 'fully_kiosk',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: 'bronze',
requirements: ['python-fullykiosk==0.0.15'],
dependencies: [] as string[],
afterDependencies: ['mqtt'],
codeowners: ['@cgarwood'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/fully_kiosk',
nativePort: {
discovery: ['manual', 'dhcp'],
snapshotMapping: true,
localRestApi: true,
liveCommandSuccessRequiresHostPasswordClientOrExecutor: true,
mqttEvents: false,
},
};
public async setup(configArg: IFullyKioskConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new FullyKioskRuntime(new FullyKioskClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantFullyKioskIntegration extends FullyKioskIntegration {}
class FullyKioskRuntime implements IIntegrationRuntime {
public domain = 'fully_kiosk';
constructor(private readonly client: FullyKioskClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return FullyKioskMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return FullyKioskMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'fully_kiosk' && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === 'fully_kiosk' && requestArg.service === 'refresh') {
const result = await this.client.refresh();
return { success: result.success, data: result.snapshot, error: result.error };
}
const snapshot = await this.client.getSnapshot();
const command = FullyKioskMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported Fully Kiosk service mapping: ${requestArg.domain}.${requestArg.service}` };
}
if ('error' in command) {
return { success: false, error: command.error };
}
return { success: true, data: await this.client.execute(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,245 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { normalizeMac } from './fully_kiosk.classes.client.js';
import type { IFullyKioskDhcpEntry, IFullyKioskDeviceInfo, IFullyKioskManualEntry, IFullyKioskSnapshot, TFullyKioskProtocol } from './fully_kiosk.types.js';
import { fullyKioskDefaultPort, fullyKioskDomain } from './fully_kiosk.types.js';
export class FullyKioskManualMatcher implements IDiscoveryMatcher<IFullyKioskManualEntry> {
public id = 'fully-kiosk-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Fully Kiosk Browser host, URL, snapshot, or injected client entries.';
public async matches(inputArg: IFullyKioskManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const endpoint = endpointFromManual(inputArg);
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const deviceInfo = deviceInfoValue(inputArg.deviceInfo || metadata.deviceInfo);
const macAddress = normalizeMac(inputArg.macAddress || stringMetadata(metadata, 'macAddress') || stringMetadata(metadata, 'macaddress') || snapshot?.macAddress || snapshot?.deviceInfo.Mac || deviceInfo?.Mac);
const hasManualData = Boolean(snapshot || deviceInfo || inputArg.settings || inputArg.client || metadata.client);
const matched = Boolean(endpoint.host || hasManualData || isFullyKioskHint(inputArg) || isFullyKioskHint({ ...inputArg, metadata }));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Fully Kiosk setup hints.' };
}
const id = inputArg.id || snapshot?.uniqueId || snapshot?.deviceInfo.deviceID || snapshot?.deviceInfo.deviceId || deviceInfo?.deviceID || macAddress || (endpoint.host ? `${endpoint.host}:${endpoint.port || fullyKioskDefaultPort}` : undefined);
return {
matched: true,
confidence: endpoint.host || hasManualData ? 'high' : 'medium',
reason: endpoint.host ? 'Manual entry contains a local Fully Kiosk REST endpoint.' : 'Manual entry contains Fully Kiosk metadata.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: fullyKioskDomain,
id,
host: endpoint.host,
port: endpoint.port || fullyKioskDefaultPort,
name: inputArg.name || snapshot?.name || snapshot?.deviceInfo.deviceName || deviceInfo?.deviceName || endpoint.host,
manufacturer: inputArg.manufacturer || snapshot?.deviceInfo.deviceManufacturer || deviceInfo?.deviceManufacturer || 'Fully Kiosk',
model: inputArg.model || snapshot?.deviceInfo.deviceModel || deviceInfo?.deviceModel,
macAddress,
metadata: {
...metadata,
fullyKiosk: true,
fully_kiosk: true,
protocol: endpoint.protocol,
ssl: endpoint.protocol === 'https',
url: endpoint.url,
password: inputArg.password || stringMetadata(metadata, 'password'),
snapshot,
deviceInfo,
settings: inputArg.settings || metadata.settings,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
discoveryProtocol: 'manual',
},
},
metadata: {
protocol: endpoint.protocol,
url: endpoint.url,
manualSupported: true,
},
};
}
}
export class FullyKioskDhcpMatcher implements IDiscoveryMatcher<IFullyKioskDhcpEntry> {
public id = 'fully-kiosk-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize DHCP updates for previously known Fully Kiosk devices or DHCP entries carrying Fully Kiosk hints.';
public async matches(inputArg: IFullyKioskDhcpEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const host = inputArg.ip || inputArg.ipAddress || inputArg.host;
const macAddress = normalizeMac(inputArg.macAddress || inputArg.macaddress || stringMetadata(metadata, 'macAddress') || stringMetadata(metadata, 'macaddress'));
const registered = inputArg.registeredDevices === true || inputArg.registered_devices === true || metadata.registeredDevices === true || metadata.registered_devices === true;
const hinted = isFullyKioskHint(inputArg) || isFullyKioskHint({ ...inputArg, metadata });
if (!host || !macAddress || (!registered && !hinted)) {
return {
matched: false,
confidence: hinted || registered ? 'medium' : 'low',
reason: 'DHCP entry lacks a Fully Kiosk hint, registered-device marker, host, or MAC address.',
normalizedDeviceId: macAddress,
};
}
return {
matched: true,
confidence: hinted ? 'high' : 'medium',
reason: hinted ? 'DHCP entry carries Fully Kiosk metadata.' : 'DHCP registered-device entry can update an existing Fully Kiosk host by MAC address.',
normalizedDeviceId: macAddress,
candidate: {
source: 'dhcp',
integrationDomain: fullyKioskDomain,
id: macAddress,
host,
port: numberMetadata(metadata, 'port') || fullyKioskDefaultPort,
name: inputArg.name || inputArg.hostname || host,
manufacturer: inputArg.manufacturer || 'Fully Kiosk',
model: inputArg.model,
macAddress,
metadata: {
...metadata,
fullyKiosk: true,
fully_kiosk: true,
registeredDevices: registered,
discoveryProtocol: 'dhcp',
},
},
};
}
}
export class FullyKioskCandidateValidator implements IDiscoveryValidator {
public id = 'fully-kiosk-candidate-validator';
public description = 'Validate Fully Kiosk candidates have manual/DHCP metadata and a local setup source.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== fullyKioskDomain) {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Fully Kiosk.` };
}
const metadata = candidateArg.metadata || {};
const endpoint = endpointFromCandidate(candidateArg);
const snapshot = snapshotValue(metadata.snapshot);
const deviceInfo = deviceInfoValue(metadata.deviceInfo);
const macAddress = normalizeMac(candidateArg.macAddress || candidateArg.id || stringMetadata(metadata, 'macAddress') || stringMetadata(metadata, 'macaddress') || snapshot?.macAddress || snapshot?.deviceInfo.Mac || deviceInfo?.Mac);
const matched = candidateArg.integrationDomain === fullyKioskDomain || isFullyKioskHint(candidateArg) || Boolean(snapshot || deviceInfo || metadata.client);
const hasSource = Boolean(endpoint.host || snapshot || deviceInfo || metadata.client);
if (!matched || !hasSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'Fully Kiosk candidate lacks a host, injected client, snapshot, or device info.' : 'Candidate is not Fully Kiosk.',
normalizedDeviceId: candidateArg.id || macAddress,
};
}
if (endpoint.port !== undefined && !validPort(endpoint.port)) {
return { matched: false, confidence: 'low', reason: 'Fully Kiosk candidate has an invalid port.' };
}
const id = candidateArg.id || snapshot?.uniqueId || snapshot?.deviceInfo.deviceID || snapshot?.deviceInfo.deviceId || deviceInfo?.deviceID || macAddress || (endpoint.host ? `${endpoint.host}:${endpoint.port || fullyKioskDefaultPort}` : undefined);
return {
matched: true,
confidence: id && endpoint.host ? 'certain' : endpoint.host ? 'high' : 'medium',
reason: 'Candidate has Fully Kiosk metadata and a local endpoint, client, snapshot, or device info.',
normalizedDeviceId: id,
candidate: {
...candidateArg,
integrationDomain: fullyKioskDomain,
id,
host: endpoint.host,
port: endpoint.port || fullyKioskDefaultPort,
manufacturer: candidateArg.manufacturer || snapshot?.deviceInfo.deviceManufacturer || deviceInfo?.deviceManufacturer || 'Fully Kiosk',
macAddress: macAddress || candidateArg.macAddress,
metadata: {
...metadata,
fullyKiosk: true,
fully_kiosk: true,
protocol: endpoint.protocol,
ssl: endpoint.protocol === 'https',
url: endpoint.url,
},
},
};
}
}
export const createFullyKioskDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: fullyKioskDomain, displayName: 'Fully Kiosk Browser' })
.addMatcher(new FullyKioskManualMatcher())
.addMatcher(new FullyKioskDhcpMatcher())
.addValidator(new FullyKioskCandidateValidator());
};
const endpointFromManual = (inputArg: IFullyKioskManualEntry): { protocol: TFullyKioskProtocol; host?: string; port?: number; url?: string } => {
const parsed = parseEndpoint(inputArg.url || inputArg.host);
if (parsed) {
return parsed;
}
const protocol: TFullyKioskProtocol = inputArg.protocol || (inputArg.ssl ? 'https' : 'http');
const port = inputArg.port || fullyKioskDefaultPort;
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
};
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TFullyKioskProtocol; host?: string; port?: number; url?: string } => {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(stringMetadata(metadata, 'url') || candidateArg.host);
if (parsed) {
return parsed;
}
const protocol: TFullyKioskProtocol = stringMetadata(metadata, 'protocol') === 'https' || metadata.ssl === true ? 'https' : 'http';
const port = candidateArg.port || fullyKioskDefaultPort;
return { protocol, host: candidateArg.host, port, url: candidateArg.host ? `${protocol}://${candidateArg.host}:${port}` : undefined };
};
const parseEndpoint = (valueArg: string | undefined): { protocol: TFullyKioskProtocol; host: string; port: number; url: string } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
const protocol: TFullyKioskProtocol = url.protocol === 'https:' ? 'https' : 'http';
const port = url.port ? Number(url.port) : fullyKioskDefaultPort;
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IFullyKioskSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'deviceInfo' in valueArg && 'settings' in valueArg ? valueArg as IFullyKioskSnapshot : undefined;
};
const deviceInfoValue = (valueArg: unknown): IFullyKioskDeviceInfo | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IFullyKioskDeviceInfo : undefined;
};
const isFullyKioskHint = (valueArg: { integrationDomain?: string; manufacturer?: string; model?: string; name?: string; metadata?: Record<string, unknown> }): boolean => {
const text = [valueArg.manufacturer, valueArg.model, valueArg.name, valueArg.metadata?.manufacturer, valueArg.metadata?.model, valueArg.metadata?.name].filter(Boolean).join(' ').toLowerCase();
return valueArg.integrationDomain === fullyKioskDomain
|| text.includes('fully kiosk')
|| text.includes('fully browser')
|| valueArg.metadata?.fullyKiosk === true
|| valueArg.metadata?.fully_kiosk === true;
};
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
const value = metadataArg[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const numberMetadata = (metadataArg: Record<string, unknown>, keyArg: string): number | undefined => {
const value = metadataArg[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
return Math.round(Number(value));
}
return undefined;
};
const validPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
@@ -0,0 +1,602 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type { IFullyKioskClientCommand, IFullyKioskDeviceInfo, IFullyKioskSettings, IFullyKioskSnapshot, TFullyKioskImageKind } from './fully_kiosk.types.js';
import { fullyKioskDefaultPort, fullyKioskDomain, fullyKioskMusicStream } from './fully_kiosk.types.js';
interface IFullyKioskEntityDefinition {
key: string;
platform: TEntityPlatform;
name: string;
state: unknown;
available: boolean;
attributes?: Record<string, unknown>;
}
interface IFullyKioskSensorDescription {
key: string;
name: string;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
value?: (valueArg: unknown) => unknown;
attributes?: (valueArg: unknown) => Record<string, unknown>;
}
const binarySensors = [
{ key: 'kioskMode', name: 'Kiosk mode' },
{ key: 'plugged', name: 'Plugged in', deviceClass: 'plug' },
{ key: 'isDeviceAdmin', name: 'Device admin' },
];
const sensors: IFullyKioskSensorDescription[] = [
{ key: 'batteryLevel', name: 'Battery', unit: '%', deviceClass: 'battery', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'batteryTemperature', name: 'Battery temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
{ key: 'currentPage', name: 'Current page', entityCategory: 'diagnostic', value: truncateUrl, attributes: urlAttributes },
{ key: 'screenOrientation', name: 'Screen orientation', entityCategory: 'diagnostic' },
{ key: 'foregroundApp', name: 'Foreground app', entityCategory: 'diagnostic' },
{ key: 'internalStorageFreeSpace', name: 'Internal storage free space', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', value: bytesToMegabytes },
{ key: 'internalStorageTotalSpace', name: 'Internal storage total space', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', value: bytesToMegabytes },
{ key: 'ramFreeMemory', name: 'RAM free memory', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', value: bytesToMegabytes },
{ key: 'ramTotalMemory', name: 'RAM total memory', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', value: bytesToMegabytes },
];
const switches = [
{ key: 'screensaver', name: 'Screensaver', state: (snapshotArg: IFullyKioskSnapshot) => booleanValue(snapshotArg.deviceInfo.isInScreensaver), onCommand: 'startScreensaver', offCommand: 'stopScreensaver' },
{ key: 'maintenance', name: 'Maintenance mode', state: (snapshotArg: IFullyKioskSnapshot) => booleanValue(snapshotArg.deviceInfo.maintenanceMode), onCommand: 'enableLockedMode', offCommand: 'disableLockedMode', entityCategory: 'config' },
{ key: 'kiosk', name: 'Kiosk locked', state: (snapshotArg: IFullyKioskSnapshot) => booleanValue(snapshotArg.deviceInfo.kioskLocked), onCommand: 'lockKiosk', offCommand: 'unlockKiosk', entityCategory: 'config' },
{ key: 'motion_detection', name: 'Motion detection', state: (snapshotArg: IFullyKioskSnapshot) => booleanValue(snapshotArg.settings.motionDetection), onCommand: 'setBooleanSetting', offCommand: 'setBooleanSetting', settingKey: 'motionDetection', entityCategory: 'config' },
{ key: 'screen_on', name: 'Screen on', state: (snapshotArg: IFullyKioskSnapshot) => booleanValue(snapshotArg.deviceInfo.screenOn), onCommand: 'screenOn', offCommand: 'screenOff' },
];
const buttons = [
{ key: 'restart_app', name: 'Restart browser', command: 'restartApp', entityCategory: 'config' },
{ key: 'reboot_device', name: 'Restart device', command: 'rebootDevice', entityCategory: 'config' },
{ key: 'to_foreground', name: 'To foreground', command: 'toForeground', entityCategory: 'config' },
{ key: 'to_background', name: 'To background', command: 'toBackground', entityCategory: 'config' },
{ key: 'load_start_url', name: 'Load start URL', command: 'loadStartUrl', entityCategory: 'config' },
{ key: 'clear_cache', name: 'Clear cache', command: 'clearCache', entityCategory: 'config' },
{ key: 'trigger_motion', name: 'Trigger motion', command: 'triggerMotion', entityCategory: 'config' },
];
const numbers = [
{ key: 'timeToScreensaverV2', name: 'Screensaver time', min: 0, max: 86400, step: 1, unit: 's' },
{ key: 'screensaverBrightness', name: 'Screensaver brightness', min: 0, max: 255, step: 1 },
{ key: 'timeToScreenOffV2', name: 'Screen off time', min: 0, max: 86400, step: 1, unit: 's' },
{ key: 'screenBrightness', name: 'Screen brightness', min: 0, max: 255, step: 1 },
];
const fullyKioskServiceCommands: Record<string, string> = {
screen_on: 'screenOn',
screen_off: 'screenOff',
force_sleep: 'forceSleep',
start_screensaver: 'startScreensaver',
stop_screensaver: 'stopScreensaver',
lock_kiosk: 'lockKiosk',
unlock_kiosk: 'unlockKiosk',
enable_locked_mode: 'enableLockedMode',
disable_locked_mode: 'disableLockedMode',
restart_app: 'restartApp',
reboot_device: 'rebootDevice',
to_foreground: 'toForeground',
to_background: 'toBackground',
load_start_url: 'loadStartUrl',
clear_cache: 'clearCache',
clear_webstorage: 'clearWebstorage',
clear_cookies: 'clearCookies',
reset_webview: 'resetWebview',
trigger_motion: 'triggerMotion',
stop_sound: 'stopSound',
stop_text_to_speech: 'stopTextToSpeech',
};
export class FullyKioskMapper {
public static toDevices(snapshotArg: IFullyKioskSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
{ id: 'battery', capability: 'sensor', name: 'Battery', readable: true, writable: false, unit: '%' },
{ id: 'current_page', capability: 'sensor', name: 'Current page', readable: true, writable: false },
{ id: 'screen_on', capability: 'switch', name: 'Screen on', readable: true, writable: true },
{ id: 'screensaver', capability: 'switch', name: 'Screensaver', readable: true, writable: true },
{ id: 'screenshot', capability: 'camera', name: 'Screenshot', readable: true, writable: false },
{ id: 'media_player', capability: 'media', name: 'Media player', readable: true, writable: true },
{ id: 'notification', capability: 'notification', name: 'Notification', readable: false, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
{ featureId: 'battery', value: primitiveState(snapshotArg.deviceInfo.batteryLevel), updatedAt },
{ featureId: 'current_page', value: stringOrNull(snapshotArg.deviceInfo.currentPage), updatedAt },
{ featureId: 'screen_on', value: booleanValue(snapshotArg.deviceInfo.screenOn), updatedAt },
{ featureId: 'screensaver', value: booleanValue(snapshotArg.deviceInfo.isInScreensaver), updatedAt },
{ featureId: 'screenshot', value: { available: snapshotArg.online }, updatedAt },
{ featureId: 'media_player', value: snapshotArg.deviceInfo.soundUrlPlaying ? 'playing' : 'idle', updatedAt },
];
return [{
id: this.deviceId(snapshotArg),
integrationDomain: fullyKioskDomain,
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: stringOrUndefined(snapshotArg.deviceInfo.deviceManufacturer) || 'Fully Kiosk',
model: stringOrUndefined(snapshotArg.deviceInfo.deviceModel) || 'Android kiosk',
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
uniqueId: snapshotArg.uniqueId,
deviceID: snapshotArg.deviceInfo.deviceID,
macAddress: snapshotArg.macAddress,
host: snapshotArg.host,
port: snapshotArg.port,
appVersionName: snapshotArg.deviceInfo.appVersionName,
ip4: snapshotArg.deviceInfo.ip4,
currentPage: snapshotArg.deviceInfo.currentPage,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IFullyKioskSnapshot): IIntegrationEntity[] {
const deviceId = this.deviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const baseName = this.deviceName(snapshotArg);
const usedIds = new Map<string, number>();
const entities: IIntegrationEntity[] = [];
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'online',
platform: 'binary_sensor',
name: `${baseName} Online`,
state: snapshotArg.online ? 'on' : 'off',
available: true,
attributes: { deviceClass: 'connectivity', source: snapshotArg.source, error: snapshotArg.error },
}));
for (const description of binarySensors) {
if (description.key in snapshotArg.deviceInfo) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: description.key,
platform: 'binary_sensor',
name: `${baseName} ${description.name}`,
state: booleanValue(snapshotArg.deviceInfo[description.key]) ? 'on' : 'off',
available: snapshotArg.online,
attributes: this.cleanAttributes({ deviceClass: description.deviceClass, entityCategory: 'diagnostic' }),
}));
}
}
for (const description of sensors) {
if (description.key in snapshotArg.deviceInfo) {
const rawValue = snapshotArg.deviceInfo[description.key];
const value = description.value ? description.value(rawValue) : rawValue;
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: description.key,
platform: 'sensor',
name: `${baseName} ${description.name}`,
state: primitiveState(value),
available: snapshotArg.online && value !== undefined,
attributes: this.cleanAttributes({
unit: description.unit,
deviceClass: description.deviceClass,
stateClass: description.stateClass,
entityCategory: description.entityCategory,
...description.attributes?.(rawValue),
}),
}));
}
}
for (const description of switches) {
const isOn = description.state(snapshotArg);
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: description.key,
platform: 'switch',
name: `${baseName} ${description.name}`,
state: isOn ? 'on' : 'off',
available: snapshotArg.online,
attributes: this.cleanAttributes({
fullyKioskSwitchKey: description.key,
onCommand: description.onCommand,
offCommand: description.offCommand,
settingKey: description.settingKey,
entityCategory: description.entityCategory,
}),
}));
}
for (const description of buttons) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: description.key,
platform: 'button',
name: `${baseName} ${description.name}`,
state: 'idle',
available: snapshotArg.online,
attributes: { fullyKioskButtonCommand: description.command, entityCategory: description.entityCategory },
}));
}
for (const description of numbers) {
if (description.key in snapshotArg.settings) {
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: description.key,
platform: 'number',
name: `${baseName} ${description.name}`,
state: numberOrNull(snapshotArg.settings[description.key]),
available: snapshotArg.online,
attributes: this.cleanAttributes({
fullyKioskSettingKey: description.key,
min: description.min,
max: description.max,
step: description.step,
unit: description.unit,
entityCategory: 'config',
}),
}));
}
}
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'camshot',
platform: 'camera' as TEntityPlatform,
name: `${baseName} Camera`,
state: snapshotArg.online ? 'idle' : 'unavailable',
available: snapshotArg.online,
attributes: { imageKind: 'camshot', supportedFeatures: ['snapshot'], requiresAuth: true },
}));
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'screenshot',
platform: 'camera' as TEntityPlatform,
name: `${baseName} Screenshot`,
state: snapshotArg.online ? 'idle' : 'unavailable',
available: snapshotArg.online,
attributes: { imageKind: 'screenshot', supportedFeatures: ['snapshot'], requiresAuth: true },
}));
entities.push(this.entity(snapshotArg, usedIds, deviceId, uniqueBase, {
key: 'mediaplayer',
platform: 'media_player',
name: `${baseName} Media player`,
state: snapshotArg.deviceInfo.soundUrlPlaying ? 'playing' : 'idle',
available: snapshotArg.online,
attributes: { supportedFeatures: ['play_media', 'stop', 'volume_set'], assumedState: true },
}));
return entities;
}
public static commandForService(snapshotArg: IFullyKioskSnapshot, requestArg: IServiceCallRequest): IFullyKioskClientCommand | { error: string } | undefined {
if (requestArg.domain === fullyKioskDomain) {
return this.fullyKioskServiceCommand(requestArg);
}
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off'].includes(requestArg.service)) {
const target = this.targetSwitch(snapshotArg, requestArg);
if ('error' in target) {
return target;
}
return this.switchCommand(target.key, requestArg.service === 'turn_on', requestArg, target.entity);
}
if (requestArg.domain === 'button' && requestArg.service === 'press') {
const entity = this.findTargetEntity(snapshotArg, requestArg);
const cmd = stringOrUndefined(entity?.attributes?.fullyKioskButtonCommand);
return cmd ? this.sendCommand(cmd, requestArg, {}, entity) : undefined;
}
if (requestArg.domain === 'number' && ['set_value', 'set_native_value'].includes(requestArg.service)) {
const entity = this.findTargetEntity(snapshotArg, requestArg);
const settingKey = stringOrUndefined(entity?.attributes?.fullyKioskSettingKey);
if (!settingKey) {
return { error: 'Fully Kiosk number services require a Fully Kiosk number entity target.' };
}
const value = requestArg.data?.value ?? requestArg.data?.native_value;
if (typeof value !== 'number' && typeof value !== 'string') {
return { error: 'Fully Kiosk number services require numeric data.value.' };
}
return this.setStringSetting(settingKey, value, requestArg, entity);
}
if (requestArg.domain === 'camera' && ['snapshot', 'camera_image', 'camera_snapshot', 'snapshot_image'].includes(requestArg.service)) {
const entity = this.findTargetEntity(snapshotArg, requestArg);
const imageKind = imageKindFromEntity(entity) || 'camshot';
return this.imageCommand(imageKind, requestArg, entity);
}
if (requestArg.domain === 'media_player') {
return this.mediaPlayerCommand(requestArg);
}
if (requestArg.domain === 'notify' && requestArg.service === 'send_message') {
const message = stringOrUndefined(requestArg.data?.message);
if (!message) {
return { error: 'Fully Kiosk notify.send_message requires data.message.' };
}
const targetText = `${requestArg.target.entityId || ''} ${requestArg.data?.type || ''}`.toLowerCase();
return this.sendCommand(targetText.includes('tts') ? 'textToSpeech' : 'setOverlayMessage', requestArg, { text: message });
}
return undefined;
}
public static deviceId(snapshotArg: IFullyKioskSnapshot): string {
return `fully_kiosk.device.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'fully_kiosk';
}
private static fullyKioskServiceCommand(requestArg: IServiceCallRequest): IFullyKioskClientCommand | { error: string } | undefined {
if (requestArg.service === 'refresh') {
return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
return undefined;
}
if (requestArg.service === 'load_url') {
const url = stringOrUndefined(requestArg.data?.url);
return url ? this.sendCommand('loadUrl', requestArg, { url }) : { error: 'Fully Kiosk load_url requires data.url.' };
}
if (requestArg.service === 'start_application') {
const app = stringOrUndefined(requestArg.data?.application || requestArg.data?.package);
return app ? this.sendCommand('startApplication', requestArg, { package: app }) : { error: 'Fully Kiosk start_application requires data.application.' };
}
if (requestArg.service === 'set_config') {
const key = stringOrUndefined(requestArg.data?.key);
if (!key || requestArg.data?.value === undefined) {
return { error: 'Fully Kiosk set_config requires data.key and data.value.' };
}
const boolean = booleanLike(requestArg.data.value);
return boolean === undefined
? this.setStringSetting(key, requestArg.data.value, requestArg)
: this.setBooleanSetting(key, boolean, requestArg);
}
if (requestArg.service === 'set_string_setting') {
const key = stringOrUndefined(requestArg.data?.key);
return key && requestArg.data?.value !== undefined ? this.setStringSetting(key, requestArg.data.value, requestArg) : { error: 'Fully Kiosk set_string_setting requires data.key and data.value.' };
}
if (requestArg.service === 'set_boolean_setting') {
const key = stringOrUndefined(requestArg.data?.key);
const value = booleanLike(requestArg.data?.value);
return key && value !== undefined ? this.setBooleanSetting(key, value, requestArg) : { error: 'Fully Kiosk set_boolean_setting requires data.key and boolean data.value.' };
}
if (requestArg.service === 'get_screenshot' || requestArg.service === 'screenshot') {
return this.imageCommand('screenshot', requestArg);
}
if (requestArg.service === 'get_camshot' || requestArg.service === 'camshot') {
return this.imageCommand('camshot', requestArg);
}
if (requestArg.service === 'text_to_speech') {
const text = stringOrUndefined(requestArg.data?.text || requestArg.data?.message);
return text ? this.sendCommand('textToSpeech', requestArg, { text }) : { error: 'Fully Kiosk text_to_speech requires data.text or data.message.' };
}
if (requestArg.service === 'overlay_message') {
const text = stringOrUndefined(requestArg.data?.text || requestArg.data?.message);
return text ? this.sendCommand('setOverlayMessage', requestArg, { text }) : { error: 'Fully Kiosk overlay_message requires data.text or data.message.' };
}
if (requestArg.service === 'set_audio_volume') {
const level = numberOrNull(requestArg.data?.level ?? requestArg.data?.volume_level);
return level === null ? { error: 'Fully Kiosk set_audio_volume requires numeric data.level.' } : this.sendCommand('setAudioVolume', requestArg, { level, stream: requestArg.data?.stream ?? fullyKioskMusicStream });
}
if (requestArg.service === 'play_sound') {
const url = stringOrUndefined(requestArg.data?.url);
return url ? this.sendCommand('playSound', requestArg, { url, stream: requestArg.data?.stream ?? fullyKioskMusicStream }) : { error: 'Fully Kiosk play_sound requires data.url.' };
}
const cmd = fullyKioskServiceCommands[requestArg.service];
return cmd ? this.sendCommand(cmd, requestArg) : undefined;
}
private static switchCommand(keyArg: string, onArg: boolean, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IFullyKioskClientCommand | { error: string } {
const description = switches.find((switchArg) => switchArg.key === keyArg);
if (!description) {
return { error: `Unknown Fully Kiosk switch key: ${keyArg}` };
}
if (description.settingKey) {
return this.setBooleanSetting(description.settingKey, onArg, requestArg, entityArg);
}
return this.sendCommand(onArg ? description.onCommand : description.offCommand, requestArg, {}, entityArg);
}
private static mediaPlayerCommand(requestArg: IServiceCallRequest): IFullyKioskClientCommand | { error: string } | undefined {
if (requestArg.service === 'play_media') {
const mediaId = stringOrUndefined(requestArg.data?.media_content_id || requestArg.data?.media_id || requestArg.data?.url);
const mediaType = stringOrUndefined(requestArg.data?.media_content_type || requestArg.data?.media_type) || '';
if (!mediaId) {
return { error: 'Fully Kiosk media_player.play_media requires data.media_content_id or data.url.' };
}
if (mediaType.startsWith('video/') || mediaType === 'video') {
return this.sendCommand('playVideo', requestArg, { url: mediaId, stream: fullyKioskMusicStream, showControls: 1, exitOnCompletion: 1 });
}
if (mediaType.startsWith('audio/') || mediaType === 'music' || mediaType === 'audio' || !mediaType) {
return this.sendCommand('playSound', requestArg, { url: mediaId, stream: fullyKioskMusicStream });
}
return { error: `Unsupported Fully Kiosk media type ${mediaType}.` };
}
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
const mediaType = stringOrUndefined(requestArg.data?.media_content_type || requestArg.data?.media_type) || '';
return this.sendCommand(mediaType.startsWith('video') ? 'stopVideo' : 'stopSound', requestArg);
}
if (requestArg.service === 'volume_set') {
const volume = numberOrNull(requestArg.data?.volume_level);
return volume === null ? { error: 'Fully Kiosk media_player.volume_set requires data.volume_level.' } : this.sendCommand('setAudioVolume', requestArg, { level: Math.round(volume * 100), stream: fullyKioskMusicStream });
}
return undefined;
}
private static targetSwitch(snapshotArg: IFullyKioskSnapshot, requestArg: IServiceCallRequest): { key: string; entity?: IIntegrationEntity } | { error: string } {
const entity = this.findTargetEntity(snapshotArg, requestArg);
const key = stringOrUndefined(entity?.attributes?.fullyKioskSwitchKey);
if (key) {
return { key, entity };
}
if (requestArg.target.deviceId && requestArg.target.deviceId === this.deviceId(snapshotArg)) {
return { error: 'Fully Kiosk switch services require a switch entity target because the device has multiple switches.' };
}
return { error: 'Fully Kiosk switch services require a Fully Kiosk switch entity target.' };
}
private static sendCommand(cmdArg: string, requestArg: IServiceCallRequest, paramsArg: Record<string, unknown> = {}, entityArg?: IIntegrationEntity): IFullyKioskClientCommand {
return this.cleanAttributes({
type: 'send_command',
service: requestArg.service,
cmd: cmdArg,
params: paramsArg,
target: requestArg.target,
data: requestArg.data,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
}) as IFullyKioskClientCommand;
}
private static setStringSetting(keyArg: string, valueArg: unknown, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IFullyKioskClientCommand {
return this.cleanAttributes({
type: 'set_string_setting',
service: requestArg.service,
settingKey: keyArg,
value: valueArg,
target: requestArg.target,
data: requestArg.data,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
}) as IFullyKioskClientCommand;
}
private static setBooleanSetting(keyArg: string, valueArg: boolean, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IFullyKioskClientCommand {
return this.cleanAttributes({
type: 'set_boolean_setting',
service: requestArg.service,
settingKey: keyArg,
value: valueArg,
target: requestArg.target,
data: requestArg.data,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
}) as IFullyKioskClientCommand;
}
private static imageCommand(imageKindArg: TFullyKioskImageKind, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IFullyKioskClientCommand {
return this.cleanAttributes({
type: 'snapshot_image',
service: requestArg.service,
imageKind: imageKindArg,
target: requestArg.target,
data: requestArg.data,
entityId: entityArg?.id || requestArg.target.entityId,
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
uniqueId: entityArg?.uniqueId,
}) as IFullyKioskClientCommand;
}
private static findTargetEntity(snapshotArg: IFullyKioskSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
if (!requestArg.target.entityId) {
return undefined;
}
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId);
}
private static entity(snapshotArg: IFullyKioskSnapshot, usedIdsArg: Map<string, number>, deviceIdArg: string, uniqueBaseArg: string, definitionArg: IFullyKioskEntityDefinition): IIntegrationEntity {
const entitySlug = this.uniqueEntitySlug(usedIdsArg, `${this.slug(this.deviceName(snapshotArg))}_${this.slug(definitionArg.key)}`);
return {
id: `${definitionArg.platform}.${entitySlug}`,
uniqueId: `fully_kiosk_${uniqueBaseArg}_${this.slug(definitionArg.key)}`,
integrationDomain: fullyKioskDomain,
deviceId: deviceIdArg,
platform: definitionArg.platform,
name: definitionArg.name,
state: primitiveState(definitionArg.state),
attributes: this.cleanAttributes(definitionArg.attributes || {}),
available: definitionArg.available,
};
}
private static uniqueEntitySlug(usedIdsArg: Map<string, number>, valueArg: string): string {
const count = usedIdsArg.get(valueArg) || 0;
usedIdsArg.set(valueArg, count + 1);
return count ? `${valueArg}_${count + 1}` : valueArg;
}
private static deviceName(snapshotArg: IFullyKioskSnapshot): string {
return snapshotArg.name || stringOrUndefined(snapshotArg.deviceInfo.deviceName) || 'Fully Kiosk Browser';
}
private static uniqueBase(snapshotArg: IFullyKioskSnapshot): string {
return this.slug(snapshotArg.uniqueId || stringOrUndefined(snapshotArg.deviceInfo.deviceID) || stringOrUndefined(snapshotArg.deviceInfo.deviceId) || snapshotArg.macAddress || snapshotArg.host && `${snapshotArg.host}_${snapshotArg.port || fullyKioskDefaultPort}` || this.deviceName(snapshotArg));
}
private static cleanAttributes<T extends Record<string, unknown>>(attributesArg: T): T {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T;
}
}
const imageKindFromEntity = (entityArg: IIntegrationEntity | undefined): TFullyKioskImageKind | undefined => {
const value = entityArg?.attributes?.imageKind;
return value === 'screenshot' || value === 'camshot' ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean => valueArg === true || valueArg === 'true' || valueArg === '1' || valueArg === 1;
const booleanLike = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
const normalized = valueArg.trim().toLowerCase();
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return true;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return false;
}
}
return undefined;
};
function bytesToMegabytes(valueArg: unknown): number | null {
const value = numberOrNull(valueArg);
return value === null ? null : Math.round(value * 0.000001 * 10) / 10;
}
function truncateUrl(valueArg: unknown): string | null {
const value = stringOrUndefined(valueArg);
if (!value) {
return null;
}
return value.length > 255 ? value.slice(0, 255) : value;
}
function urlAttributes(valueArg: unknown): Record<string, unknown> {
const value = stringOrUndefined(valueArg);
return value ? { fullUrl: value, truncated: value.length > 255 } : {};
}
const stringOrUndefined = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringOrNull = (valueArg: unknown): string | null => stringOrUndefined(valueArg) || null;
const numberOrNull = (valueArg: unknown): number | null => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
return Number(valueArg);
}
return null;
};
const primitiveState = (valueArg: unknown): string | number | boolean | null | Record<string, unknown> => {
if (valueArg === undefined) {
return null;
}
if (valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg)) {
return valueArg as string | number | boolean | null;
}
if (typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return String(valueArg);
};
@@ -1,4 +1,143 @@
export interface IHomeAssistantFullyKioskConfig {
// TODO: replace with the TypeScript-native config for fully_kiosk.
import type { IServiceCallRequest } from '../../core/types.js';
export const fullyKioskDomain = 'fully_kiosk';
export const fullyKioskDefaultPort = 2323;
export const fullyKioskDefaultTimeoutMs = 15000;
export const fullyKioskMusicStream = 3;
export type TFullyKioskProtocol = 'http' | 'https';
export type TFullyKioskSnapshotSource = 'http' | 'snapshot' | 'client' | 'manual' | 'runtime';
export type TFullyKioskImageKind = 'screenshot' | 'camshot';
export interface IFullyKioskRestObject {
status?: string;
statustext?: string;
[key: string]: unknown;
}
export interface IFullyKioskDeviceInfo extends IFullyKioskRestObject {
deviceID?: string;
deviceId?: string;
deviceName?: string;
deviceManufacturer?: string;
deviceModel?: string;
appVersionName?: string;
Mac?: string;
mac?: string;
ip4?: string;
ip6?: string;
hostname4?: string;
hostname6?: string;
settings?: IFullyKioskSettings;
}
export interface IFullyKioskSettings extends IFullyKioskRestObject {}
export interface IFullyKioskSnapshot {
deviceInfo: IFullyKioskDeviceInfo;
settings: IFullyKioskSettings;
online: boolean;
updatedAt: string;
source: TFullyKioskSnapshotSource;
host?: string;
port?: number;
protocol: TFullyKioskProtocol;
ssl: boolean;
verifySsl?: boolean;
name?: string;
uniqueId?: string;
macAddress?: string;
error?: string;
metadata?: Record<string, unknown>;
}
export interface IFullyKioskSnapshotImage {
contentType: string;
data: Uint8Array;
}
export interface IFullyKioskClientLike {
getSnapshot?(): Promise<IFullyKioskSnapshot>;
getDeviceInfo?(): Promise<IFullyKioskDeviceInfo>;
getSettings?(): Promise<IFullyKioskSettings>;
sendCommand?(cmdArg: string, paramsArg?: Record<string, unknown>): Promise<unknown>;
getScreenshot?(): Promise<Uint8Array | ArrayBuffer | Buffer>;
getCamshot?(): Promise<Uint8Array | ArrayBuffer | Buffer>;
execute?(commandArg: IFullyKioskClientCommand): Promise<unknown>;
destroy?(): Promise<void>;
}
export interface IFullyKioskCommandExecutor {
execute(commandArg: IFullyKioskClientCommand): Promise<unknown>;
}
export interface IFullyKioskConfig {
host?: string;
port?: number;
protocol?: TFullyKioskProtocol;
ssl?: boolean;
verifySsl?: boolean;
password?: string;
name?: string;
uniqueId?: string;
macAddress?: string;
manufacturer?: string;
model?: string;
timeoutMs?: number;
online?: boolean;
snapshot?: IFullyKioskSnapshot;
deviceInfo?: IFullyKioskDeviceInfo;
settings?: IFullyKioskSettings;
client?: IFullyKioskClientLike;
commandExecutor?: IFullyKioskCommandExecutor | ((commandArg: IFullyKioskClientCommand) => Promise<unknown>);
metadata?: Record<string, unknown>;
}
export interface IHomeAssistantFullyKioskConfig extends IFullyKioskConfig {}
export interface IFullyKioskManualEntry extends IFullyKioskConfig {
id?: string;
url?: string;
source?: string;
integrationDomain?: string;
manufacturer?: string;
}
export interface IFullyKioskDhcpEntry {
ip?: string;
ipAddress?: string;
host?: string;
hostname?: string;
macaddress?: string;
macAddress?: string;
registeredDevices?: boolean;
registered_devices?: boolean;
integrationDomain?: string;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export type TFullyKioskClientCommandType =
| 'refresh'
| 'send_command'
| 'set_string_setting'
| 'set_boolean_setting'
| 'snapshot_image'
| 'stream_source';
export interface IFullyKioskClientCommand {
type: TFullyKioskClientCommandType;
service?: string;
cmd?: string;
params?: Record<string, unknown>;
settingKey?: string;
value?: unknown;
imageKind?: TFullyKioskImageKind;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
entityId?: string;
deviceId?: string;
uniqueId?: string;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './fully_kiosk.classes.client.js';
export * from './fully_kiosk.classes.configflow.js';
export * from './fully_kiosk.classes.integration.js';
export * from './fully_kiosk.discovery.js';
export * from './fully_kiosk.mapper.js';
export * from './fully_kiosk.types.js';
+7 -13
View File
@@ -236,7 +236,6 @@ import { HomeAssistantDigitalOceanIntegration } from '../digital_ocean/index.js'
import { HomeAssistantDiscogsIntegration } from '../discogs/index.js';
import { HomeAssistantDiscordIntegration } from '../discord/index.js';
import { HomeAssistantDiscovergyIntegration } from '../discovergy/index.js';
import { HomeAssistantDlinkIntegration } from '../dlink/index.js';
import { HomeAssistantDnsipIntegration } from '../dnsip/index.js';
import { HomeAssistantDoodsIntegration } from '../doods/index.js';
import { HomeAssistantDoorIntegration } from '../door/index.js';
@@ -365,7 +364,6 @@ import { HomeAssistantFolderWatcherIntegration } from '../folder_watcher/index.j
import { HomeAssistantFoobotIntegration } from '../foobot/index.js';
import { HomeAssistantForecastSolarIntegration } from '../forecast_solar/index.js';
import { HomeAssistantFortiosIntegration } from '../fortios/index.js';
import { HomeAssistantFoscamIntegration } from '../foscam/index.js';
import { HomeAssistantFoursquareIntegration } from '../foursquare/index.js';
import { HomeAssistantFrankeverIntegration } from '../frankever/index.js';
import { HomeAssistantFreeMobileIntegration } from '../free_mobile/index.js';
@@ -380,7 +378,6 @@ 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';
import { HomeAssistantFullyKioskIntegration } from '../fully_kiosk/index.js';
import { HomeAssistantFumisIntegration } from '../fumis/index.js';
import { HomeAssistantFuturenowIntegration } from '../futurenow/index.js';
import { HomeAssistantFytaIntegration } from '../fyta/index.js';
@@ -412,7 +409,6 @@ import { HomeAssistantGitlabCiIntegration } from '../gitlab_ci/index.js';
import { HomeAssistantGitterIntegration } from '../gitter/index.js';
import { HomeAssistantGoalzeroIntegration } from '../goalzero/index.js';
import { HomeAssistantGogogate2Integration } from '../gogogate2/index.js';
import { HomeAssistantGoodweIntegration } from '../goodwe/index.js';
import { HomeAssistantGoogleIntegration } from '../google/index.js';
import { HomeAssistantGoogleAirQualityIntegration } from '../google_air_quality/index.js';
import { HomeAssistantGoogleAssistantIntegration } from '../google_assistant/index.js';
@@ -484,7 +480,6 @@ import { HomeAssistantHomeeIntegration } from '../homee/index.js';
import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js';
import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js';
import { HomeAssistantHomewizardIntegration } from '../homewizard/index.js';
import { HomeAssistantHomeworksIntegration } from '../homeworks/index.js';
import { HomeAssistantHoneywellIntegration } from '../honeywell/index.js';
import { HomeAssistantHoneywellStringLightsIntegration } from '../honeywell_string_lights/index.js';
@@ -497,7 +492,6 @@ import { HomeAssistantHueBleIntegration } from '../hue_ble/index.js';
import { HomeAssistantHuisbaasjeIntegration } from '../huisbaasje/index.js';
import { HomeAssistantHumidifierIntegration } from '../humidifier/index.js';
import { HomeAssistantHumidityIntegration } from '../humidity/index.js';
import { HomeAssistantHunterdouglasPowerviewIntegration } from '../hunterdouglas_powerview/index.js';
import { HomeAssistantHurricanShuttersWholesaleIntegration } from '../hurrican_shutters_wholesale/index.js';
import { HomeAssistantHusqvarnaAutomowerIntegration } from '../husqvarna_automower/index.js';
import { HomeAssistantHusqvarnaAutomowerBleIntegration } from '../husqvarna_automower_ble/index.js';
@@ -1608,7 +1602,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDigitalOceanIntegra
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscogsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscordIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscovergyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDnsipIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoodsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoorIntegration());
@@ -1737,7 +1730,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFolderWatcherIntegr
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoobotIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantForecastSolarIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFortiosIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoscamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFoursquareIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFrankeverIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFreeMobileIntegration());
@@ -1752,7 +1744,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantFroniusIntegration(
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFrontendIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuAnywairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFujitsuFglairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFullyKioskIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFumisIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFuturenowIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantFytaIntegration());
@@ -1784,7 +1775,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitlabCiIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGitterIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoalzeroIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGogogate2Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoodweIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoogleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoogleAirQualityIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantGoogleAssistantIntegration());
@@ -1856,7 +1846,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomewizardIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeworksIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHoneywellIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHoneywellStringLightsIntegration());
@@ -1869,7 +1858,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHueBleIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHuisbaasjeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHumidifierIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHumidityIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHunterdouglasPowerviewIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHurricanShuttersWholesaleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHusqvarnaAutomowerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHusqvarnaAutomowerBleIntegration());
@@ -2744,7 +2732,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1370;
export const generatedHomeAssistantPortCount = 1364;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient",
@@ -2771,6 +2759,7 @@ export const handwrittenHomeAssistantPortDomains = [
"denonavr",
"devolo_home_network",
"directv",
"dlink",
"dlna_dmr",
"dlna_dms",
"doorbird",
@@ -2779,17 +2768,22 @@ export const handwrittenHomeAssistantPortDomains = [
"elgato",
"esphome",
"forked_daapd",
"foscam",
"fritz",
"frontier_silicon",
"fully_kiosk",
"glances",
"go2rtc",
"goodwe",
"harmony",
"heos",
"hikvision",
"homekit_controller",
"homematic",
"homewizard",
"huawei_lte",
"hue",
"hunterdouglas_powerview",
"hyperion",
"ipp",
"jellyfin",
@@ -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,171 @@
import { GoodweMapper } from './goodwe.mapper.js';
import type { IGoodweClientLike, IGoodweCommandRequest, IGoodweConfig, IGoodweDeviceInfo, IGoodweRawData, IGoodweSnapshot } from './goodwe.types.js';
export class GoodweApiError extends Error {}
export class GoodweApiConnectionError extends GoodweApiError {}
export class GoodweProtocolNotImplementedError extends GoodweApiError {}
export class GoodweUnsupportedCommandError extends GoodweApiError {}
export interface IGoodweRefreshResult {
success: boolean;
snapshot: IGoodweSnapshot;
error?: string;
data?: unknown;
}
export class GoodweClient {
private currentSnapshot?: IGoodweSnapshot;
constructor(private readonly config: IGoodweConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IGoodweSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = GoodweMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.hasManualData()) {
this.currentSnapshot = GoodweMapper.toSnapshot({
config: this.config,
rawData: this.config.rawData,
online: this.config.online ?? true,
source: 'manual',
});
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.host) {
this.currentSnapshot = this.offlineSnapshot('Native GoodWe UDP/TCP transport is not implemented in this TypeScript port. Provide an injected client, commandExecutor, snapshot, or rawData; host-only configs are read-only metadata.');
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('No GoodWe host, injected client, snapshot, or raw data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IGoodweRefreshResult> {
try {
this.currentSnapshot = undefined;
const liveCapable = Boolean(this.config.client);
const snapshot = await this.getSnapshot(liveCapable);
const success = liveCapable && snapshot.online && !snapshot.error;
return { success, snapshot, error: success ? undefined : snapshot.error || 'GoodWe refresh requires an injected client because native UDP/TCP transport is not implemented.', 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<IGoodweSnapshot> {
throw new GoodweProtocolNotImplementedError('Native GoodWe UDP/TCP snapshot fetching is not implemented. Use config.client, config.snapshot, or config.rawData.');
}
public async execute(commandArg: IGoodweCommandRequest): 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;
}
throw new GoodweApiConnectionError('GoodWe commands require an injected client.execute or commandExecutor. Native UDP/TCP writes are not implemented; static snapshots/manual data are read-only.');
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async snapshotFromClient(clientArg: IGoodweClientLike): Promise<IGoodweSnapshot> {
if (clientArg.getSnapshot) {
const result = await clientArg.getSnapshot();
if (this.isSnapshot(result)) {
return GoodweMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return GoodweMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
}
if (clientArg.getRawData) {
return GoodweMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client' });
}
const rawData = await this.rawDataFromClient(clientArg);
if (!GoodweMapper.hasUsableRawData(rawData)) {
throw new GoodweApiConnectionError('GoodWe client did not return runtime data, device info, settings, or sensor metadata.');
}
return GoodweMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'client' });
}
private async rawDataFromClient(clientArg: IGoodweClientLike): Promise<Partial<IGoodweRawData>> {
const deviceInfo = await this.deviceInfoFromClient(clientArg);
const runtimeData = await this.callOptional<Record<string, unknown>>(clientArg.readRuntimeData || clientArg.read_runtime_data, clientArg);
const settingsData = await this.callOptional<Record<string, unknown>>(clientArg.readSettingsData || clientArg.read_settings_data, clientArg).catch(() => undefined);
const operationMode = await this.callOptional<string | number>(clientArg.getOperationMode || clientArg.get_operation_mode, clientArg).catch(() => undefined);
const supportedOperationModes = await this.callOptional<Array<string | number>>(clientArg.getOperationModes || clientArg.get_operation_modes, clientArg, false).catch(() => undefined);
const gridExportLimit = await this.callOptional<number>(clientArg.getGridExportLimit || clientArg.get_grid_export_limit, clientArg).catch(() => undefined);
const batteryDischargeDepth = await this.callOptional<number>(clientArg.getOngridBatteryDod || clientArg.get_ongrid_battery_dod, clientArg).catch(() => undefined);
const sensors = typeof clientArg.sensors === 'function' ? GoodweMapper.normalizeSensorDefinitions(clientArg.sensors()) : undefined;
const settings = typeof clientArg.settings === 'function' ? GoodweMapper.normalizeSettingDefinitions(clientArg.settings()) : undefined;
return {
deviceInfo,
runtimeData,
settingsData,
sensors,
settings,
operationMode,
supportedOperationModes,
gridExportLimit,
batteryDischargeDepth,
fetchedAt: new Date().toISOString(),
};
}
private async deviceInfoFromClient(clientArg: IGoodweClientLike): Promise<Partial<IGoodweDeviceInfo> | undefined> {
const methodResult = await this.callOptional<Partial<IGoodweDeviceInfo>>(clientArg.getDeviceInfo || clientArg.readDeviceInfo || clientArg.read_device_info, clientArg).catch(() => undefined);
const propertyInfo = GoodweMapper.deviceInfoFromRecord(clientArg as Record<string, unknown>);
return { ...propertyInfo, ...methodResult };
}
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 offlineSnapshot(errorArg: string): IGoodweSnapshot {
return GoodweMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: false, source: 'runtime', error: errorArg });
}
private hasManualData(): boolean {
return Boolean(this.config.rawData || this.config.snapshot);
}
private isSnapshot(valueArg: unknown): valueArg is IGoodweSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'inverter' in valueArg && 'runtimeData' in valueArg && 'sensors' in valueArg);
}
private cloneSnapshot(snapshotArg: IGoodweSnapshot): IGoodweSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IGoodweSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
@@ -0,0 +1,110 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IGoodweConfig, IGoodweRawData, IGoodweSnapshot, TGoodweInverterFamily } from './goodwe.types.js';
import { goodweDefaultRetries, goodweDefaultTimeoutMs, goodweDefaultUdpPort, goodweDisplayName, goodweManufacturer } from './goodwe.types.js';
export class GoodweConfigFlow implements IConfigFlow<IGoodweConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IGoodweConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Configure GoodWe inverter',
description: 'Configure a local GoodWe inverter host, or use snapshot/raw data/injected client metadata. This TypeScript port does not implement the GoodWe UDP/TCP protocol, so host-only configs expose metadata and stay offline until a client or snapshot is supplied.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'modelFamily', label: 'Model family', type: 'select', options: [{ label: 'Auto/unknown', value: '' }, { label: 'ET/EH/BT/BH', value: 'ET' }, { label: 'ES/EM/BP', value: 'ES' }, { label: 'DT/MS/NS/XS', value: 'DT' }] },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'serialNumber', label: 'Serial number', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IGoodweConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringMetadata(metadata, 'url'));
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.inverter.host || this.stringMetadata(metadata, 'host');
const port = this.numberValue(valuesArg.port) || candidateArg.port || parsed?.port || snapshot?.inverter.port || this.numberMetadata(metadata, 'port') || goodweDefaultUdpPort;
const hasManualData = Boolean(snapshot || rawData || metadata.client);
if (!host && !hasManualData) {
return { kind: 'error', title: 'GoodWe setup failed', error: 'GoodWe host, injected client, snapshot, or raw runtime data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'GoodWe setup failed', error: 'GoodWe port must be between 1 and 65535.' };
}
const serialNumber = this.stringValue(valuesArg.serialNumber) || candidateArg.serialNumber || snapshot?.inverter.serialNumber || this.stringMetadata(metadata, 'serialNumber');
const modelFamily = (this.stringValue(valuesArg.modelFamily) || snapshot?.inverter.modelFamily || this.stringMetadata(metadata, 'modelFamily')) as TGoodweInverterFamily | undefined;
const config: IGoodweConfig = {
host,
port,
timeoutMs: goodweDefaultTimeoutMs,
retries: goodweDefaultRetries,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.inverter.name || this.stringMetadata(metadata, 'name') || goodweDisplayName,
manufacturer: candidateArg.manufacturer || snapshot?.inverter.manufacturer || goodweManufacturer,
model: candidateArg.model || snapshot?.inverter.model || this.stringMetadata(metadata, 'model'),
modelFamily,
uniqueId: candidateArg.id || snapshot?.inverter.id || serialNumber || (host ? `${host}:${port}` : undefined),
serialNumber,
firmware: snapshot?.inverter.firmware || this.stringMetadata(metadata, 'firmware'),
armFirmware: snapshot?.inverter.armFirmware || this.stringMetadata(metadata, 'armFirmware'),
ratedPower: snapshot?.inverter.ratedPower || this.numberMetadata(metadata, 'ratedPower'),
snapshot,
rawData,
client: metadata.client as IGoodweConfig['client'],
commandExecutor: metadata.commandExecutor as IGoodweConfig['commandExecutor'],
};
return { kind: 'done', title: 'GoodWe inverter 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 stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
return this.stringValue(metadataArg[keyArg]);
}
private numberMetadata(metadataArg: Record<string, unknown>, keyArg: string): number | undefined {
return this.numberValue(metadataArg[keyArg]);
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `goodwe://${valueArg}`);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IGoodweSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'inverter' in valueArg && 'runtimeData' in valueArg ? valueArg as IGoodweSnapshot : undefined;
};
const rawDataValue = (valueArg: unknown): Partial<IGoodweRawData> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IGoodweRawData> : undefined;
};
@@ -1,27 +1,108 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { GoodweClient } from './goodwe.classes.client.js';
import { GoodweConfigFlow } from './goodwe.classes.configflow.js';
import { createGoodweDiscoveryDescriptor } from './goodwe.discovery.js';
import { GoodweMapper } from './goodwe.mapper.js';
import type { IGoodweConfig } from './goodwe.types.js';
import { goodweDefaultTcpPort, goodweDefaultUdpPort, goodweDisplayName, goodweDomain } from './goodwe.types.js';
export class HomeAssistantGoodweIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "goodwe",
displayName: "GoodWe Inverter",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/goodwe",
"upstreamDomain": "goodwe",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"goodwe==0.4.10"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@mletenay",
"@starkillerOG"
]
},
});
export class GoodweIntegration extends BaseIntegration<IGoodweConfig> {
public readonly domain = goodweDomain;
public readonly displayName = goodweDisplayName;
public readonly status = 'read-only-runtime' as const;
public readonly discoveryDescriptor = createGoodweDiscoveryDescriptor();
public readonly configFlow = new GoodweConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/goodwe',
upstreamDomain: goodweDomain,
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['goodwe==0.4.10'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@mletenay', '@starkillerOG'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/goodwe',
platforms: ['button', 'number', 'select', 'sensor'],
defaults: {
udpPort: goodweDefaultUdpPort,
tcpFallbackPort: goodweDefaultTcpPort,
scanIntervalSeconds: 10,
},
discovery: {
manual: true,
upstreamManifestDiscovery: 'none; Home Assistant config flow asks for a host and probes UDP 8899 then TCP 502 through goodwe==0.4.10',
},
runtime: {
type: 'read-only-runtime',
polling: 'manual snapshots, raw runtime data, or injected GoodWe client snapshots',
services: ['snapshot', 'status', 'refresh', 'set_operation_mode', 'set_grid_export_limit', 'set_battery_discharge_depth', 'synchronize_clock', 'write_setting', 'read_setting'],
controls: 'delegated only to an injected client.execute or commandExecutor',
},
localApi: {
implemented: [
'manual host/snapshot/raw-data/injected-client configuration',
'GoodWe runtime snapshot normalization from read_runtime_data-style dictionaries',
'sensor, select, number, and button entity modeling from Home Assistant GoodWe platforms',
'read/control delegation to injected client or commandExecutor',
],
explicitUnsupported: [
'native GoodWe UDP AA55/Modbus protocol transport in this TypeScript package',
'broadcast inverter scan on UDP 48899',
'pretending a host-only UDP/TCP inverter refresh or write succeeded without an injected protocol client/executor',
],
},
};
public async setup(configArg: IGoodweConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new GoodweRuntime(new GoodweClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantGoodweIntegration extends GoodweIntegration {}
class GoodweRuntime implements IIntegrationRuntime {
public domain = goodweDomain;
constructor(private readonly client: GoodweClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return GoodweMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return GoodweMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === goodweDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === goodweDomain && 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 = GoodweMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported GoodWe 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();
}
}
+156
View File
@@ -0,0 +1,156 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { GoodweMapper } from './goodwe.mapper.js';
import type { IGoodweManualEntry, IGoodweRawData, IGoodweSnapshot } from './goodwe.types.js';
import { goodweDefaultUdpPort, goodweDisplayName, goodweDomain, goodweManufacturer } from './goodwe.types.js';
export class GoodweManualMatcher implements IDiscoveryMatcher<IGoodweManualEntry> {
public id = 'goodwe-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual GoodWe host, snapshot, raw-data, injected-client, and command-executor setup entries.';
public async matches(inputArg: IGoodweManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = inputArg.metadata || {};
const parsed = parseEndpoint(inputArg.host);
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const rawData = rawDataValue(inputArg.rawData || metadata.rawData || rawDataFromInput(inputArg));
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.modelFamily, metadata.name, metadata.manufacturer, metadata.model, metadata.modelFamily].filter(Boolean).join(' ').toLowerCase();
const matched = Boolean(inputArg.host || inputArg.serialNumber || inputArg.modelFamily || metadata.goodwe || hasManualData || text.includes('goodwe') || text.includes('good we'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain GoodWe setup hints.' };
}
const host = parsed?.host || inputArg.host || snapshot?.inverter.host || stringValue(metadata.host);
const port = inputArg.port || parsed?.port || snapshot?.inverter.port || numberValue(metadata.port) || goodweDefaultUdpPort;
const id = inputArg.id || inputArg.uniqueId || inputArg.serialNumber || snapshot?.inverter.serialNumber || snapshot?.inverter.id || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start GoodWe setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: goodweDomain,
id,
host,
port,
name: inputArg.name || snapshot?.inverter.name || goodweDisplayName,
manufacturer: inputArg.manufacturer || snapshot?.inverter.manufacturer || goodweManufacturer,
model: inputArg.model || snapshot?.inverter.model,
serialNumber: inputArg.serialNumber || snapshot?.inverter.serialNumber,
metadata: {
...metadata,
goodwe: true,
discoveryProtocol: 'manual',
modelFamily: inputArg.modelFamily || snapshot?.inverter.modelFamily || metadata.modelFamily,
snapshot,
rawData,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
},
},
};
}
}
export class GoodweCandidateValidator implements IDiscoveryValidator {
public id = 'goodwe-candidate-validator';
public description = 'Validate GoodWe candidates from manual setup and externally supplied discovery data.';
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 text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.name, metadata.manufacturer, metadata.model, metadata.modelFamily].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === goodweDomain
|| metadata.goodwe === true
|| text.includes('goodwe')
|| text.includes('good we');
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
const normalizedDeviceId = candidateArg.serialNumber || candidateArg.id || snapshot?.inverter.serialNumber || snapshot?.inverter.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || goodweDefaultUdpPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'GoodWe candidate lacks a host, injected client, snapshot, or raw data.' : 'Candidate is not GoodWe.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has GoodWe metadata and a local host, injected client, snapshot, or raw data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: goodweDomain,
port: candidateArg.port || goodweDefaultUdpPort,
manufacturer: candidateArg.manufacturer || goodweManufacturer,
},
};
}
}
export const createGoodweDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: goodweDomain, displayName: goodweDisplayName })
.addMatcher(new GoodweManualMatcher())
.addValidator(new GoodweCandidateValidator());
};
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `goodwe://${valueArg}`);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const rawDataFromInput = (inputArg: IGoodweManualEntry): Partial<IGoodweRawData> | undefined => {
const rawData: Partial<IGoodweRawData> = {
deviceInfo: GoodweMapper.cleanObject({
host: inputArg.host,
port: inputArg.port,
name: inputArg.name,
manufacturer: inputArg.manufacturer,
model: inputArg.model,
modelFamily: inputArg.modelFamily,
serialNumber: inputArg.serialNumber,
firmware: inputArg.firmware,
armFirmware: inputArg.armFirmware,
ratedPower: inputArg.ratedPower,
}),
runtimeData: inputArg.runtimeData,
settingsData: inputArg.settingsData,
sensors: inputArg.sensors,
settings: inputArg.settings,
};
return Object.values(rawData).some(Boolean) ? rawData : undefined;
};
const snapshotValue = (valueArg: unknown): IGoodweSnapshot | undefined => {
return valueArg && typeof valueArg === 'object' && 'inverter' in valueArg && 'runtimeData' in valueArg ? valueArg as IGoodweSnapshot : undefined;
};
const rawDataValue = (valueArg: unknown): Partial<IGoodweRawData> | undefined => {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IGoodweRawData> : 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;
};
+682
View File
@@ -0,0 +1,682 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IGoodweCommandRequest,
IGoodweConfig,
IGoodweDeviceInfo,
IGoodweRawData,
IGoodweSensorDefinition,
IGoodweSensorSnapshot,
IGoodweSettingDefinition,
IGoodweSettingSnapshot,
IGoodweSnapshot,
TGoodweOperationModeOption,
TGoodweSnapshotSource,
} from './goodwe.types.js';
import {
goodweDefaultUdpPort,
goodweDisplayName,
goodweDomain,
goodweHomeAssistantOperationModeOptions,
goodweManufacturer,
goodweOperationModeValues,
goodweOperationModes,
} from './goodwe.types.js';
interface IGoodweSnapshotOptions {
config: IGoodweConfig;
rawData?: Partial<IGoodweRawData>;
online?: boolean;
source?: TGoodweSnapshotSource;
error?: string;
}
interface IGoodweEntityDefinition {
key: string;
entityIdKey?: string;
platform: TEntityPlatform;
name: string;
state: unknown;
available?: boolean;
attributes?: Record<string, unknown>;
}
const mainSensorIds = new Set(['ppv', 'house_consumption', 'active_power', 'battery_soc', 'e_day', 'e_total', 'meter_e_total_exp', 'meter_e_total_imp', 'e_bat_charge_total', 'e_bat_discharge_total']);
const commonSensorNames: Record<string, string> = {
ppv: 'PV Power',
ppv1: 'PV1 Power',
ppv2: 'PV2 Power',
ppv3: 'PV3 Power',
ppv4: 'PV4 Power',
house_consumption: 'House Consumption',
active_power: 'Active Power',
battery_soc: 'Battery State of Charge',
e_day: 'Energy Today',
e_total: 'Energy Total',
e_load_day: 'Load Energy Today',
meter_e_total_exp: 'Meter Total Energy Export',
meter_e_total_imp: 'Meter Total Energy Import',
e_bat_charge_total: 'Battery Charge Total',
e_bat_discharge_total: 'Battery Discharge Total',
temperature: 'Temperature',
grid_mode: 'Grid Mode',
work_mode: 'Work Mode',
timestamp: 'Timestamp',
};
const commonSensorUnits: Record<string, string> = {
ppv: 'W',
ppv1: 'W',
ppv2: 'W',
ppv3: 'W',
ppv4: 'W',
house_consumption: 'W',
active_power: 'W',
battery_soc: '%',
e_day: 'kWh',
e_total: 'kWh',
e_load_day: 'kWh',
meter_e_total_exp: 'kWh',
meter_e_total_imp: 'kWh',
e_bat_charge_total: 'kWh',
e_bat_discharge_total: 'kWh',
};
const unitDescriptions: Record<string, { deviceClass?: string; stateClass?: string; unit?: string }> = {
A: { deviceClass: 'current', stateClass: 'measurement', unit: 'A' },
V: { deviceClass: 'voltage', stateClass: 'measurement', unit: 'V' },
W: { deviceClass: 'power', stateClass: 'measurement', unit: 'W' },
kWh: { deviceClass: 'energy', stateClass: 'total_increasing', unit: 'kWh' },
VA: { deviceClass: 'apparent_power', stateClass: 'measurement', unit: 'VA' },
var: { deviceClass: 'reactive_power', stateClass: 'measurement', unit: 'var' },
C: { deviceClass: 'temperature', stateClass: 'measurement', unit: 'C' },
'°C': { deviceClass: 'temperature', stateClass: 'measurement', unit: '°C' },
Hz: { deviceClass: 'frequency', stateClass: 'measurement', unit: 'Hz' },
h: { deviceClass: 'duration', stateClass: 'measurement', unit: 'h' },
'%': { stateClass: 'measurement', unit: '%' },
};
export class GoodweMapper {
public static toSnapshot(optionsArg: IGoodweSnapshotOptions): IGoodweSnapshot {
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 hasData = this.hasUsableRawData(rawData);
const device = this.inverterInfo(optionsArg.config, rawData);
const runtimeData = rawData.runtimeData || {};
const settingsData = rawData.settingsData || {};
const settings = this.settingSnapshots(rawData, settingsData);
const operationMode = this.operationMode(rawData.operationMode ?? settingsData.operation_mode ?? settingsData.work_mode);
const supportedOperationModes = this.supportedOperationModes(rawData.supportedOperationModes, operationMode);
const gridExportLimit = numberValue(rawData.gridExportLimit) ?? numberValue(settingsData.grid_export_limit);
const batteryDischargeDepth = numberValue(rawData.batteryDischargeDepth) ?? numberValue(settingsData.battery_discharge_depth) ?? numberValue(settingsData.dod);
const gridExportLimitUnit = settings.find((settingArg) => settingArg.id === 'grid_export_limit')?.unit === '%' ? '%' : 'W';
const localControl = Boolean(optionsArg.config.commandExecutor || optionsArg.config.client?.execute);
const localRead = Boolean(optionsArg.config.client || optionsArg.config.rawData || optionsArg.config.snapshot || hasData);
const sensors = this.sensorSnapshots(rawData, runtimeData, Boolean(optionsArg.online ?? optionsArg.config.online ?? hasData));
const online = optionsArg.online ?? optionsArg.config.online ?? hasData;
const source = optionsArg.source || (hasData ? 'manual' : 'runtime');
return {
inverter: device,
runtimeData: this.clone(runtimeData),
settingsData: this.clone(settingsData),
sensors,
settings,
controls: {
operationMode,
supportedOperationModes,
gridExportLimit,
gridExportLimitUnit,
batteryDischargeDepth,
},
capabilities: {
localRead,
localControl,
operationModeSelect: Boolean(operationMode || supportedOperationModes.length),
gridExportLimitNumber: gridExportLimit !== undefined || settings.some((settingArg) => settingArg.id === 'grid_export_limit'),
batteryDischargeDepthNumber: batteryDischargeDepth !== undefined || settings.some((settingArg) => settingArg.id === 'battery_discharge_depth' || settingArg.id === 'dod'),
synchronizeClockButton: localControl && settings.some((settingArg) => settingArg.id === 'time'),
},
rawData: hasData ? this.clone(rawData) : undefined,
online,
updatedAt: rawData.fetchedAt || new Date().toISOString(),
source,
error: optionsArg.error,
};
}
public static toDevices(snapshotArg: IGoodweSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'online', capability: 'sensor', name: 'Online', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'online', value: snapshotArg.online, updatedAt },
];
for (const sensorId of ['ppv', 'house_consumption', 'active_power', 'battery_soc', 'e_day', 'e_total']) {
const sensor = snapshotArg.sensors.find((sensorArg) => sensorArg.id === sensorId);
if (!sensor) {
continue;
}
features.push({ id: sensor.id, capability: sensor.unit === 'kWh' ? 'energy' : 'sensor', name: sensor.name || this.humanName(sensor.id), readable: true, writable: false, unit: sensor.unit });
state.push({ featureId: sensor.id, value: this.deviceStateValue(sensor.value), updatedAt });
}
if (snapshotArg.capabilities.operationModeSelect) {
features.push({ id: 'operation_mode', capability: 'sensor', name: 'Operation mode', readable: true, writable: snapshotArg.capabilities.localControl });
state.push({ featureId: 'operation_mode', value: snapshotArg.controls.operationMode || null, updatedAt });
}
if (snapshotArg.capabilities.gridExportLimitNumber) {
features.push({ id: 'grid_export_limit', capability: 'sensor', name: 'Grid export limit', readable: true, writable: snapshotArg.capabilities.localControl, unit: snapshotArg.controls.gridExportLimitUnit });
state.push({ featureId: 'grid_export_limit', value: snapshotArg.controls.gridExportLimit ?? null, updatedAt });
}
return [{
id: this.deviceId(snapshotArg),
integrationDomain: goodweDomain,
name: snapshotArg.inverter.name,
protocol: 'unknown',
manufacturer: snapshotArg.inverter.manufacturer,
model: snapshotArg.inverter.model || snapshotArg.inverter.modelFamily || goodweDisplayName,
online: snapshotArg.online,
features,
state,
metadata: this.cleanAttributes({
host: snapshotArg.inverter.host,
port: snapshotArg.inverter.port,
serialNumber: snapshotArg.inverter.serialNumber,
modelFamily: snapshotArg.inverter.modelFamily,
firmware: snapshotArg.inverter.firmware,
armFirmware: snapshotArg.inverter.armFirmware,
ratedPower: snapshotArg.inverter.ratedPower,
source: snapshotArg.source,
localRead: snapshotArg.capabilities.localRead,
localControl: snapshotArg.capabilities.localControl,
error: snapshotArg.error,
}),
}];
}
public static toEntities(snapshotArg: IGoodweSnapshot): IIntegrationEntity[] {
const deviceId = this.deviceId(snapshotArg);
const uniqueBase = this.uniqueBase(snapshotArg);
const entities: IIntegrationEntity[] = [];
for (const sensor of snapshotArg.sensors) {
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
key: sensor.id,
entityIdKey: sensor.name || sensor.id,
platform: 'sensor',
name: `${snapshotArg.inverter.name} ${sensor.name || this.humanName(sensor.id)}`,
state: sensor.value ?? null,
available: sensor.available,
attributes: this.cleanAttributes({
key: sensor.id,
unit: sensor.unit,
deviceClass: sensor.id === 'battery_soc' ? 'battery' : sensor.deviceClass,
stateClass: sensor.stateClass,
kind: sensor.kind,
entityCategory: sensor.entityCategory,
}),
}));
}
if (snapshotArg.capabilities.operationModeSelect) {
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
key: 'operation_mode',
platform: 'select',
name: `${snapshotArg.inverter.name} Inverter Operation Mode`,
state: snapshotArg.controls.operationMode || null,
available: snapshotArg.online && Boolean(snapshotArg.controls.operationMode),
attributes: {
options: snapshotArg.controls.supportedOperationModes,
writable: snapshotArg.capabilities.localControl,
entityCategory: 'config',
},
}));
}
if (snapshotArg.capabilities.gridExportLimitNumber) {
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
key: 'grid_export_limit',
platform: 'number',
name: `${snapshotArg.inverter.name} Grid Export Limit`,
state: snapshotArg.controls.gridExportLimit ?? null,
available: snapshotArg.online && snapshotArg.controls.gridExportLimit !== undefined,
attributes: {
unit: snapshotArg.controls.gridExportLimitUnit,
min: 0,
max: snapshotArg.controls.gridExportLimitUnit === '%' ? 200 : 10000,
step: snapshotArg.controls.gridExportLimitUnit === '%' ? 1 : 100,
writable: snapshotArg.capabilities.localControl,
entityCategory: 'config',
},
}));
}
if (snapshotArg.capabilities.batteryDischargeDepthNumber) {
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
key: 'battery_discharge_depth',
platform: 'number',
name: `${snapshotArg.inverter.name} Depth of Discharge (On-Grid)`,
state: snapshotArg.controls.batteryDischargeDepth ?? null,
available: snapshotArg.online && snapshotArg.controls.batteryDischargeDepth !== undefined,
attributes: {
unit: '%',
min: 0,
max: 99,
step: 1,
writable: snapshotArg.capabilities.localControl,
entityCategory: 'config',
},
}));
}
if (snapshotArg.capabilities.synchronizeClockButton) {
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
key: 'synchronize_clock',
platform: 'button',
name: `${snapshotArg.inverter.name} Synchronize Inverter Clock`,
state: 'available',
available: snapshotArg.online,
attributes: { entityCategory: 'config' },
}));
}
return entities;
}
public static commandForService(snapshotArg: IGoodweSnapshot, requestArg: IServiceCallRequest): IGoodweCommandRequest | undefined {
if (requestArg.domain === goodweDomain && requestArg.service === 'set_operation_mode') {
const operationMode = this.operationMode(requestArg.data?.operationMode ?? requestArg.data?.operation_mode ?? requestArg.data?.mode ?? requestArg.data?.option);
return operationMode ? { action: 'set_operation_mode', operationMode, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data } : undefined;
}
if (requestArg.domain === goodweDomain && requestArg.service === 'set_grid_export_limit') {
const gridExportLimit = numberValue(requestArg.data?.value ?? requestArg.data?.gridExportLimit ?? requestArg.data?.grid_export_limit);
return gridExportLimit === undefined ? undefined : { action: 'set_grid_export_limit', gridExportLimit, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === goodweDomain && requestArg.service === 'set_battery_discharge_depth') {
const batteryDischargeDepth = numberValue(requestArg.data?.value ?? requestArg.data?.batteryDischargeDepth ?? requestArg.data?.battery_discharge_depth);
return batteryDischargeDepth === undefined ? undefined : { action: 'set_battery_discharge_depth', batteryDischargeDepth, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === goodweDomain && ['synchronize_clock', 'sync_clock'].includes(requestArg.service)) {
return { action: 'synchronize_clock', timestamp: stringValue(requestArg.data?.timestamp) || new Date().toISOString(), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === goodweDomain && ['write_setting', 'read_setting'].includes(requestArg.service)) {
const settingId = stringValue(requestArg.data?.settingId) || stringValue(requestArg.data?.setting_id);
if (!settingId) {
return undefined;
}
return { action: requestArg.service, settingId, value: requestArg.data?.value, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
const targetEntityId = stringValue(requestArg.target.entityId);
if (requestArg.domain === 'select' && requestArg.service === 'select_option' && targetEntityId?.includes('operation_mode')) {
const operationMode = this.operationMode(requestArg.data?.option);
return operationMode ? { action: 'set_operation_mode', operationMode, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data } : undefined;
}
if (requestArg.domain === 'number' && requestArg.service === 'set_value' && targetEntityId?.includes('grid_export_limit')) {
const gridExportLimit = numberValue(requestArg.data?.value);
return gridExportLimit === undefined ? undefined : { action: 'set_grid_export_limit', gridExportLimit, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === 'number' && requestArg.service === 'set_value' && targetEntityId?.includes('battery_discharge_depth')) {
const batteryDischargeDepth = numberValue(requestArg.data?.value);
return batteryDischargeDepth === undefined ? undefined : { action: 'set_battery_discharge_depth', batteryDischargeDepth, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === 'button' && requestArg.service === 'press' && targetEntityId?.includes('synchronize_clock')) {
return { action: 'synchronize_clock', timestamp: new Date().toISOString(), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
return undefined;
}
public static normalizeSensorDefinitions(valuesArg: unknown): IGoodweSensorDefinition[] | undefined {
if (!Array.isArray(valuesArg)) {
return undefined;
}
return valuesArg.map((valueArg) => this.sensorDefinitionFromRecord(valueArg)).filter((valueArg): valueArg is IGoodweSensorDefinition => Boolean(valueArg));
}
public static normalizeSettingDefinitions(valuesArg: unknown): IGoodweSettingDefinition[] | undefined {
if (!Array.isArray(valuesArg)) {
return undefined;
}
return valuesArg.map((valueArg) => this.settingDefinitionFromRecord(valueArg)).filter((valueArg): valueArg is IGoodweSettingDefinition => Boolean(valueArg));
}
public static deviceInfoFromRecord(recordArg: Record<string, unknown>): Partial<IGoodweDeviceInfo> {
return this.cleanObject({
name: stringValue(recordArg.name) || stringValue(recordArg.deviceName),
model: stringValue(recordArg.model) || stringValue(recordArg.model_name) || stringValue(recordArg.modelName),
modelName: stringValue(recordArg.model_name) || stringValue(recordArg.modelName),
modelFamily: stringValue(recordArg.model_family) || stringValue(recordArg.modelFamily),
serialNumber: stringValue(recordArg.serial_number) || stringValue(recordArg.serialNumber),
firmware: stringValue(recordArg.firmware),
armFirmware: stringValue(recordArg.arm_firmware) || stringValue(recordArg.armFirmware),
ratedPower: numberValue(recordArg.rated_power) ?? numberValue(recordArg.ratedPower),
acOutputType: numberValue(recordArg.ac_output_type) ?? numberValue(recordArg.acOutputType),
dsp1Version: numberValue(recordArg.dsp1_version) ?? numberValue(recordArg.dsp1Version),
dsp2Version: numberValue(recordArg.dsp2_version) ?? numberValue(recordArg.dsp2Version),
dspSvnVersion: numberValue(recordArg.dsp_svn_version) ?? numberValue(recordArg.dspSvnVersion),
armVersion: numberValue(recordArg.arm_version) ?? numberValue(recordArg.armVersion),
armSvnVersion: numberValue(recordArg.arm_svn_version) ?? numberValue(recordArg.armSvnVersion),
});
}
public static hasUsableRawData(rawDataArg: Partial<IGoodweRawData> | undefined): boolean {
if (!rawDataArg) {
return false;
}
return Boolean(
rawDataArg.deviceInfo
|| Object.keys(rawDataArg.runtimeData || {}).length
|| Object.keys(rawDataArg.settingsData || {}).length
|| rawDataArg.sensors?.length
|| rawDataArg.settings?.length
|| rawDataArg.operationMode !== undefined
|| rawDataArg.gridExportLimit !== undefined
|| rawDataArg.batteryDischargeDepth !== undefined
);
}
public static cleanObject<TValue extends Record<string, unknown>>(valueArg: TValue): TValue {
return this.cleanAttributes(valueArg) as TValue;
}
public static cleanAttributes(valueArg: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(valueArg)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value) && value.length === 0) {
continue;
}
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) {
continue;
}
result[key] = value;
}
return result;
}
private static normalizeSnapshot(snapshotArg: IGoodweSnapshot, configArg: IGoodweConfig, sourceArg: TGoodweSnapshotSource, errorArg?: string): IGoodweSnapshot {
return {
...snapshotArg,
inverter: {
...snapshotArg.inverter,
host: snapshotArg.inverter.host || configArg.host,
port: snapshotArg.inverter.port || configArg.port || goodweDefaultUdpPort,
name: configArg.name || snapshotArg.inverter.name || goodweDisplayName,
},
source: sourceArg,
error: errorArg || snapshotArg.error,
};
}
private static rawData(configArg: IGoodweConfig, rawDataArg?: Partial<IGoodweRawData>): IGoodweRawData {
const source = rawDataArg || configArg.rawData || {};
const deviceInfo = this.cleanObject({
host: configArg.host,
port: configArg.port,
name: configArg.name,
manufacturer: configArg.manufacturer,
model: configArg.model,
modelFamily: configArg.modelFamily,
serialNumber: configArg.serialNumber,
firmware: configArg.firmware,
armFirmware: configArg.armFirmware,
ratedPower: configArg.ratedPower,
...(source.deviceInfo || {}),
});
return {
...source,
deviceInfo,
runtimeData: source.runtimeData || runtimeDataFromLooseRawData(source),
settingsData: source.settingsData || {},
sensors: this.normalizeSensorDefinitions(source.sensors) || [],
settings: this.normalizeSettingDefinitions(source.settings) || [],
fetchedAt: source.fetchedAt,
};
}
private static inverterInfo(configArg: IGoodweConfig, rawDataArg: IGoodweRawData): IGoodweSnapshot['inverter'] {
const info = rawDataArg.deviceInfo || {};
const serialNumber = info.serialNumber || configArg.serialNumber;
const host = info.host || configArg.host;
const port = info.port || configArg.port || (host ? goodweDefaultUdpPort : undefined);
const model = info.model || info.modelName || configArg.model;
const id = configArg.uniqueId || info.id || serialNumber || (host ? `${host}:${port || goodweDefaultUdpPort}` : 'goodwe');
return {
id,
name: info.name || configArg.name || (serialNumber ? `GoodWe ${serialNumber}` : goodweDisplayName),
host,
port,
manufacturer: info.manufacturer || configArg.manufacturer || goodweManufacturer,
model,
modelFamily: info.modelFamily || configArg.modelFamily || this.modelFamilyFromSerial(serialNumber),
serialNumber,
firmware: info.firmware || configArg.firmware,
armFirmware: info.armFirmware || configArg.armFirmware,
ratedPower: info.ratedPower || configArg.ratedPower,
configurationUrl: 'https://www.semsportal.com',
};
}
private static sensorSnapshots(rawDataArg: IGoodweRawData, runtimeDataArg: Record<string, unknown>, onlineArg: boolean): IGoodweSensorSnapshot[] {
const definitionsById = new Map<string, IGoodweSensorDefinition>();
for (const definition of rawDataArg.sensors || []) {
if (!definition.id || definition.id.startsWith('xx')) {
continue;
}
definitionsById.set(definition.id, definition);
}
for (const key of Object.keys(runtimeDataArg)) {
if (key.startsWith('xx') || definitionsById.has(key)) {
continue;
}
definitionsById.set(key, { id: key, name: commonSensorNames[key] || this.humanName(key), unit: commonSensorUnits[key] });
}
return [...definitionsById.values()].map((definitionArg) => {
const value = runtimeDataArg[definitionArg.id];
const unit = definitionArg.unit || commonSensorUnits[definitionArg.id];
const description = unit ? unitDescriptions[unit] : undefined;
return {
...definitionArg,
name: definitionArg.name || commonSensorNames[definitionArg.id] || this.humanName(definitionArg.id),
unit: description?.unit || unit,
value,
available: onlineArg && value !== undefined && value !== null,
deviceClass: description?.deviceClass,
stateClass: description?.stateClass,
entityCategory: definitionArg.entityCategory || (mainSensorIds.has(definitionArg.id) ? undefined : 'diagnostic'),
};
});
}
private static settingSnapshots(rawDataArg: IGoodweRawData, settingsDataArg: Record<string, unknown>): IGoodweSettingSnapshot[] {
const definitionsById = new Map<string, IGoodweSettingDefinition>();
for (const definition of rawDataArg.settings || []) {
if (!definition.id) {
continue;
}
definitionsById.set(definition.id, definition);
}
for (const key of Object.keys(settingsDataArg)) {
if (!definitionsById.has(key)) {
definitionsById.set(key, { id: key, name: this.humanName(key) });
}
}
return [...definitionsById.values()].map((definitionArg) => ({ ...definitionArg, value: settingsDataArg[definitionArg.id] }));
}
private static supportedOperationModes(valuesArg: unknown, activeModeArg?: TGoodweOperationModeOption): TGoodweOperationModeOption[] {
const modes = Array.isArray(valuesArg) ? valuesArg.map((valueArg) => this.operationMode(valueArg)).filter((valueArg): valueArg is TGoodweOperationModeOption => Boolean(valueArg)) : [];
const defaultModes = [...goodweHomeAssistantOperationModeOptions];
const result = modes.length ? modes : activeModeArg ? defaultModes : [];
return [...new Set(result)];
}
private static operationMode(valueArg: unknown): TGoodweOperationModeOption | undefined {
if (typeof valueArg === 'number') {
return goodweOperationModeValues[valueArg];
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const normalized = valueArg.trim().toLowerCase().replace(/[-\s]+/gu, '_');
if (/^\d+$/u.test(normalized)) {
return goodweOperationModeValues[Number(normalized)];
}
const byName = Object.values(goodweOperationModes).find((modeArg) => modeArg === normalized);
if (byName) {
return byName;
}
const enumName = normalized.replace(/^operationmode\./u, '');
const enumMap: Record<string, TGoodweOperationModeOption> = {
general: goodweOperationModes.general,
off_grid: goodweOperationModes.offGrid,
backup: goodweOperationModes.backup,
eco: goodweOperationModes.eco,
peak_shaving: goodweOperationModes.peakShaving,
self_use: goodweOperationModes.selfUse,
eco_charge: goodweOperationModes.ecoCharge,
eco_discharge: goodweOperationModes.ecoDischarge,
};
return enumMap[enumName];
}
return undefined;
}
private static entity(snapshotArg: IGoodweSnapshot, deviceIdArg: string, uniqueBaseArg: string, definitionArg: IGoodweEntityDefinition): IIntegrationEntity {
return {
id: `${definitionArg.platform}.${this.slug(`${snapshotArg.inverter.name}_${definitionArg.entityIdKey || definitionArg.key}`)}`,
uniqueId: `${goodweDomain}-${definitionArg.key}-${uniqueBaseArg}`,
integrationDomain: goodweDomain,
deviceId: deviceIdArg,
platform: definitionArg.platform,
name: definitionArg.name,
state: definitionArg.state,
attributes: this.cleanAttributes(definitionArg.attributes || {}),
available: definitionArg.available ?? snapshotArg.online,
};
}
private static deviceId(snapshotArg: IGoodweSnapshot): string {
return `${goodweDomain}.inverter.${this.slug(this.uniqueBase(snapshotArg))}`;
}
private static uniqueBase(snapshotArg: IGoodweSnapshot): string {
return snapshotArg.inverter.serialNumber || snapshotArg.inverter.id || snapshotArg.inverter.host || 'goodwe';
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) {
return valueArg;
}
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return valueArg === undefined ? null : String(valueArg);
}
private static sensorDefinitionFromRecord(valueArg: unknown): IGoodweSensorDefinition | undefined {
if (!valueArg || typeof valueArg !== 'object') {
return undefined;
}
const record = valueArg as Record<string, unknown>;
const id = stringValue(record.id) || stringValue(record.id_) || stringValue(record.key);
if (!id) {
return undefined;
}
return this.cleanObject({
id,
name: stringValue(record.name),
unit: stringValue(record.unit),
kind: stringValue(record.kind),
entityCategory: stringValue(record.entityCategory) || stringValue(record.entity_category),
enabledByDefault: typeof record.enabledByDefault === 'boolean' ? record.enabledByDefault : undefined,
}) as unknown as IGoodweSensorDefinition;
}
private static settingDefinitionFromRecord(valueArg: unknown): IGoodweSettingDefinition | undefined {
const sensor = this.sensorDefinitionFromRecord(valueArg);
if (!sensor || !valueArg || typeof valueArg !== 'object') {
return sensor;
}
const record = valueArg as Record<string, unknown>;
return this.cleanObject({
...sensor,
writable: typeof record.writable === 'boolean' ? record.writable : undefined,
min: numberValue(record.min),
max: numberValue(record.max),
step: numberValue(record.step),
}) as unknown as IGoodweSettingDefinition;
}
private static modelFamilyFromSerial(serialNumberArg: string | undefined): string | undefined {
if (!serialNumberArg) {
return undefined;
}
const serial = serialNumberArg.toUpperCase();
if (['ETU', 'ETL', 'ETR', 'BHN', 'EHU', 'BHU', 'EHR', 'BTU', 'ETT', 'HTA', 'HUB', 'EHB', 'HSB'].some((tagArg) => serial.includes(tagArg))) {
return 'ET';
}
if (['ESU', 'EMU', 'ESA', 'BPS', 'BPU', 'EMJ', 'IJL'].some((tagArg) => serial.includes(tagArg))) {
return 'ES';
}
if (['DTU', 'DTS', 'KMT', 'MSU', 'MST', 'MSC', 'DSN', 'DTN', 'DST', 'NSU', 'SSN', 'SST', 'SSX', 'SSY'].some((tagArg) => serial.includes(tagArg))) {
return 'DT';
}
return undefined;
}
private static humanName(valueArg: string): string {
return valueArg
.replace(/_/gu, ' ')
.replace(/\b\w/gu, (matchArg) => matchArg.toUpperCase())
.replace(/Pv/gu, 'PV')
.replace(/Soc/gu, 'SoC')
.replace(/Ac\b/gu, 'AC')
.replace(/Dc\b/gu, 'DC');
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/gu, '_').replace(/^_+|_+$/gu, '') || 'goodwe';
}
private static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
const runtimeDataFromLooseRawData = (rawDataArg: Partial<IGoodweRawData>): Record<string, unknown> => {
const reserved = new Set(['deviceInfo', 'runtimeData', 'settingsData', 'sensors', 'settings', 'operationMode', 'supportedOperationModes', 'gridExportLimit', 'batteryDischargeDepth', 'fetchedAt']);
const runtimeData: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawDataArg)) {
if (!reserved.has(key) && isRuntimeValue(value)) {
runtimeData[key] = value;
}
}
return runtimeData;
};
const isRuntimeValue = (valueArg: unknown): boolean => {
return ['string', 'number', 'boolean'].includes(typeof valueArg) || valueArg === null;
};
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.replace(/[^0-9+-.]/gu, ''));
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
+225 -2
View File
@@ -1,4 +1,227 @@
export interface IHomeAssistantGoodweConfig {
// TODO: replace with the TypeScript-native config for goodwe.
export const goodweDomain = 'goodwe';
export const goodweDisplayName = 'GoodWe Inverter';
export const goodweManufacturer = 'GoodWe';
export const goodweDefaultUdpPort = 8899;
export const goodweDefaultTcpPort = 502;
export const goodweDefaultTimeoutMs = 10000;
export const goodweDefaultRetries = 3;
export const goodweOperationModes = {
general: 'general',
offGrid: 'off_grid',
backup: 'backup',
eco: 'eco',
peakShaving: 'peak_shaving',
selfUse: 'self_use',
ecoCharge: 'eco_charge',
ecoDischarge: 'eco_discharge',
} as const;
export const goodweHomeAssistantOperationModeOptions = [
goodweOperationModes.general,
goodweOperationModes.offGrid,
goodweOperationModes.backup,
goodweOperationModes.eco,
goodweOperationModes.peakShaving,
goodweOperationModes.ecoCharge,
goodweOperationModes.ecoDischarge,
] as const;
export const goodweOperationModeValues: Record<number, TGoodweOperationModeOption> = {
0: goodweOperationModes.general,
1: goodweOperationModes.offGrid,
2: goodweOperationModes.backup,
3: goodweOperationModes.eco,
4: goodweOperationModes.peakShaving,
5: goodweOperationModes.selfUse,
98: goodweOperationModes.ecoCharge,
99: goodweOperationModes.ecoDischarge,
};
export type TGoodweOperationModeOption = typeof goodweOperationModes[keyof typeof goodweOperationModes];
export type TGoodweSnapshotSource = 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
export type TGoodweInverterFamily = 'ET' | 'ES' | 'DT' | string;
export type TGoodweSensorKind = 'PV' | 'AC' | 'UPS' | 'BAT' | 'GRID' | 'BMS' | string;
export interface IGoodweDeviceInfo {
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
modelName?: string;
modelFamily?: TGoodweInverterFamily;
serialNumber?: string;
firmware?: string;
armFirmware?: string;
ratedPower?: number;
acOutputType?: number;
dsp1Version?: number;
dsp2Version?: number;
dspSvnVersion?: number;
armVersion?: number;
armSvnVersion?: number;
}
export interface IGoodweSensorDefinition {
id: string;
name?: string;
unit?: string;
kind?: TGoodweSensorKind;
entityCategory?: string;
enabledByDefault?: boolean;
}
export interface IGoodweSettingDefinition extends IGoodweSensorDefinition {
writable?: boolean;
min?: number;
max?: number;
step?: number;
}
export interface IGoodweSensorSnapshot extends IGoodweSensorDefinition {
value: unknown;
available: boolean;
deviceClass?: string;
stateClass?: string;
}
export interface IGoodweSettingSnapshot extends IGoodweSettingDefinition {
value?: unknown;
}
export interface IGoodweRawData {
deviceInfo?: IGoodweDeviceInfo;
runtimeData?: Record<string, unknown>;
settingsData?: Record<string, unknown>;
sensors?: IGoodweSensorDefinition[];
settings?: IGoodweSettingDefinition[];
operationMode?: TGoodweOperationModeOption | number | string;
supportedOperationModes?: Array<TGoodweOperationModeOption | number | string>;
gridExportLimit?: number;
batteryDischargeDepth?: number;
fetchedAt?: string;
[key: string]: unknown;
}
export interface IGoodweCapabilities {
localRead: boolean;
localControl: boolean;
operationModeSelect: boolean;
gridExportLimitNumber: boolean;
batteryDischargeDepthNumber: boolean;
synchronizeClockButton: boolean;
}
export interface IGoodweInverterSnapshot {
id: string;
name: string;
host?: string;
port?: number;
manufacturer: string;
model?: string;
modelFamily?: TGoodweInverterFamily;
serialNumber?: string;
firmware?: string;
armFirmware?: string;
ratedPower?: number;
configurationUrl: string;
}
export interface IGoodweControlSnapshot {
operationMode?: TGoodweOperationModeOption;
supportedOperationModes: TGoodweOperationModeOption[];
gridExportLimit?: number;
gridExportLimitUnit: 'W' | '%';
batteryDischargeDepth?: number;
}
export interface IGoodweSnapshot {
inverter: IGoodweInverterSnapshot;
runtimeData: Record<string, unknown>;
settingsData: Record<string, unknown>;
sensors: IGoodweSensorSnapshot[];
settings: IGoodweSettingSnapshot[];
controls: IGoodweControlSnapshot;
capabilities: IGoodweCapabilities;
rawData?: IGoodweRawData;
online: boolean;
updatedAt: string;
source: TGoodweSnapshotSource;
error?: string;
}
export interface IGoodweCommandRequest {
action: string;
operationMode?: TGoodweOperationModeOption;
gridExportLimit?: number;
batteryDischargeDepth?: number;
settingId?: string;
value?: unknown;
timestamp?: string;
service?: string;
target?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface IGoodweCommandExecutor {
execute(requestArg: IGoodweCommandRequest): Promise<unknown>;
}
export interface IGoodweClientLike {
getSnapshot?: () => Promise<IGoodweSnapshot | Partial<IGoodweRawData>>;
getRawData?: () => Promise<Partial<IGoodweRawData>>;
readRuntimeData?: () => Promise<Record<string, unknown>>;
read_runtime_data?: () => Promise<Record<string, unknown>>;
readSettingsData?: () => Promise<Record<string, unknown>>;
read_settings_data?: () => Promise<Record<string, unknown>>;
getDeviceInfo?: () => Promise<Partial<IGoodweDeviceInfo> | undefined>;
readDeviceInfo?: () => Promise<Partial<IGoodweDeviceInfo> | undefined>;
read_device_info?: () => Promise<Partial<IGoodweDeviceInfo> | undefined>;
sensors?: () => IGoodweSensorDefinition[] | Array<Record<string, unknown>>;
settings?: () => IGoodweSettingDefinition[] | Array<Record<string, unknown>>;
getOperationMode?: () => Promise<TGoodweOperationModeOption | number | string | undefined>;
get_operation_mode?: () => Promise<TGoodweOperationModeOption | number | string | undefined>;
getOperationModes?: (includeEmulatedArg?: boolean) => Promise<Array<TGoodweOperationModeOption | number | string>>;
get_operation_modes?: (includeEmulatedArg?: boolean) => Promise<Array<TGoodweOperationModeOption | number | string>>;
getGridExportLimit?: () => Promise<number>;
get_grid_export_limit?: () => Promise<number>;
getOngridBatteryDod?: () => Promise<number>;
get_ongrid_battery_dod?: () => Promise<number>;
execute?: (requestArg: IGoodweCommandRequest) => Promise<unknown>;
destroy?: () => Promise<void>;
[key: string]: unknown;
}
export interface IGoodweConfig {
host?: string;
port?: number;
timeoutMs?: number;
retries?: number;
name?: string;
manufacturer?: string;
model?: string;
modelFamily?: TGoodweInverterFamily;
uniqueId?: string;
serialNumber?: string;
firmware?: string;
armFirmware?: string;
ratedPower?: number;
snapshot?: IGoodweSnapshot;
rawData?: Partial<IGoodweRawData>;
client?: IGoodweClientLike;
commandExecutor?: IGoodweCommandExecutor;
online?: boolean;
}
export interface IHomeAssistantGoodweConfig extends IGoodweConfig {}
export interface IGoodweManualEntry extends IGoodweConfig {
id?: string;
metadata?: Record<string, unknown>;
runtimeData?: Record<string, unknown>;
settingsData?: Record<string, unknown>;
sensors?: IGoodweSensorDefinition[];
settings?: IGoodweSettingDefinition[];
}
+4
View File
@@ -1,2 +1,6 @@
export * from './goodwe.classes.client.js';
export * from './goodwe.classes.configflow.js';
export * from './goodwe.classes.integration.js';
export * from './goodwe.discovery.js';
export * from './goodwe.mapper.js';
export * from './goodwe.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,469 @@
import { HomeWizardMapper } from './homewizard.mapper.js';
import type {
IHomeWizardCommandRequest,
IHomeWizardConfig,
IHomeWizardRawData,
IHomeWizardRefreshResult,
IHomeWizardSnapshot,
THomeWizardApiVersion,
THomeWizardProtocol,
} from './homewizard.types.js';
import { homeWizardDefaultHttpPort, homeWizardDefaultHttpsPort, homeWizardDefaultTimeoutMs } from './homewizard.types.js';
export class HomeWizardApiError extends Error {}
export class HomeWizardApiConnectionError extends HomeWizardApiError {}
export class HomeWizardApiAuthorizationError extends HomeWizardApiError {}
export class HomeWizardApiDisabledError extends HomeWizardApiError {}
export class HomeWizardUnsupportedCommandError extends HomeWizardApiError {}
interface IHomeWizardEndpoint {
protocol: THomeWizardProtocol;
host: string;
port: number;
}
interface IHomeWizardHttpResponse {
status: number;
text: string;
}
export class HomeWizardClient {
private currentSnapshot?: IHomeWizardSnapshot;
constructor(private readonly config: IHomeWizardConfig) {}
public static async requestToken(hostArg: string, optionsArg: { port?: number; protocol?: THomeWizardProtocol; name?: string; timeoutMs?: number; verifyTls?: boolean } = {}): Promise<string | undefined> {
const client = new HomeWizardClient({
host: hostArg,
port: optionsArg.port,
protocol: optionsArg.protocol || 'https',
apiVersion: 'v2',
timeoutMs: optionsArg.timeoutMs,
verifyTls: optionsArg.verifyTls,
});
return client.requestToken(optionsArg.name);
}
public async getSnapshot(forceRefreshArg = false): Promise<IHomeWizardSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return HomeWizardMapper.clone(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = HomeWizardMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return HomeWizardMapper.clone(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return HomeWizardMapper.clone(this.currentSnapshot);
}
if (!forceRefreshArg && this.hasManualData()) {
this.currentSnapshot = HomeWizardMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: this.config.online ?? true, source: 'manual' });
return HomeWizardMapper.clone(this.currentSnapshot);
}
if (this.hasEndpoint()) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
}
return HomeWizardMapper.clone(this.currentSnapshot);
}
this.currentSnapshot = this.offlineSnapshot('No HomeWizard local endpoint, injected client, snapshot, or raw data is configured.');
return HomeWizardMapper.clone(this.currentSnapshot);
}
public async refresh(): Promise<IHomeWizardRefreshResult> {
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 || 'HomeWizard refresh requires a live host 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: HomeWizardMapper.clone(snapshot), error };
}
}
public async fetchSnapshot(): Promise<IHomeWizardSnapshot> {
const endpoint = this.endpoint();
if (!endpoint) {
throw new HomeWizardApiConnectionError('HomeWizard local API snapshot requires config.host or config.url.');
}
const apiVersion = this.effectiveApiVersion(endpoint);
if (apiVersion === 'v2' && !this.accessToken()) {
throw new HomeWizardApiAuthorizationError('HomeWizard API v2 snapshots require config.token or config.authorizationToken.');
}
const rawData = apiVersion === 'v2' ? await this.fetchV2RawData(endpoint) : await this.fetchV1RawData(endpoint);
return HomeWizardMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http' });
}
public async execute(commandArg: IHomeWizardCommandRequest): 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 === 'request_token') {
return { token: await this.requestToken(commandArg.tokenName) };
}
if (!this.hasEndpoint()) {
throw new HomeWizardApiConnectionError('HomeWizard commands require config.host/config.url, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
}
const endpoint = this.endpoint();
if (!endpoint) {
throw new HomeWizardApiConnectionError('HomeWizard local API command requires config.host or config.url.');
}
const apiVersion = this.effectiveApiVersion(endpoint);
if (apiVersion === 'v2' && !this.accessToken()) {
throw new HomeWizardApiAuthorizationError('HomeWizard API v2 commands require config.token or config.authorizationToken.');
}
let result: unknown;
switch (commandArg.action) {
case 'set_state':
result = await this.setState(endpoint, apiVersion, commandArg);
break;
case 'set_system':
result = await this.setSystem(endpoint, apiVersion, commandArg);
break;
case 'set_battery_mode':
result = await this.setBatteryMode(endpoint, apiVersion, commandArg);
break;
case 'identify':
result = await this.identify(endpoint, apiVersion);
break;
case 'reboot':
result = await this.reboot(endpoint, apiVersion);
break;
case 'telegram':
result = await this.telegram(endpoint, apiVersion);
break;
default:
throw new HomeWizardUnsupportedCommandError(`Unsupported HomeWizard command: ${commandArg.action}`);
}
this.currentSnapshot = undefined;
return result;
}
public async requestToken(nameArg?: string): Promise<string | undefined> {
const endpoint = this.endpoint('v2');
if (!endpoint) {
throw new HomeWizardApiConnectionError('HomeWizard token requests require config.host or config.url.');
}
const response = await this.requestJson<Record<string, unknown>>(endpoint, '/api/user', {
method: 'POST',
body: { name: `local/${(nameArg || 'smarthome-exchange').slice(0, 40)}` },
apiVersion: 'v2',
allowMissingToken: true,
allowForbidden: true,
});
return typeof response?.token === 'string' && response.token ? response.token : undefined;
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async fetchV1RawData(endpointArg: IHomeWizardEndpoint): Promise<IHomeWizardRawData> {
const rawData: IHomeWizardRawData = { errors: {}, fetchedAt: new Date().toISOString() };
rawData.device = await this.requestJson(endpointArg, '/api', { apiVersion: 'v1' });
rawData.measurement = await this.requestJson(endpointArg, '/api/v1/data', { apiVersion: 'v1' });
await this.optionalResource(rawData, 'system', endpointArg, '/api/v1/system', 'v1');
await this.optionalResource(rawData, 'state', endpointArg, '/api/v1/state', 'v1');
if (!Object.keys(rawData.errors || {}).length) {
delete rawData.errors;
}
return rawData;
}
private async fetchV2RawData(endpointArg: IHomeWizardEndpoint): Promise<IHomeWizardRawData> {
const rawData: IHomeWizardRawData = { errors: {}, fetchedAt: new Date().toISOString() };
rawData.device = await this.requestJson(endpointArg, '/api', { apiVersion: 'v2' });
rawData.measurement = await this.requestJson(endpointArg, '/api/measurement', { apiVersion: 'v2' });
await this.optionalResource(rawData, 'system', endpointArg, '/api/system', 'v2');
await this.optionalResource(rawData, 'batteries', endpointArg, '/api/batteries', 'v2');
if (!Object.keys(rawData.errors || {}).length) {
delete rawData.errors;
}
return rawData;
}
private async optionalResource(rawDataArg: IHomeWizardRawData, keyArg: 'system' | 'state' | 'batteries', endpointArg: IHomeWizardEndpoint, pathArg: string, apiVersionArg: THomeWizardApiVersion): Promise<void> {
try {
rawDataArg[keyArg] = await this.requestJson(endpointArg, pathArg, { apiVersion: apiVersionArg });
} catch (errorArg) {
rawDataArg.errors = rawDataArg.errors || {};
rawDataArg.errors[keyArg] = this.errorMessage(errorArg);
}
}
private async setState(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion, commandArg: IHomeWizardCommandRequest): Promise<unknown> {
if (apiVersionArg !== 'v1') {
throw new HomeWizardUnsupportedCommandError('HomeWizard state control is represented by the v1 Energy Socket /api/v1/state endpoint.');
}
const body = cleanBody({ power_on: commandArg.powerOn, switch_lock: commandArg.switchLock, brightness: commandArg.brightness === undefined ? undefined : Math.round(commandArg.brightness) });
return this.requestJson(endpointArg, '/api/v1/state', { method: 'PUT', body, apiVersion: 'v1' });
}
private async setSystem(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion, commandArg: IHomeWizardCommandRequest): Promise<unknown> {
if (apiVersionArg === 'v1') {
const results: unknown[] = [];
if (commandArg.cloudEnabled !== undefined) {
results.push(await this.requestJson(endpointArg, '/api/v1/system', { method: 'PUT', body: { cloud_enabled: commandArg.cloudEnabled }, apiVersion: 'v1' }));
}
if (commandArg.statusLedBrightnessPct !== undefined) {
results.push(await this.requestJson(endpointArg, '/api/v1/state', { method: 'PUT', body: { brightness: Math.round(commandArg.statusLedBrightnessPct * 2.55) }, apiVersion: 'v1' }));
}
if (commandArg.apiV1Enabled !== undefined) {
throw new HomeWizardUnsupportedCommandError('api_v1_enabled is not supported by HomeWizard API v1.');
}
return results.length === 1 ? results[0] : results;
}
return this.requestJson(endpointArg, '/api/system', {
method: 'PUT',
body: cleanBody({ cloud_enabled: commandArg.cloudEnabled, status_led_brightness_pct: commandArg.statusLedBrightnessPct, api_v1_enabled: commandArg.apiV1Enabled }),
apiVersion: 'v2',
});
}
private async setBatteryMode(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion, commandArg: IHomeWizardCommandRequest): Promise<unknown> {
if (apiVersionArg !== 'v2') {
throw new HomeWizardUnsupportedCommandError('HomeWizard battery group mode control requires API v2 /api/batteries.');
}
const mode = commandArg.batteryMode;
if (!mode) {
throw new HomeWizardUnsupportedCommandError('HomeWizard battery mode commands require a mode/option.');
}
return this.requestJson(endpointArg, '/api/batteries', { method: 'PUT', body: batteryModeBody(mode), apiVersion: 'v2' });
}
private async identify(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion): Promise<unknown> {
return apiVersionArg === 'v2'
? this.requestJson(endpointArg, '/api/system/identify', { method: 'PUT', apiVersion: 'v2' })
: this.requestJson(endpointArg, '/api/v1/identify', { method: 'PUT', apiVersion: 'v1' });
}
private async reboot(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion): Promise<unknown> {
if (apiVersionArg !== 'v2') {
throw new HomeWizardUnsupportedCommandError('HomeWizard reboot requires API v2 /api/system/reboot.');
}
return this.requestJson(endpointArg, '/api/system/reboot', { method: 'PUT', apiVersion: 'v2' });
}
private async telegram(endpointArg: IHomeWizardEndpoint, apiVersionArg: THomeWizardApiVersion): Promise<string> {
const response = apiVersionArg === 'v2'
? await this.requestText(endpointArg, '/api/telegram', { apiVersion: 'v2' })
: await this.requestText(endpointArg, '/api/v1/telegram', { apiVersion: 'v1' });
return response.text;
}
private async snapshotFromClient(): Promise<IHomeWizardSnapshot> {
const client = this.config.client;
if (!client) {
throw new HomeWizardApiConnectionError('No HomeWizard client configured.');
}
if (client.getSnapshot) {
const result = await client.getSnapshot();
if (this.isSnapshot(result)) {
return HomeWizardMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return HomeWizardMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
}
if (client.getRawData) {
return HomeWizardMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
}
throw new HomeWizardApiConnectionError('HomeWizard client must expose getSnapshot() or getRawData().');
}
private async requestJson<TValue = Record<string, unknown>>(endpointArg: IHomeWizardEndpoint, pathArg: string, optionsArg: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown; apiVersion: THomeWizardApiVersion; allowMissingToken?: boolean; allowForbidden?: boolean } = { apiVersion: 'v1' }): Promise<TValue> {
const response = await this.requestText(endpointArg, pathArg, optionsArg);
if (response.status === 204 || !response.text) {
return {} as TValue;
}
return JSON.parse(response.text) as TValue;
}
private async requestText(endpointArg: IHomeWizardEndpoint, pathArg: string, optionsArg: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown; apiVersion: THomeWizardApiVersion; allowMissingToken?: boolean; allowForbidden?: boolean }): Promise<IHomeWizardHttpResponse> {
const token = this.accessToken();
if (optionsArg.apiVersion === 'v2' && !token && !optionsArg.allowMissingToken) {
throw new HomeWizardApiAuthorizationError('HomeWizard API v2 request requires config.token or config.authorizationToken.');
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (optionsArg.apiVersion === 'v2') {
headers['X-Api-Version'] = '2';
}
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await this.httpRequest(endpointArg, pathArg, {
method: optionsArg.method || 'GET',
headers,
body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
});
if (response.status === 401) {
throw new HomeWizardApiAuthorizationError('HomeWizard API authorization failed.');
}
if (response.status === 403) {
if (optionsArg.allowForbidden) {
return response;
}
throw new HomeWizardApiDisabledError('HomeWizard local API is disabled or user creation is not enabled.');
}
if (response.status === 404) {
throw new HomeWizardUnsupportedCommandError(`HomeWizard API endpoint is not available: ${pathArg}`);
}
if (response.status < 200 || response.status >= 300) {
throw new HomeWizardApiError(`HomeWizard API request failed with HTTP ${response.status}: ${response.text}`);
}
return response;
}
private async httpRequest(endpointArg: IHomeWizardEndpoint, pathArg: string, optionsArg: { method: string; headers: Record<string, string>; body?: string }): Promise<IHomeWizardHttpResponse> {
const transport = endpointArg.protocol === 'https' ? await import('node:https') : await import('node:http');
const request = (transport as { request: (...args: unknown[]) => { on(eventArg: string, handlerArg: (valueArg: Error) => void): void; write(dataArg: string): void; end(): void; destroy(errorArg?: Error): void } }).request;
const timeoutMs = this.config.timeoutMs || homeWizardDefaultTimeoutMs;
return await new Promise<IHomeWizardHttpResponse>((resolve, reject) => {
const requestOptions: Record<string, unknown> = {
method: optionsArg.method,
hostname: endpointArg.host,
port: endpointArg.port,
path: pathArg,
headers: optionsArg.headers,
};
if (endpointArg.protocol === 'https') {
requestOptions.rejectUnauthorized = this.config.verifyTls === true;
if (this.config.identifier) {
requestOptions.servername = this.config.identifier;
}
}
const req = request(requestOptions, (res: { statusCode?: number; on(eventArg: string, handlerArg: (chunkArg?: Buffer | string) => void): void }) => {
const chunks: Buffer[] = [];
res.on('data', (chunkArg?: Buffer | string) => {
if (chunkArg !== undefined) {
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') });
});
});
const timer = setTimeout(() => {
req.destroy(new HomeWizardApiConnectionError(`HomeWizard API request timed out after ${timeoutMs}ms.`));
}, timeoutMs);
req.on('error', (errorArg: Error) => {
clearTimeout(timer);
reject(errorArg instanceof HomeWizardApiError ? errorArg : new HomeWizardApiConnectionError(this.errorMessage(errorArg)));
});
if (optionsArg.body !== undefined) {
req.write(optionsArg.body);
}
req.end();
});
}
private endpoint(apiVersionArg?: THomeWizardApiVersion): IHomeWizardEndpoint | undefined {
const value = this.config.url || this.config.host;
if (!value) {
return undefined;
}
const version = apiVersionArg || this.config.apiVersion;
const defaultProtocol = this.config.protocol || (version === 'v2' ? 'https' : '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' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort),
};
} catch {
const protocol = defaultProtocol;
return { protocol, host: value, port: this.config.port || (protocol === 'https' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort) };
}
}
private effectiveApiVersion(endpointArg: IHomeWizardEndpoint): THomeWizardApiVersion {
if (this.config.apiVersion && this.config.apiVersion !== 'auto') {
return this.config.apiVersion;
}
if (this.accessToken() || endpointArg.protocol === 'https') {
return 'v2';
}
return 'v1';
}
private accessToken(): string | undefined {
return stringValue(this.config.token) || stringValue(this.config.authorizationToken);
}
private hasEndpoint(): boolean {
return Boolean(this.config.host || this.config.url);
}
private hasManualData(): boolean {
return Boolean(this.config.rawData || this.config.snapshot);
}
private isSnapshot(valueArg: unknown): valueArg is IHomeWizardSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'measurement' in valueArg && 'capabilities' in valueArg);
}
private offlineSnapshot(errorArg: string): IHomeWizardSnapshot {
return HomeWizardMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: false, source: 'runtime', error: errorArg });
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
function cleanBody(valueArg: Record<string, unknown>): Record<string, unknown> {
const body = Object.fromEntries(Object.entries(valueArg).filter(([, value]) => value !== undefined));
if (!Object.keys(body).length) {
throw new HomeWizardUnsupportedCommandError('HomeWizard command has no writable values.');
}
return body;
}
function batteryModeBody(modeArg: string): Record<string, unknown> {
switch (modeArg) {
case 'zero':
return { mode: 'zero', permissions: ['charge_allowed', 'discharge_allowed'] };
case 'zero_charge_only':
return { mode: 'zero', permissions: ['charge_allowed'] };
case 'zero_discharge_only':
return { mode: 'zero', permissions: ['discharge_allowed'] };
case 'standby':
return { mode: 'standby', permissions: [] };
case 'to_full':
return { mode: 'to_full' };
default:
return { mode: modeArg };
}
}
function stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
@@ -0,0 +1,153 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import { HomeWizardClient } from './homewizard.classes.client.js';
import type { IHomeWizardConfig, IHomeWizardRawData, IHomeWizardSnapshot, THomeWizardApiVersion, THomeWizardProtocol } from './homewizard.types.js';
import { homeWizardDefaultHttpPort, homeWizardDefaultHttpsPort } from './homewizard.types.js';
export class HomeWizardConfigFlow implements IConfigFlow<IHomeWizardConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHomeWizardConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Configure HomeWizard',
description: 'Configure a local HomeWizard Energy endpoint. API v1 uses HTTP after enabling Local API in the app; API v2 uses a bearer token obtained by pressing the device button.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: false },
{ name: 'port', label: 'Port', type: 'number', required: false },
{ name: 'apiVersion', label: 'API version', type: 'select', required: false, options: [{ label: 'Auto', value: 'auto' }, { label: 'v1 HTTP', value: 'v1' }, { label: 'v2 HTTPS', value: 'v2' }] },
{ name: 'token', label: 'API v2 token', type: 'password', required: false },
{ name: 'tokenName', label: 'Token name', type: 'text', required: false },
{ 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<IHomeWizardConfig>> {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || candidateArg.host);
let token = this.stringValue(valuesArg.token) || this.stringValue(metadata.token) || this.stringValue(metadata.authorizationToken);
const apiVersion = this.inferApiVersion(valuesArg.apiVersion || metadata.apiVersion || snapshot?.apiVersion, token, metadata.protocol, candidateArg.port || parsed?.port);
const protocol = this.protocol(metadata.protocol, apiVersion);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.host;
const port = this.numberValue(valuesArg.port) || parsed?.port || candidateArg.port || snapshot?.port || (apiVersion === 'v2' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort);
const hasManualData = Boolean(snapshot || rawData || metadata.client);
if (!host && !hasManualData) {
return { kind: 'error', title: 'HomeWizard setup failed', error: 'HomeWizard host, injected client, snapshot, or raw data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'HomeWizard setup failed', error: 'HomeWizard port must be between 1 and 65535.' };
}
const config: IHomeWizardConfig = {
host,
port,
protocol,
apiVersion,
token,
timeoutMs: 5000,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.productName || this.stringValue(metadata.productName),
productName: this.stringValue(metadata.productName) || snapshot?.device.productName,
productType: this.stringValue(metadata.productType) || candidateArg.model || snapshot?.device.productType,
serial: this.stringValue(metadata.serial) || candidateArg.serialNumber || snapshot?.device.serial,
uniqueId: candidateArg.id || snapshot?.device.id || (host ? `${host}:${port}` : undefined),
identifier: this.stringValue(metadata.identifier) || snapshot?.device.verificationId,
verifyTls: metadata.verifyTls === true,
snapshot,
rawData,
client: metadata.client as IHomeWizardConfig['client'],
commandExecutor: metadata.commandExecutor as IHomeWizardConfig['commandExecutor'],
};
if (host && apiVersion === 'v2' && !token && !hasManualData) {
token = await HomeWizardClient.requestToken(host, {
port,
protocol,
name: this.stringValue(valuesArg.tokenName) || 'smarthome-exchange',
timeoutMs: 2000,
verifyTls: config.verifyTls,
}).catch(() => undefined);
if (!token) {
return {
kind: 'wait',
title: 'Authorize HomeWizard',
description: 'Press the HomeWizard device button, then submit again to request an API v2 token. No configuration is stored until a token or another usable local data source is available.',
};
}
config.token = token;
}
if (host && !hasManualData) {
const snapshotResult = await new HomeWizardClient(config).getSnapshot(true);
if (!snapshotResult.online || snapshotResult.error) {
return { kind: 'error', title: 'HomeWizard setup failed', error: snapshotResult.error || 'HomeWizard local API did not return a usable snapshot.' };
}
config.productName = config.productName || snapshotResult.device.productName;
config.productType = config.productType || snapshotResult.device.productType;
config.serial = config.serial || snapshotResult.device.serial;
config.uniqueId = `${snapshotResult.device.productType}_${snapshotResult.device.serial}`;
config.identifier = config.identifier || snapshotResult.device.verificationId;
}
return { kind: 'done', title: 'HomeWizard 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 apiVersion(valueArg: unknown): THomeWizardApiVersion {
return valueArg === 'v1' || valueArg === 'v2' ? valueArg : 'auto';
}
private inferApiVersion(valueArg: unknown, tokenArg: string | undefined, protocolArg: unknown, portArg: number | undefined): THomeWizardApiVersion {
const explicit = this.apiVersion(valueArg);
if (explicit !== 'auto') {
return explicit;
}
if (tokenArg || protocolArg === 'https' || portArg === homeWizardDefaultHttpsPort) {
return 'v2';
}
return 'auto';
}
private protocol(valueArg: unknown, apiVersionArg: THomeWizardApiVersion): THomeWizardProtocol | undefined {
if (valueArg === 'http' || valueArg === 'https') {
return valueArg;
}
return apiVersionArg === 'v2' ? 'https' : undefined;
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
const snapshotValue = (valueArg: unknown): IHomeWizardSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'measurement' in valueArg ? valueArg as IHomeWizardSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IHomeWizardRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHomeWizardRawData> : undefined;
const parseEndpoint = (valueArg: string | undefined): { 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 { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
@@ -1,27 +1,108 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { BaseIntegration } from '../../core/classes.baseintegration.js';
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { HomeWizardClient } from './homewizard.classes.client.js';
import { HomeWizardConfigFlow } from './homewizard.classes.configflow.js';
import { createHomeWizardDiscoveryDescriptor } from './homewizard.discovery.js';
import { HomeWizardMapper } from './homewizard.mapper.js';
import type { IHomeWizardConfig } from './homewizard.types.js';
import { homeWizardDefaultHttpPort, homeWizardDefaultHttpsPort, homeWizardDisplayName, homeWizardDomain, homeWizardZeroconfTypes } from './homewizard.types.js';
export class HomeAssistantHomewizardIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "homewizard",
displayName: "HomeWizard",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/homewizard",
"upstreamDomain": "homewizard",
"integrationType": "device",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"python-homewizard-energy==10.0.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@DCSBL"
]
},
});
export class HomeWizardIntegration extends BaseIntegration<IHomeWizardConfig> {
public readonly domain = homeWizardDomain;
public readonly displayName = homeWizardDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createHomeWizardDiscoveryDescriptor();
public readonly configFlow = new HomeWizardConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/homewizard',
upstreamDomain: homeWizardDomain,
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: 'platinum',
requirements: ['python-homewizard-energy==10.0.1'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@DCSBL'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/homewizard',
zeroconf: [...homeWizardZeroconfTypes],
dhcp: [{ registered_devices: true }],
discovery: {
zeroconf: [...homeWizardZeroconfTypes],
dhcp: 'Home Assistant registered_devices DHCP updates plus HomeWizard Energy hostname hints',
manual: true,
},
runtime: {
type: 'control-runtime',
polling: 'local HomeWizard Energy HTTP API snapshots (v1 over HTTP and v2 over HTTPS/HTTP when configured)',
services: ['snapshot', 'status', 'refresh', 'switches', 'status light brightness', 'battery group mode', 'identify', 'reboot', 'telegram', 'API v2 token request'],
controls: true,
},
localApi: {
implemented: [
'zeroconf matching for _hwenergy._tcp.local and _homewizard._tcp.local from the Home Assistant manifest',
'DHCP registered-device/manual discovery candidates',
`API v1 GET http://host:${homeWizardDefaultHttpPort}/api and /api/v1/data/system/state/telegram plus PUT /api/v1/state/system/identify`,
`API v2 GET https://host:${homeWizardDefaultHttpsPort}/api, /api/measurement, /api/system, /api/batteries, /api/telegram plus POST /api/user and PUT /api/system, /api/system/identify, /api/system/reboot, /api/batteries`,
'static snapshot/raw-data and injected client/executor operation',
],
explicitUnsupported: [
'cloud HomeWizard account APIs and historical cloud data',
'API v2 websocket push streaming in this polling runtime layer',
'pretending service calls succeeded without host/client/commandExecutor',
'API v2 Energy Socket state control until HomeWizard exposes that endpoint publicly',
],
},
};
public async setup(configArg: IHomeWizardConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new HomeWizardRuntime(new HomeWizardClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantHomewizardIntegration extends HomeWizardIntegration {}
class HomeWizardRuntime implements IIntegrationRuntime {
public domain = homeWizardDomain;
constructor(private readonly client: HomeWizardClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return HomeWizardMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return HomeWizardMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === homeWizardDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === homeWizardDomain && 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 = HomeWizardMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported HomeWizard 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,242 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IHomeWizardDhcpRecord, IHomeWizardManualEntry, IHomeWizardRawData, IHomeWizardSnapshot, IHomeWizardZeroconfRecord, THomeWizardApiVersion } from './homewizard.types.js';
import { homeWizardDefaultHttpPort, homeWizardDefaultHttpsPort, homeWizardDisplayName, homeWizardDomain, homeWizardProductNames, homeWizardZeroconfTypes } from './homewizard.types.js';
export class HomeWizardZeroconfMatcher implements IDiscoveryMatcher<IHomeWizardZeroconfRecord> {
public id = 'homewizard-zeroconf-match';
public source = 'mdns' as const;
public description = 'Recognize HomeWizard Energy _hwenergy._tcp.local and _homewizard._tcp.local zeroconf advertisements from the Home Assistant manifest.';
public async matches(recordArg: IHomeWizardZeroconfRecord): Promise<IDiscoveryMatch> {
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const isV1 = type === normalizeMdnsType(homeWizardZeroconfTypes[0]) || type.includes('_hwenergy');
const isV2 = type === normalizeMdnsType(homeWizardZeroconfTypes[1]) || type.includes('_homewizard');
const matched = isV1 || isV2 || recordArg.metadata?.homewizard === true;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a HomeWizard Energy zeroconf advertisement.' };
}
const apiVersion: THomeWizardApiVersion = isV2 ? 'v2' : 'v1';
const serial = stringValue(txt.serial);
const productType = stringValue(txt.product_type);
const productName = stringValue(txt.product_name) || productType && homeWizardProductNames[productType] || cleanServiceName(recordArg.name) || homeWizardDisplayName;
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
const id = productType && serial ? `${productType}_${serial}` : serial || stringValue(txt.id) || cleanServiceName(recordArg.name) || host;
return {
matched: true,
confidence: host && serial && productType ? 'certain' : host ? 'high' : 'medium',
reason: `mDNS service matches ${isV2 ? homeWizardZeroconfTypes[1] : homeWizardZeroconfTypes[0]}.`,
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: homeWizardDomain,
id,
host,
port: recordArg.port || (apiVersion === 'v2' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort),
name: productName,
manufacturer: homeWizardDisplayName,
model: productType,
serialNumber: serial,
macAddress: normalizeMac(serial),
metadata: {
...recordArg.metadata,
homewizard: true,
discoveryProtocol: 'zeroconf',
mdnsType: type,
mdnsName: recordArg.name,
apiVersion,
localApiEnabled: txt.api_enabled === undefined ? undefined : txt.api_enabled !== '0',
path: txt.path,
identifier: txt.id,
productType,
productName,
serial,
txt,
},
},
};
}
}
export class HomeWizardDhcpMatcher implements IDiscoveryMatcher<IHomeWizardDhcpRecord> {
public id = 'homewizard-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize HomeWizard registered DHCP devices and HomeWizard Energy hostnames; the Home Assistant manifest uses registered_devices DHCP updates.';
public async matches(recordArg: IHomeWizardDhcpRecord): Promise<IDiscoveryMatch> {
const metadata = recordArg.metadata || {};
const hostname = stringValue(recordArg.hostname) || stringValue(recordArg.hostName) || '';
const normalizedHostname = hostname.toLowerCase();
const productType = stringValue(recordArg.product_type) || stringValue(recordArg.productType) || stringValue(metadata.productType);
const serial = stringValue(recordArg.serial) || stringValue(metadata.serial) || normalizeMac(recordArg.macaddress || recordArg.macAddress)?.replace(/:/g, '');
const hostMatches = ['p1meter-', 'energysocket-', 'kwhmeter-', 'watermeter-', 'homewizard-'].some((prefixArg) => normalizedHostname.startsWith(prefixArg));
const matched = Boolean(recordArg.registered_devices || recordArg.registeredDevices || metadata.homewizard === true || productType || hostMatches || recordArg.metadata?.integrationDomain === homeWizardDomain);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP lease does not look like a registered HomeWizard Energy device.' };
}
const host = recordArg.ip || recordArg.address;
const id = productType && serial ? `${productType}_${serial}` : serial || hostname || host;
return {
matched: true,
confidence: host && id ? 'high' : 'medium',
reason: recordArg.registered_devices || recordArg.registeredDevices ? 'DHCP lease came from registered HomeWizard devices.' : 'DHCP lease contains HomeWizard Energy hostname or metadata.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: homeWizardDomain,
id,
host,
port: homeWizardDefaultHttpPort,
name: stringValue(recordArg.product_name) || stringValue(recordArg.productName) || stringValue(metadata.productName) || hostname || homeWizardDisplayName,
manufacturer: homeWizardDisplayName,
model: productType,
serialNumber: serial,
macAddress: normalizeMac(recordArg.macaddress || recordArg.macAddress || serial),
metadata: {
...metadata,
homewizard: true,
discoveryProtocol: 'dhcp',
apiVersion: stringValue(metadata.apiVersion) || 'auto',
hostname,
productType,
serial,
},
},
};
}
}
export class HomeWizardManualMatcher implements IDiscoveryMatcher<IHomeWizardManualEntry> {
public id = 'homewizard-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual HomeWizard host/token, snapshot, raw-data, and injected-client setup entries.';
public async matches(inputArg: IHomeWizardManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
const parsed = parseEndpoint(inputArg.url || inputArg.host);
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.productName, inputArg.productType, metadata.name, metadata.productName, metadata.productType].filter(Boolean).join(' ').toLowerCase();
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
const matched = Boolean(inputArg.host || inputArg.url || inputArg.token || inputArg.authorizationToken || inputArg.productType || metadata.homewizard || hasManualData || text.includes('homewizard') || text.includes('hwe-'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain HomeWizard setup hints.' };
}
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.host;
const apiVersion = inputArg.apiVersion || stringValue(metadata.apiVersion) as THomeWizardApiVersion | undefined || snapshot?.apiVersion || 'auto';
const port = inputArg.port || parsed?.port || snapshot?.port || (apiVersion === 'v2' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort);
const id = inputArg.id || inputArg.uniqueId || snapshot?.device.id || inputArg.serial || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start HomeWizard setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: homeWizardDomain,
id,
host,
port,
name: inputArg.name || snapshot?.device.productName || inputArg.productName || homeWizardDisplayName,
manufacturer: homeWizardDisplayName,
model: inputArg.model || inputArg.productType || snapshot?.device.productType,
serialNumber: inputArg.serial || snapshot?.device.serial,
metadata: {
...metadata,
homewizard: true,
discoveryProtocol: 'manual',
apiVersion,
token: inputArg.token || inputArg.authorizationToken || metadata.token || metadata.authorizationToken,
productType: inputArg.productType || snapshot?.device.productType,
productName: inputArg.productName || snapshot?.device.productName,
serial: inputArg.serial || snapshot?.device.serial,
protocol: inputArg.protocol,
verifyTls: metadata.verifyTls,
snapshot,
rawData,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
},
},
};
}
}
export class HomeWizardCandidateValidator implements IDiscoveryValidator {
public id = 'homewizard-candidate-validator';
public description = 'Validate HomeWizard candidates from zeroconf, DHCP, and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.mdnsType, metadata.productType].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === homeWizardDomain || metadata.homewizard === true || text.includes('homewizard') || text.includes('_hwenergy') || text.includes('_homewizard') || text.includes('hwe-');
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
const normalizedDeviceId = candidateArg.id || snapshot?.device.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || homeWizardDefaultHttpPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'HomeWizard candidate lacks a host, injected client, snapshot, or raw data.' : 'Candidate is not HomeWizard.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has HomeWizard metadata and a usable local endpoint, client, snapshot, or raw data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: homeWizardDomain,
manufacturer: candidateArg.manufacturer || homeWizardDisplayName,
},
};
}
}
export const createHomeWizardDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: homeWizardDomain, displayName: homeWizardDisplayName })
.addMatcher(new HomeWizardZeroconfMatcher())
.addMatcher(new HomeWizardDhcpMatcher())
.addMatcher(new HomeWizardManualMatcher())
.addValidator(new HomeWizardCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(recordArg)) {
normalized[key.toLowerCase()] = value;
}
return normalized;
};
const cleanServiceName = (valueArg: string | undefined): string | undefined => valueArg?.replace(/\._.*$/u, '').replace(/\.local\.?$/i, '').trim() || undefined;
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : 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 snapshotValue = (valueArg: unknown): IHomeWizardSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'measurement' in valueArg ? valueArg as IHomeWizardSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IHomeWizardRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHomeWizardRawData> : undefined;
const parseEndpoint = (valueArg: string | undefined): { 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 { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
@@ -0,0 +1,840 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IHomeWizardBatteriesSnapshot,
IHomeWizardCommandRequest,
IHomeWizardConfig,
IHomeWizardDeviceSnapshot,
IHomeWizardExternalDevice,
IHomeWizardMeasurementSnapshot,
IHomeWizardRawData,
IHomeWizardSnapshot,
IHomeWizardStateSnapshot,
IHomeWizardSystemSnapshot,
THomeWizardApiVersion,
THomeWizardProtocol,
THomeWizardSnapshotSource,
} from './homewizard.types.js';
import {
homeWizardDefaultHttpPort,
homeWizardDefaultHttpsPort,
homeWizardDisplayName,
homeWizardDomain,
homeWizardProductNames,
homeWizardProductTypes,
homeWizardVerificationIds,
} from './homewizard.types.js';
interface IHomeWizardSnapshotOptions {
config: IHomeWizardConfig;
rawData?: Partial<IHomeWizardRawData>;
online?: boolean;
source?: THomeWizardSnapshotSource;
error?: string;
}
interface IHomeWizardSensorDefinition {
key: string;
name: string;
unit?: string;
deviceClass?: string;
stateClass?: string;
entityCategory?: string;
value(snapshotArg: IHomeWizardSnapshot): unknown;
}
interface IHomeWizardEntityOptions {
platform: TEntityPlatform;
key: string;
name: string;
state: unknown;
available: boolean;
attributes?: Record<string, unknown>;
deviceId?: string;
}
const batteryManagedProductTypes = new Set<string>([
homeWizardProductTypes.p1Meter,
homeWizardProductTypes.energyMeter1Phase,
homeWizardProductTypes.energyMeter3Phase,
homeWizardProductTypes.eastronSdm230,
homeWizardProductTypes.eastronSdm630,
]);
const identifyUnsupportedProductTypes = new Set<string>([
homeWizardProductTypes.energyMeter1Phase,
homeWizardProductTypes.energyMeter3Phase,
homeWizardProductTypes.eastronSdm230,
homeWizardProductTypes.eastronSdm630,
]);
const productionPowerProductTypes = new Set<string>([
homeWizardProductTypes.energySocket,
homeWizardProductTypes.energyMeter1Phase,
homeWizardProductTypes.energyMeter3Phase,
homeWizardProductTypes.eastronSdm230,
homeWizardProductTypes.eastronSdm630,
homeWizardProductTypes.battery,
]);
const sensorDefinitions: IHomeWizardSensorDefinition[] = [
sensor('smr_version', 'DSMR version', (snapshotArg) => snapshotArg.measurement.protocol_version, { entityCategory: 'diagnostic' }),
sensor('meter_model', 'Smart meter model', (snapshotArg) => snapshotArg.measurement.meter_model, { entityCategory: 'diagnostic' }),
sensor('unique_meter_id', 'Smart meter identifier', (snapshotArg) => snapshotArg.measurement.unique_id, { entityCategory: 'diagnostic' }),
sensor('wifi_ssid', 'Wi-Fi SSID', (snapshotArg) => snapshotArg.system?.wifi_ssid, { entityCategory: 'diagnostic' }),
sensor('active_tariff', 'Tariff', (snapshotArg) => valueToString(snapshotArg.measurement.tariff), { deviceClass: 'enum' }),
sensor('wifi_strength', 'Wi-Fi strength', (snapshotArg) => snapshotArg.system?.wifi_strength_pct, { unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }),
sensor('wifi_rssi', 'Wi-Fi RSSI', (snapshotArg) => snapshotArg.system?.wifi_rssi_db, { unit: 'dB', stateClass: 'measurement', entityCategory: 'diagnostic' }),
sensor('total_power_import_kwh', 'Energy import', (snapshotArg) => snapshotArg.measurement.energy_import_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_import_t1_kwh', 'Energy import tariff 1', (snapshotArg) => snapshotArg.measurement.energy_import_t1_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_import_t2_kwh', 'Energy import tariff 2', (snapshotArg) => snapshotArg.measurement.energy_import_t2_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_import_t3_kwh', 'Energy import tariff 3', (snapshotArg) => snapshotArg.measurement.energy_import_t3_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_import_t4_kwh', 'Energy import tariff 4', (snapshotArg) => snapshotArg.measurement.energy_import_t4_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_export_kwh', 'Energy export', (snapshotArg) => snapshotArg.measurement.energy_export_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_export_t1_kwh', 'Energy export tariff 1', (snapshotArg) => snapshotArg.measurement.energy_export_t1_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_export_t2_kwh', 'Energy export tariff 2', (snapshotArg) => snapshotArg.measurement.energy_export_t2_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_export_t3_kwh', 'Energy export tariff 3', (snapshotArg) => snapshotArg.measurement.energy_export_t3_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('total_power_export_t4_kwh', 'Energy export tariff 4', (snapshotArg) => snapshotArg.measurement.energy_export_t4_kwh, { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }),
sensor('active_power_w', 'Power', (snapshotArg) => snapshotArg.measurement.power_w, { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }),
sensor('active_power_l1_w', 'Power phase 1', (snapshotArg) => snapshotArg.measurement.power_l1_w, { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }),
sensor('active_power_l2_w', 'Power phase 2', (snapshotArg) => snapshotArg.measurement.power_l2_w, { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }),
sensor('active_power_l3_w', 'Power phase 3', (snapshotArg) => snapshotArg.measurement.power_l3_w, { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }),
sensor('active_voltage_v', 'Voltage', (snapshotArg) => snapshotArg.measurement.voltage_v, { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }),
sensor('active_voltage_l1_v', 'Voltage phase 1', (snapshotArg) => snapshotArg.measurement.voltage_l1_v, { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }),
sensor('active_voltage_l2_v', 'Voltage phase 2', (snapshotArg) => snapshotArg.measurement.voltage_l2_v, { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }),
sensor('active_voltage_l3_v', 'Voltage phase 3', (snapshotArg) => snapshotArg.measurement.voltage_l3_v, { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }),
sensor('active_current_a', 'Current', (snapshotArg) => snapshotArg.measurement.current_a, { unit: 'A', deviceClass: 'current', stateClass: 'measurement' }),
sensor('active_current_l1_a', 'Current phase 1', (snapshotArg) => snapshotArg.measurement.current_l1_a, { unit: 'A', deviceClass: 'current', stateClass: 'measurement' }),
sensor('active_current_l2_a', 'Current phase 2', (snapshotArg) => snapshotArg.measurement.current_l2_a, { unit: 'A', deviceClass: 'current', stateClass: 'measurement' }),
sensor('active_current_l3_a', 'Current phase 3', (snapshotArg) => snapshotArg.measurement.current_l3_a, { unit: 'A', deviceClass: 'current', stateClass: 'measurement' }),
sensor('active_frequency_hz', 'Frequency', (snapshotArg) => snapshotArg.measurement.frequency_hz, { unit: 'Hz', deviceClass: 'frequency', stateClass: 'measurement' }),
sensor('active_apparent_power_va', 'Apparent power', (snapshotArg) => snapshotArg.measurement.apparent_power_va, { unit: 'VA', deviceClass: 'apparent_power', stateClass: 'measurement' }),
sensor('active_apparent_power_l1_va', 'Apparent power phase 1', (snapshotArg) => snapshotArg.measurement.apparent_power_l1_va, { unit: 'VA', deviceClass: 'apparent_power', stateClass: 'measurement' }),
sensor('active_apparent_power_l2_va', 'Apparent power phase 2', (snapshotArg) => snapshotArg.measurement.apparent_power_l2_va, { unit: 'VA', deviceClass: 'apparent_power', stateClass: 'measurement' }),
sensor('active_apparent_power_l3_va', 'Apparent power phase 3', (snapshotArg) => snapshotArg.measurement.apparent_power_l3_va, { unit: 'VA', deviceClass: 'apparent_power', stateClass: 'measurement' }),
sensor('active_reactive_power_var', 'Reactive power', (snapshotArg) => snapshotArg.measurement.reactive_power_var, { unit: 'var', deviceClass: 'reactive_power', stateClass: 'measurement' }),
sensor('active_reactive_power_l1_var', 'Reactive power phase 1', (snapshotArg) => snapshotArg.measurement.reactive_power_l1_var, { unit: 'var', deviceClass: 'reactive_power', stateClass: 'measurement' }),
sensor('active_reactive_power_l2_var', 'Reactive power phase 2', (snapshotArg) => snapshotArg.measurement.reactive_power_l2_var, { unit: 'var', deviceClass: 'reactive_power', stateClass: 'measurement' }),
sensor('active_reactive_power_l3_var', 'Reactive power phase 3', (snapshotArg) => snapshotArg.measurement.reactive_power_l3_var, { unit: 'var', deviceClass: 'reactive_power', stateClass: 'measurement' }),
sensor('active_power_factor', 'Power factor', (snapshotArg) => percentage(snapshotArg.measurement.power_factor), { unit: '%', deviceClass: 'power_factor', stateClass: 'measurement' }),
sensor('active_power_factor_l1', 'Power factor phase 1', (snapshotArg) => percentage(snapshotArg.measurement.power_factor_l1), { unit: '%', deviceClass: 'power_factor', stateClass: 'measurement' }),
sensor('active_power_factor_l2', 'Power factor phase 2', (snapshotArg) => percentage(snapshotArg.measurement.power_factor_l2), { unit: '%', deviceClass: 'power_factor', stateClass: 'measurement' }),
sensor('active_power_factor_l3', 'Power factor phase 3', (snapshotArg) => percentage(snapshotArg.measurement.power_factor_l3), { unit: '%', deviceClass: 'power_factor', stateClass: 'measurement' }),
sensor('voltage_sag_l1_count', 'Voltage sags detected phase 1', (snapshotArg) => snapshotArg.measurement.voltage_sag_l1_count, { entityCategory: 'diagnostic' }),
sensor('voltage_sag_l2_count', 'Voltage sags detected phase 2', (snapshotArg) => snapshotArg.measurement.voltage_sag_l2_count, { entityCategory: 'diagnostic' }),
sensor('voltage_sag_l3_count', 'Voltage sags detected phase 3', (snapshotArg) => snapshotArg.measurement.voltage_sag_l3_count, { entityCategory: 'diagnostic' }),
sensor('voltage_swell_l1_count', 'Voltage swells detected phase 1', (snapshotArg) => snapshotArg.measurement.voltage_swell_l1_count, { entityCategory: 'diagnostic' }),
sensor('voltage_swell_l2_count', 'Voltage swells detected phase 2', (snapshotArg) => snapshotArg.measurement.voltage_swell_l2_count, { entityCategory: 'diagnostic' }),
sensor('voltage_swell_l3_count', 'Voltage swells detected phase 3', (snapshotArg) => snapshotArg.measurement.voltage_swell_l3_count, { entityCategory: 'diagnostic' }),
sensor('any_power_fail_count', 'Power failures detected', (snapshotArg) => snapshotArg.measurement.any_power_fail_count, { entityCategory: 'diagnostic' }),
sensor('long_power_fail_count', 'Long power failures detected', (snapshotArg) => snapshotArg.measurement.long_power_fail_count, { entityCategory: 'diagnostic' }),
sensor('active_power_average_w', 'Average demand', (snapshotArg) => snapshotArg.measurement.average_power_15m_w, { unit: 'W', deviceClass: 'power' }),
sensor('monthly_power_peak_w', 'Peak demand current month', (snapshotArg) => snapshotArg.measurement.monthly_power_peak_w, { unit: 'W', deviceClass: 'power' }),
sensor('active_liter_lpm', 'Water usage', (snapshotArg) => snapshotArg.measurement.active_liter_lpm, { unit: 'L/min', deviceClass: 'volume_flow_rate', stateClass: 'measurement' }),
sensor('total_liter_m3', 'Total water usage', (snapshotArg) => snapshotArg.measurement.total_liter_m3, { unit: 'm3', deviceClass: 'water', stateClass: 'total_increasing' }),
sensor('state_of_charge_pct', 'State of charge', (snapshotArg) => snapshotArg.measurement.state_of_charge_pct, { unit: '%', deviceClass: 'battery', stateClass: 'measurement' }),
sensor('cycles', 'Battery cycles', (snapshotArg) => snapshotArg.measurement.cycles, { stateClass: 'total_increasing', entityCategory: 'diagnostic' }),
sensor('uptime', 'Uptime', (snapshotArg) => uptimeIso(snapshotArg.system?.uptime_s), { deviceClass: 'timestamp', entityCategory: 'diagnostic' }),
];
export class HomeWizardMapper {
public static toSnapshot(optionsArg: IHomeWizardSnapshotOptions): IHomeWizardSnapshot {
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 device = this.device(optionsArg.config, rawData.device);
const endpoint = parseEndpoint(optionsArg.config.url || optionsArg.config.host, optionsArg.config.port, optionsArg.config.protocol, optionsArg.config.apiVersion);
const apiVersion = this.apiVersion(optionsArg.config, device, endpoint?.protocol);
const measurement = this.measurement(rawData.measurement || {});
const state = objectValue(rawData.state) as IHomeWizardStateSnapshot | undefined;
const system = this.system(rawData.system, measurement, state);
const batteries = objectValue(rawData.batteries) as IHomeWizardBatteriesSnapshot | undefined;
const externalDevices = this.externalDevices(measurement);
measurement.externalDevices = externalDevices;
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData.device || rawData.measurement || rawData.system || rawData.state || rawData.batteries);
const source = optionsArg.source || (rawData.device || rawData.measurement ? 'http' : 'runtime');
const snapshot: IHomeWizardSnapshot = {
device,
host: endpoint?.host,
port: endpoint?.port,
protocol: endpoint?.protocol,
apiVersion,
measurement,
system,
state,
batteries,
externalDevices,
capabilities: this.capabilities(optionsArg.config, device, apiVersion, state, system, batteries, endpoint?.host),
rawData: Object.keys(rawData).length ? rawData : undefined,
online,
updatedAt: rawData.fetchedAt || new Date().toISOString(),
source,
error: optionsArg.error,
};
return this.normalizeSnapshot(snapshot, optionsArg.config, source, optionsArg.error);
}
public static toDevices(snapshotArg: IHomeWizardSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features = this.toEntities(snapshotArg)
.filter((entityArg) => entityArg.deviceId === this.mainDeviceId(snapshotArg))
.map((entityArg) => ({
id: String(entityArg.attributes?.key || entityArg.uniqueId),
capability: deviceCapability(entityArg.platform),
name: entityArg.name,
readable: true,
writable: ['switch', 'number', 'select', 'button'].includes(entityArg.platform),
}));
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.mainDeviceId(snapshotArg),
integrationDomain: homeWizardDomain,
name: snapshotArg.device.productName,
protocol: snapshotArg.host ? 'http' : 'unknown',
manufacturer: homeWizardDisplayName,
model: snapshotArg.device.modelName || snapshotArg.device.productType,
online: snapshotArg.online,
features: [
{ id: 'online', capability: 'sensor', name: 'Online', readable: true, writable: false },
...features,
],
state: [
{ featureId: 'online', value: snapshotArg.online, updatedAt },
...features.map((featureArg) => ({ featureId: featureArg.id, value: deviceStateValue(this.featureState(snapshotArg, featureArg.id)), updatedAt })),
],
metadata: cleanAttributes({
serial: snapshotArg.device.serial,
productType: snapshotArg.device.productType,
apiVersion: snapshotArg.device.apiVersion,
firmwareVersion: snapshotArg.device.firmwareVersion,
verificationId: snapshotArg.device.verificationId,
host: snapshotArg.host,
port: snapshotArg.port,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
for (const externalDevice of snapshotArg.externalDevices) {
devices.push({
id: this.externalDeviceId(snapshotArg, externalDevice),
integrationDomain: homeWizardDomain,
name: externalDeviceName(externalDevice.type),
protocol: snapshotArg.host ? 'http' : 'unknown',
manufacturer: homeWizardDisplayName,
model: snapshotArg.device.productType,
online: snapshotArg.online,
features: [{ id: 'value', capability: 'sensor', name: 'Value', readable: true, writable: false }],
state: [{ featureId: 'value', value: deviceStateValue(externalDevice.value), updatedAt }],
metadata: cleanAttributes({
viaDevice: this.mainDeviceId(snapshotArg),
serial: externalDevice.uniqueId,
type: externalDevice.type,
unit: externalDevice.unit,
timestamp: externalDevice.timestamp,
}),
});
}
return devices;
}
public static toEntities(snapshotArg: IHomeWizardSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
for (const definition of sensorDefinitions) {
const state = definition.value(snapshotArg);
if (state !== undefined && state !== null) {
entities.push(this.entity(snapshotArg, {
platform: 'sensor',
key: definition.key,
name: definition.name,
state,
available: snapshotArg.online,
attributes: cleanAttributes({
key: definition.key,
nativeUnitOfMeasurement: definition.unit,
deviceClass: definition.deviceClass,
stateClass: definition.stateClass,
entityCategory: definition.entityCategory,
}),
}));
}
}
if (productionPowerProductTypes.has(snapshotArg.device.productType) && numberValue(snapshotArg.measurement.power_w) !== undefined) {
const power = numberValue(snapshotArg.measurement.power_w);
entities.push(this.entity(snapshotArg, {
platform: 'sensor',
key: 'active_production_power_w',
name: 'Production power',
state: power === undefined ? undefined : power * -1,
available: snapshotArg.online,
attributes: { key: 'active_production_power_w', nativeUnitOfMeasurement: 'W', deviceClass: 'power', stateClass: 'measurement' },
}));
}
if (snapshotArg.capabilities.powerSwitch) {
entities.push(this.entity(snapshotArg, {
platform: 'switch',
key: 'power_on',
name: `${snapshotArg.device.productName} Power`,
state: onOffState(snapshotArg.state?.power_on),
available: snapshotArg.online && Boolean(snapshotArg.state) && snapshotArg.state?.switch_lock !== true,
attributes: { key: 'power_on', deviceClass: 'outlet' },
}));
entities.push(this.entity(snapshotArg, {
platform: 'switch',
key: 'switch_lock',
name: `${snapshotArg.device.productName} Switch lock`,
state: onOffState(snapshotArg.state?.switch_lock),
available: snapshotArg.online && Boolean(snapshotArg.state),
attributes: { key: 'switch_lock', entityCategory: 'config' },
}));
}
if (snapshotArg.capabilities.cloudConnection) {
entities.push(this.entity(snapshotArg, {
platform: 'switch',
key: 'cloud_connection',
name: `${snapshotArg.device.productName} Cloud connection`,
state: onOffState(snapshotArg.system?.cloud_enabled),
available: snapshotArg.online && Boolean(snapshotArg.system),
attributes: { key: 'cloud_connection', entityCategory: 'config' },
}));
}
if (snapshotArg.capabilities.statusLedBrightness) {
entities.push(this.entity(snapshotArg, {
platform: 'number',
key: 'status_light_brightness',
name: `${snapshotArg.device.productName} Status light brightness`,
state: roundOrUndefined(snapshotArg.system?.status_led_brightness_pct),
available: snapshotArg.online && Boolean(snapshotArg.system),
attributes: { key: 'status_light_brightness', nativeUnitOfMeasurement: '%', entityCategory: 'config' },
}));
}
if (snapshotArg.capabilities.batteries && snapshotArg.batteries) {
entities.push(this.entity(snapshotArg, {
platform: 'select',
key: 'battery_group_mode',
name: `${snapshotArg.device.productName} Battery group mode`,
state: stringValue(snapshotArg.batteries.mode) || 'unknown',
available: snapshotArg.online,
attributes: { key: 'battery_group_mode', options: this.supportedBatteryModes(snapshotArg.device), entityCategory: 'config' },
}));
}
if (snapshotArg.capabilities.identify) {
entities.push(this.entity(snapshotArg, {
platform: 'button',
key: 'identify',
name: `${snapshotArg.device.productName} Identify`,
state: 'available',
available: snapshotArg.online,
attributes: { key: 'identify', entityCategory: 'config', deviceClass: 'identify' },
}));
}
for (const externalDevice of snapshotArg.externalDevices) {
const unit = externalDevice.unit === 'm3' ? 'm3' : externalDevice.unit;
entities.push(this.entity(snapshotArg, {
platform: 'sensor',
key: `external_${externalDevice.id}`,
name: externalDeviceName(externalDevice.type),
state: externalDevice.value,
available: snapshotArg.online,
deviceId: this.externalDeviceId(snapshotArg, externalDevice),
attributes: cleanAttributes({ key: `external_${externalDevice.id}`, nativeUnitOfMeasurement: unit, externalType: externalDevice.type, timestamp: externalDevice.timestamp }),
}));
}
return entities;
}
public static commandForService(snapshotArg: IHomeWizardSnapshot, requestArg: IServiceCallRequest): IHomeWizardCommandRequest | undefined {
if (requestArg.domain === homeWizardDomain) {
if (requestArg.service === 'identify') {
return { action: 'identify', service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'reboot') {
return { action: 'reboot', service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'telegram') {
return { action: 'telegram', service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (['request_token', 'get_token', 'authorize'].includes(requestArg.service)) {
return { action: 'request_token', tokenName: stringValue(requestArg.data?.name) || stringValue(requestArg.data?.tokenName), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'set_state') {
return {
action: 'set_state',
powerOn: booleanValue(requestArg.data?.power_on ?? requestArg.data?.powerOn),
switchLock: booleanValue(requestArg.data?.switch_lock ?? requestArg.data?.switchLock),
brightness: numberValue(requestArg.data?.brightness),
service: `${requestArg.domain}.${requestArg.service}`,
target: requestArg.target,
data: requestArg.data,
};
}
if (requestArg.service === 'set_system') {
return {
action: 'set_system',
cloudEnabled: booleanValue(requestArg.data?.cloud_enabled ?? requestArg.data?.cloudEnabled),
statusLedBrightnessPct: numberValue(requestArg.data?.status_led_brightness_pct ?? requestArg.data?.statusLedBrightnessPct),
apiV1Enabled: booleanValue(requestArg.data?.api_v1_enabled ?? requestArg.data?.apiV1Enabled),
service: `${requestArg.domain}.${requestArg.service}`,
target: requestArg.target,
data: requestArg.data,
};
}
if (['set_battery_mode', 'select_battery_mode'].includes(requestArg.service)) {
return { action: 'set_battery_mode', batteryMode: stringValue(requestArg.data?.mode ?? requestArg.data?.option), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
}
const entity = this.targetEntity(snapshotArg, requestArg);
const key = stringValue(entity?.attributes?.key);
if (!key) {
return undefined;
}
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off'].includes(requestArg.service)) {
const active = requestArg.service === 'turn_on';
if (key === 'power_on') {
return { action: 'set_state', powerOn: active, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (key === 'switch_lock') {
return { action: 'set_state', switchLock: active, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (key === 'cloud_connection') {
return { action: 'set_system', cloudEnabled: active, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
}
if (requestArg.domain === 'number' && ['set_value', 'set_native_value'].includes(requestArg.service) && key === 'status_light_brightness') {
return { action: 'set_system', statusLedBrightnessPct: numberValue(requestArg.data?.value ?? requestArg.data?.native_value), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === 'select' && requestArg.service === 'select_option' && key === 'battery_group_mode') {
return { action: 'set_battery_mode', batteryMode: stringValue(requestArg.data?.option), service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === 'button' && requestArg.service === 'press' && key === 'identify') {
return { action: 'identify', service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
return undefined;
}
public static mainDeviceId(snapshotArg: IHomeWizardSnapshot): string {
return `${homeWizardDomain}.${slug(snapshotArg.device.serial || snapshotArg.device.id || snapshotArg.host || 'device')}`;
}
public static supportedBatteryModes(deviceArg: IHomeWizardDeviceSnapshot): string[] {
const modes = ['standby', 'to_full', 'zero'];
if (apiMajor(deviceArg.apiVersion) >= 2 && compareApiVersion(deviceArg.apiVersion, '2.2.0') >= 0) {
modes.push('zero_charge_only', 'zero_discharge_only');
}
return modes;
}
public static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
private static normalizeSnapshot(snapshotArg: IHomeWizardSnapshot, configArg: IHomeWizardConfig, sourceArg: THomeWizardSnapshotSource, errorArg?: string): IHomeWizardSnapshot {
const rawData = snapshotArg.rawData ? this.clone(snapshotArg.rawData) : undefined;
return {
...snapshotArg,
host: snapshotArg.host || parseEndpoint(configArg.url || configArg.host, configArg.port, configArg.protocol, configArg.apiVersion)?.host,
port: snapshotArg.port || parseEndpoint(configArg.url || configArg.host, configArg.port, configArg.protocol, configArg.apiVersion)?.port,
protocol: snapshotArg.protocol || parseEndpoint(configArg.url || configArg.host, configArg.port, configArg.protocol, configArg.apiVersion)?.protocol,
source: sourceArg,
error: errorArg || snapshotArg.error,
rawData,
capabilities: this.capabilities(configArg, snapshotArg.device, snapshotArg.apiVersion, snapshotArg.state, snapshotArg.system, snapshotArg.batteries, snapshotArg.host || configArg.host || configArg.url),
};
}
private static rawData(configArg: IHomeWizardConfig, rawDataArg?: Partial<IHomeWizardRawData>): IHomeWizardRawData {
const rawData: IHomeWizardRawData = this.clone({ ...(configArg.rawData || {}), ...(rawDataArg || {}) });
if (!rawData.device && (configArg.productType || configArg.productName || configArg.serial || configArg.name)) {
rawData.device = cleanAttributes({
product_type: configArg.productType,
product_name: configArg.productName || configArg.name,
serial: configArg.serial || configArg.uniqueId,
api_version: configArg.apiVersion === 'v2' ? '2.0.0' : 'v1',
});
}
return rawData;
}
private static device(configArg: IHomeWizardConfig, deviceArg?: Record<string, unknown>): IHomeWizardDeviceSnapshot {
const productType = stringValue(deviceArg?.product_type ?? deviceArg?.productType) || configArg.productType || 'unknown';
const serial = stringValue(deviceArg?.serial) || configArg.serial || configArg.uniqueId || endpointHost(configArg.url || configArg.host) || productType;
const productName = stringValue(deviceArg?.product_name ?? deviceArg?.productName) || configArg.productName || configArg.name || homeWizardProductNames[productType] || homeWizardDisplayName;
const apiVersionValue = stringValue(deviceArg?.api_version ?? deviceArg?.apiVersion) || (configArg.apiVersion === 'v2' ? '2.0.0' : 'v1');
const verificationProductId = homeWizardVerificationIds[productType];
return {
id: verificationProductId ? `appliance/${verificationProductId}/${serial}` : `${productType}/${serial}`,
productName,
productType,
serial,
firmwareVersion: stringValue(deviceArg?.firmware_version ?? deviceArg?.firmwareVersion),
apiVersion: apiVersionValue,
modelName: homeWizardProductNames[productType] || productName,
verificationId: verificationProductId ? `appliance/${verificationProductId}/${serial}` : undefined,
};
}
private static apiVersion(configArg: IHomeWizardConfig, deviceArg: IHomeWizardDeviceSnapshot, protocolArg?: THomeWizardProtocol): THomeWizardApiVersion {
if (configArg.apiVersion && configArg.apiVersion !== 'auto') {
return configArg.apiVersion;
}
if (deviceArg.apiVersion && deviceArg.apiVersion !== 'v1') {
return 'v2';
}
if (configArg.token || configArg.authorizationToken || protocolArg === 'https') {
return 'v2';
}
return 'v1';
}
private static measurement(measurementArg: Record<string, unknown>): IHomeWizardMeasurementSnapshot {
const measurement = { ...measurementArg } as IHomeWizardMeasurementSnapshot;
alias(measurement, 'protocol_version', 'smr_version');
alias(measurement, 'tariff', 'active_tariff');
alias(measurement, 'energy_import_kwh', 'total_power_import_kwh', 'total_power_import_t1_kwh');
alias(measurement, 'energy_import_t1_kwh', 'total_power_import_t1_kwh');
alias(measurement, 'energy_import_t2_kwh', 'total_power_import_t2_kwh');
alias(measurement, 'energy_import_t3_kwh', 'total_power_import_t3_kwh');
alias(measurement, 'energy_import_t4_kwh', 'total_power_import_t4_kwh');
alias(measurement, 'energy_export_kwh', 'total_power_export_kwh', 'total_power_export_t1_kwh');
alias(measurement, 'energy_export_t1_kwh', 'total_power_export_t1_kwh');
alias(measurement, 'energy_export_t2_kwh', 'total_power_export_t2_kwh');
alias(measurement, 'energy_export_t3_kwh', 'total_power_export_t3_kwh');
alias(measurement, 'energy_export_t4_kwh', 'total_power_export_t4_kwh');
alias(measurement, 'power_w', 'active_power_w');
alias(measurement, 'power_l1_w', 'active_power_l1_w');
alias(measurement, 'power_l2_w', 'active_power_l2_w');
alias(measurement, 'power_l3_w', 'active_power_l3_w');
alias(measurement, 'voltage_v', 'active_voltage_v');
alias(measurement, 'voltage_l1_v', 'active_voltage_l1_v');
alias(measurement, 'voltage_l2_v', 'active_voltage_l2_v');
alias(measurement, 'voltage_l3_v', 'active_voltage_l3_v');
alias(measurement, 'current_a', 'active_current_a');
alias(measurement, 'current_l1_a', 'active_current_l1_a');
alias(measurement, 'current_l2_a', 'active_current_l2_a');
alias(measurement, 'current_l3_a', 'active_current_l3_a');
alias(measurement, 'apparent_power_va', 'active_apparent_power_va');
alias(measurement, 'apparent_power_l1_va', 'active_apparent_power_l1_va');
alias(measurement, 'apparent_power_l2_va', 'active_apparent_power_l2_va');
alias(measurement, 'apparent_power_l3_va', 'active_apparent_power_l3_va');
alias(measurement, 'reactive_power_var', 'active_reactive_power_var');
alias(measurement, 'reactive_power_l1_var', 'active_reactive_power_l1_var');
alias(measurement, 'reactive_power_l2_var', 'active_reactive_power_l2_var');
alias(measurement, 'reactive_power_l3_var', 'active_reactive_power_l3_var');
alias(measurement, 'power_factor', 'active_power_factor');
alias(measurement, 'power_factor_l1', 'active_power_factor_l1');
alias(measurement, 'power_factor_l2', 'active_power_factor_l2');
alias(measurement, 'power_factor_l3', 'active_power_factor_l3');
alias(measurement, 'frequency_hz', 'active_frequency_hz');
alias(measurement, 'average_power_15m_w', 'active_power_average_w');
alias(measurement, 'monthly_power_peak_w', 'montly_power_peak_w');
alias(measurement, 'monthly_power_peak_timestamp', 'montly_power_peak_timestamp');
return measurement;
}
private static system(systemArg: Record<string, unknown> | undefined, measurementArg: IHomeWizardMeasurementSnapshot, stateArg?: IHomeWizardStateSnapshot): IHomeWizardSystemSnapshot | undefined {
const system = objectValue(systemArg) as IHomeWizardSystemSnapshot | undefined;
const result: IHomeWizardSystemSnapshot = { ...(system || {}) };
if (result.wifi_ssid === undefined && measurementArg.wifi_ssid !== undefined) {
result.wifi_ssid = measurementArg.wifi_ssid;
}
if (result.wifi_strength_pct === undefined && measurementArg.wifi_strength !== undefined) {
result.wifi_strength_pct = measurementArg.wifi_strength;
}
if (result.wifi_strength_pct === undefined && result.wifi_rssi_db !== undefined) {
result.wifi_strength_pct = rssiToPercent(numberValue(result.wifi_rssi_db));
}
if (result.status_led_brightness_pct === undefined && stateArg?.brightness !== undefined) {
result.status_led_brightness_pct = Math.round((numberValue(stateArg.brightness) || 0) / 2.55);
}
return Object.keys(result).length ? result : undefined;
}
private static externalDevices(measurementArg: IHomeWizardMeasurementSnapshot): IHomeWizardExternalDevice[] {
const external = measurementArg.external || measurementArg.external_devices || measurementArg.externalDevices;
const items = Array.isArray(external) ? external : external && typeof external === 'object' ? Object.values(external) : [];
return items
.map((itemArg) => objectValue(itemArg))
.filter((itemArg): itemArg is Record<string, unknown> => Boolean(itemArg))
.map((itemArg) => {
const uniqueId = decodeHexString(stringValue(itemArg.unique_id ?? itemArg.uniqueId) || 'external');
const type = stringValue(itemArg.type);
return {
id: slug(`${type || 'external'}_${uniqueId}`),
uniqueId,
type,
value: numberValue(itemArg.value) ?? stringValue(itemArg.value),
unit: stringValue(itemArg.unit),
timestamp: stringValue(itemArg.timestamp) || numberValue(itemArg.timestamp),
};
});
}
private static capabilities(configArg: IHomeWizardConfig, deviceArg: IHomeWizardDeviceSnapshot, apiVersionArg: THomeWizardApiVersion, stateArg?: IHomeWizardStateSnapshot, systemArg?: IHomeWizardSystemSnapshot, batteriesArg?: IHomeWizardBatteriesSnapshot, hostArg?: string): IHomeWizardSnapshot['capabilities'] {
const major = apiMajor(deviceArg.apiVersion);
const hasLiveTransport = Boolean(configArg.commandExecutor || configArg.client?.execute || (hostArg && (apiVersionArg === 'v1' || configArg.token || configArg.authorizationToken)));
return {
localControl: hasLiveTransport,
apiV1: apiVersionArg === 'v1' || deviceArg.apiVersion === 'v1',
apiV2: apiVersionArg === 'v2' || major >= 2,
powerSwitch: deviceArg.productType === homeWizardProductTypes.energySocket,
switchLock: deviceArg.productType === homeWizardProductTypes.energySocket,
cloudConnection: deviceArg.productType !== homeWizardProductTypes.battery && Boolean(systemArg || hasLiveTransport),
statusLedBrightness: deviceArg.productType === homeWizardProductTypes.battery || deviceArg.productType === homeWizardProductTypes.energySocket || (deviceArg.productType === homeWizardProductTypes.p1Meter && major >= 2),
identify: !identifyUnsupportedProductTypes.has(deviceArg.productType),
reboot: major >= 2 && deviceArg.productType !== homeWizardProductTypes.battery,
batteries: batteryManagedProductTypes.has(deviceArg.productType) && Boolean(batteriesArg || hasLiveTransport),
telegram: deviceArg.productType === homeWizardProductTypes.p1Meter,
};
}
private static entity(snapshotArg: IHomeWizardSnapshot, optionsArg: IHomeWizardEntityOptions): IIntegrationEntity {
const deviceId = optionsArg.deviceId || this.mainDeviceId(snapshotArg);
const uniqueId = `${snapshotArg.device.serial}_${optionsArg.key}`;
return {
id: `${optionsArg.platform}.${slug(`${snapshotArg.device.productName}_${optionsArg.key}`)}`,
uniqueId,
integrationDomain: homeWizardDomain,
deviceId,
platform: optionsArg.platform,
name: optionsArg.name,
state: optionsArg.state,
attributes: optionsArg.attributes,
available: optionsArg.available,
};
}
private static externalDeviceId(snapshotArg: IHomeWizardSnapshot, deviceArg: IHomeWizardExternalDevice): string {
return `${homeWizardDomain}.external.${slug(`${snapshotArg.device.serial}_${deviceArg.id}`)}`;
}
private static targetEntity(snapshotArg: IHomeWizardSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
const entities = this.toEntities(snapshotArg);
return entities.find((entityArg) => {
return entityArg.id === requestArg.target.entityId
|| entityArg.uniqueId === requestArg.target.entityId
|| entityArg.deviceId === requestArg.target.deviceId;
});
}
private static featureState(snapshotArg: IHomeWizardSnapshot, featureIdArg: string): unknown {
return this.toEntities(snapshotArg).find((entityArg) => entityArg.attributes?.key === featureIdArg || entityArg.uniqueId === featureIdArg)?.state;
}
}
function sensor(keyArg: string, nameArg: string, valueArg: (snapshotArg: IHomeWizardSnapshot) => unknown, optionsArg: Omit<IHomeWizardSensorDefinition, 'key' | 'name' | 'value'> = {}): IHomeWizardSensorDefinition {
return { key: keyArg, name: nameArg, value: valueArg, ...optionsArg };
}
function parseEndpoint(valueArg: string | undefined, portArg?: number, protocolArg?: THomeWizardProtocol, apiVersionArg?: THomeWizardApiVersion): { protocol: THomeWizardProtocol; host: string; port: number } | undefined {
if (!valueArg) {
return undefined;
}
try {
const defaultProtocol = protocolArg || (apiVersionArg === 'v2' ? 'https' : 'http');
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' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort) };
} catch {
const protocol = protocolArg || (apiVersionArg === 'v2' ? 'https' : 'http');
return { protocol, host: valueArg, port: portArg || (protocol === 'https' ? homeWizardDefaultHttpsPort : homeWizardDefaultHttpPort) };
}
}
function endpointHost(valueArg: string | undefined): string | undefined {
return parseEndpoint(valueArg)?.host;
}
function alias(targetArg: Record<string, unknown>, canonicalArg: string, ...sourceArgs: string[]): void {
if (targetArg[canonicalArg] !== undefined) {
return;
}
for (const source of sourceArgs) {
if (targetArg[source] !== undefined) {
targetArg[canonicalArg] = targetArg[source];
return;
}
}
}
function apiMajor(versionArg: string | undefined): number {
if (!versionArg || versionArg === 'v1') {
return 1;
}
const match = versionArg.match(/(\d+)/);
return match ? Number(match[1]) : 1;
}
function compareApiVersion(versionArg: string, otherArg: string): number {
const left = versionArg.replace(/^v/i, '').split('.').map((partArg) => Number(partArg) || 0);
const right = otherArg.replace(/^v/i, '').split('.').map((partArg) => Number(partArg) || 0);
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const diff = (left[index] || 0) - (right[index] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
function valueToString(valueArg: unknown): string | undefined {
if (valueArg === undefined || valueArg === null) {
return undefined;
}
return String(valueArg);
}
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 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.toLowerCase();
if (['true', '1', 'on', 'yes'].includes(normalized)) {
return true;
}
if (['false', '0', 'off', 'no'].includes(normalized)) {
return false;
}
}
return undefined;
}
function objectValue(valueArg: unknown): Record<string, unknown> | undefined {
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}
function cleanAttributes<TValue extends Record<string, unknown>>(valueArg: TValue): TValue {
return Object.fromEntries(Object.entries(valueArg).filter(([, value]) => value !== undefined && value !== null && value !== '')) as TValue;
}
function slug(valueArg: unknown): string {
return String(valueArg || 'homewizard').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'homewizard';
}
function percentage(valueArg: unknown): number | undefined {
const value = numberValue(valueArg);
return value === undefined ? undefined : value * 100;
}
function uptimeIso(valueArg: unknown): string | undefined {
const seconds = numberValue(valueArg);
return seconds === undefined ? undefined : new Date(Date.now() - seconds * 1000).toISOString();
}
function onOffState(valueArg: unknown): 'on' | 'off' | 'unknown' {
const value = booleanValue(valueArg);
return value === undefined ? 'unknown' : value ? 'on' : 'off';
}
function roundOrUndefined(valueArg: unknown): number | undefined {
const value = numberValue(valueArg);
return value === undefined ? undefined : Math.round(value);
}
function rssiToPercent(valueArg: number | undefined): number | undefined {
if (valueArg === undefined) {
return undefined;
}
if (valueArg <= -100 || valueArg === 0) {
return 0;
}
if (valueArg >= -50) {
return 100;
}
return 2 * (valueArg + 100);
}
function decodeHexString(valueArg: string): string {
if (!/^[0-9a-f]+$/i.test(valueArg) || valueArg.length % 2 !== 0) {
return valueArg;
}
try {
const decoded = Buffer.from(valueArg, 'hex').toString('utf8');
return decoded.replace(/[\u0000-\u001f\u007f]/g, '') || valueArg;
} catch {
return valueArg;
}
}
function externalDeviceName(typeArg: string | undefined): string {
switch (typeArg) {
case 'gas_meter':
return 'Gas meter';
case 'heat_meter':
return 'Heat meter';
case 'warm_water_meter':
return 'Warm water meter';
case 'water_meter':
return 'Water meter';
case 'inlet_heat_meter':
return 'Inlet heat meter';
default:
return 'External meter';
}
}
function deviceCapability(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability {
switch (platformArg) {
case 'switch':
return 'switch';
case 'light':
return 'light';
case 'cover':
return 'cover';
case 'fan':
return 'fan';
case 'climate':
return 'climate';
case 'media_player':
return 'media';
default:
return 'sensor';
}
}
function deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (valueArg === undefined || valueArg === null) {
return null;
}
if (['string', 'number', 'boolean'].includes(typeof valueArg)) {
return valueArg as string | number | boolean;
}
if (typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return String(valueArg);
}
+240 -3
View File
@@ -1,4 +1,241 @@
export interface IHomeAssistantHomewizardConfig {
// TODO: replace with the TypeScript-native config for homewizard.
[key: string]: unknown;
import type { IServiceCallRequest } from '../../core/types.js';
export const homeWizardDomain = 'homewizard';
export const homeWizardDisplayName = 'HomeWizard';
export const homeWizardDefaultHttpPort = 80;
export const homeWizardDefaultHttpsPort = 443;
export const homeWizardDefaultTimeoutMs = 5000;
export const homeWizardZeroconfTypes = ['_hwenergy._tcp.local.', '_homewizard._tcp.local.'] as const;
export const homeWizardProductTypes = {
p1Meter: 'HWE-P1',
energySocket: 'HWE-SKT',
waterMeter: 'HWE-WTR',
display: 'HWE-DSP',
energyMeter1Phase: 'HWE-KWH1',
energyMeter3Phase: 'HWE-KWH3',
eastronSdm230: 'SDM230-wifi',
eastronSdm630: 'SDM630-wifi',
battery: 'HWE-BAT',
} as const;
export const homeWizardProductNames: Record<string, string> = {
[homeWizardProductTypes.p1Meter]: 'Wi-Fi P1 Meter',
[homeWizardProductTypes.energySocket]: 'Wi-Fi Energy Socket',
[homeWizardProductTypes.waterMeter]: 'Wi-Fi Watermeter',
[homeWizardProductTypes.display]: 'Energy Display',
[homeWizardProductTypes.energyMeter1Phase]: 'Wi-Fi kWh Meter 1-phase',
[homeWizardProductTypes.energyMeter3Phase]: 'Wi-Fi kWh Meter 3-phase',
[homeWizardProductTypes.eastronSdm230]: 'Wi-Fi kWh Meter 1-phase',
[homeWizardProductTypes.eastronSdm630]: 'Wi-Fi kWh Meter 3-phase',
[homeWizardProductTypes.battery]: 'Plug-In Battery',
};
export const homeWizardVerificationIds: Record<string, string> = {
[homeWizardProductTypes.p1Meter]: 'p1dongle',
[homeWizardProductTypes.energySocket]: 'energysocket',
[homeWizardProductTypes.waterMeter]: 'watermeter',
[homeWizardProductTypes.display]: 'display',
[homeWizardProductTypes.energyMeter1Phase]: 'energymeter',
[homeWizardProductTypes.energyMeter3Phase]: 'energymeter',
[homeWizardProductTypes.eastronSdm230]: 'energymeter',
[homeWizardProductTypes.eastronSdm630]: 'energymeter',
[homeWizardProductTypes.battery]: 'battery',
};
export type THomeWizardApiVersion = 'auto' | 'v1' | 'v2';
export type THomeWizardSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime';
export type THomeWizardProtocol = 'http' | 'https';
export interface IHomeWizardZeroconfRecord {
name?: string;
type?: string;
serviceType?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export interface IHomeWizardDhcpRecord {
hostname?: string;
hostName?: string;
ip?: string;
address?: string;
macaddress?: string;
macAddress?: string;
registered_devices?: boolean;
registeredDevices?: boolean;
product_type?: string;
productType?: string;
product_name?: string;
productName?: string;
serial?: string;
metadata?: Record<string, unknown>;
}
export interface IHomeWizardManualEntry {
id?: string;
uniqueId?: string;
host?: string;
url?: string;
port?: number;
protocol?: THomeWizardProtocol;
apiVersion?: THomeWizardApiVersion;
token?: string;
authorizationToken?: string;
name?: string;
manufacturer?: string;
model?: string;
productType?: string;
productName?: string;
serial?: string;
snapshot?: IHomeWizardSnapshot;
rawData?: Partial<IHomeWizardRawData>;
client?: IHomeWizardClientLike;
commandExecutor?: IHomeWizardCommandExecutor;
metadata?: Record<string, unknown>;
}
export interface IHomeWizardDeviceSnapshot {
id: string;
productName: string;
productType: string;
serial: string;
firmwareVersion?: string;
apiVersion: string;
modelName?: string;
verificationId?: string;
}
export interface IHomeWizardExternalDevice {
id: string;
uniqueId: string;
type?: string;
value?: number | string;
unit?: string;
timestamp?: string | number;
}
export interface IHomeWizardMeasurementSnapshot extends Record<string, unknown> {
externalDevices?: IHomeWizardExternalDevice[];
}
export interface IHomeWizardSystemSnapshot extends Record<string, unknown> {}
export interface IHomeWizardStateSnapshot extends Record<string, unknown> {}
export interface IHomeWizardBatteriesSnapshot extends Record<string, unknown> {}
export interface IHomeWizardCapabilities {
localControl: boolean;
apiV1: boolean;
apiV2: boolean;
powerSwitch: boolean;
switchLock: boolean;
cloudConnection: boolean;
statusLedBrightness: boolean;
identify: boolean;
reboot: boolean;
batteries: boolean;
telegram: boolean;
}
export interface IHomeWizardRawData {
device?: Record<string, unknown>;
measurement?: Record<string, unknown>;
system?: Record<string, unknown>;
state?: Record<string, unknown>;
batteries?: Record<string, unknown>;
telegram?: string;
errors?: Record<string, string>;
fetchedAt?: string;
}
export interface IHomeWizardSnapshot {
device: IHomeWizardDeviceSnapshot;
host?: string;
port?: number;
protocol?: THomeWizardProtocol;
apiVersion: THomeWizardApiVersion;
measurement: IHomeWizardMeasurementSnapshot;
system?: IHomeWizardSystemSnapshot;
state?: IHomeWizardStateSnapshot;
batteries?: IHomeWizardBatteriesSnapshot;
externalDevices: IHomeWizardExternalDevice[];
capabilities: IHomeWizardCapabilities;
rawData?: IHomeWizardRawData;
online: boolean;
updatedAt: string;
source: THomeWizardSnapshotSource;
error?: string;
}
export interface IHomeWizardCommandExecutor {
execute(commandArg: IHomeWizardCommandRequest): Promise<unknown>;
}
export interface IHomeWizardClientLike {
getSnapshot?(): Promise<IHomeWizardSnapshot | Partial<IHomeWizardRawData>>;
getRawData?(): Promise<Partial<IHomeWizardRawData>>;
execute?(commandArg: IHomeWizardCommandRequest): Promise<unknown>;
destroy?(): Promise<void>;
}
export interface IHomeWizardConfig {
host?: string;
url?: string;
port?: number;
protocol?: THomeWizardProtocol;
apiVersion?: THomeWizardApiVersion;
token?: string;
authorizationToken?: string;
identifier?: string;
verifyTls?: boolean;
timeoutMs?: number;
name?: string;
productName?: string;
productType?: string;
serial?: string;
uniqueId?: string;
online?: boolean;
snapshot?: IHomeWizardSnapshot;
rawData?: Partial<IHomeWizardRawData>;
client?: IHomeWizardClientLike;
commandExecutor?: IHomeWizardCommandExecutor;
}
export interface IHomeAssistantHomewizardConfig extends IHomeWizardConfig {}
export type THomeWizardCommandAction =
| 'refresh'
| 'request_token'
| 'set_state'
| 'set_system'
| 'set_battery_mode'
| 'identify'
| 'reboot'
| 'telegram';
export interface IHomeWizardCommandRequest {
action: THomeWizardCommandAction;
powerOn?: boolean;
switchLock?: boolean;
brightness?: number;
cloudEnabled?: boolean;
statusLedBrightnessPct?: number;
apiV1Enabled?: boolean;
batteryMode?: string;
tokenName?: string;
service?: string;
target?: IServiceCallRequest['target'];
data?: Record<string, unknown>;
}
export interface IHomeWizardRefreshResult {
success: boolean;
snapshot?: IHomeWizardSnapshot;
error?: string;
data?: unknown;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './homewizard.classes.client.js';
export * from './homewizard.classes.configflow.js';
export * from './homewizard.classes.integration.js';
export * from './homewizard.discovery.js';
export * from './homewizard.mapper.js';
export * from './homewizard.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,376 @@
import { HunterDouglasPowerViewMapper } from './hunterdouglas_powerview.mapper.js';
import type {
IHunterDouglasPowerViewCommandRequest,
IHunterDouglasPowerViewConfig,
IHunterDouglasPowerViewHubRawData,
IHunterDouglasPowerViewRawData,
IHunterDouglasPowerViewRefreshResult,
IHunterDouglasPowerViewRoomRaw,
IHunterDouglasPowerViewSceneRaw,
IHunterDouglasPowerViewShadeRaw,
IHunterDouglasPowerViewSnapshot,
} from './hunterdouglas_powerview.types.js';
import { hunterDouglasPowerViewDefaultPort, hunterDouglasPowerViewDefaultTimeoutMs } from './hunterdouglas_powerview.types.js';
export class HunterDouglasPowerViewApiError extends Error {}
export class HunterDouglasPowerViewApiConnectionError extends HunterDouglasPowerViewApiError {}
export class HunterDouglasPowerViewUnsupportedCommandError extends HunterDouglasPowerViewApiError {}
interface IHunterDouglasPowerViewEndpoint {
protocol: 'http' | 'https';
host: string;
port?: number;
}
export class HunterDouglasPowerViewClient {
private currentSnapshot?: IHunterDouglasPowerViewSnapshot;
constructor(private readonly config: IHunterDouglasPowerViewConfig) {}
public async getSnapshot(forceRefreshArg = false): Promise<IHunterDouglasPowerViewSnapshot> {
if (!forceRefreshArg && this.currentSnapshot) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.config.snapshot) {
this.currentSnapshot = HunterDouglasPowerViewMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.config.client) {
try {
this.currentSnapshot = await this.snapshotFromClient();
} catch (errorArg) {
this.currentSnapshot = HunterDouglasPowerViewMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
if (!forceRefreshArg && this.hasManualData()) {
this.currentSnapshot = HunterDouglasPowerViewMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.hasEndpoint()) {
try {
this.currentSnapshot = await this.fetchSnapshot();
} catch (errorArg) {
this.currentSnapshot = HunterDouglasPowerViewMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
}
return this.cloneSnapshot(this.currentSnapshot);
}
this.currentSnapshot = HunterDouglasPowerViewMapper.offlineSnapshot(this.config, 'No PowerView local HTTP endpoint, injected client, snapshot, or manual data is configured.');
return this.cloneSnapshot(this.currentSnapshot);
}
public async refresh(): Promise<IHunterDouglasPowerViewRefreshResult> {
try {
this.currentSnapshot = undefined;
const liveCapable = Boolean(this.config.host || this.config.url || this.config.client);
const snapshot = await this.getSnapshot(liveCapable);
const success = liveCapable && snapshot.online && !snapshot.error;
return { success, snapshot, error: success ? undefined : snapshot.error || 'PowerView refresh requires a live host or injected client.', data: { source: snapshot.source } };
} catch (errorArg) {
const error = this.errorMessage(errorArg);
const snapshot = HunterDouglasPowerViewMapper.offlineSnapshot(this.config, error);
this.currentSnapshot = snapshot;
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
}
}
public async fetchSnapshot(): Promise<IHunterDouglasPowerViewSnapshot> {
if (!this.hasEndpoint()) {
throw new HunterDouglasPowerViewApiConnectionError('PowerView local HTTP snapshot requires config.host or config.url.');
}
const apiVersion = await this.detectApiVersion();
const [hub, home, rooms, scenes, shades] = await Promise.all([
this.fetchHubData(apiVersion),
apiVersion >= 3 ? this.requestJson<IHunterDouglasPowerViewHubRawData>('home').catch(() => ({})) : Promise.resolve({}),
this.fetchResources<IHunterDouglasPowerViewRoomRaw>(apiVersion, 'rooms', 'roomData'),
this.fetchResources<IHunterDouglasPowerViewSceneRaw>(apiVersion, 'scenes', 'sceneData'),
this.fetchResources<IHunterDouglasPowerViewShadeRaw>(apiVersion, 'shades', 'shadeData'),
]);
const rawData: Partial<IHunterDouglasPowerViewRawData> = {
apiVersion,
hub,
home,
rooms,
scenes,
shades,
fetchedAt: new Date().toISOString(),
};
const snapshot = HunterDouglasPowerViewMapper.toSnapshot({ config: { ...this.config, apiVersion }, rawData, online: true, source: 'http' });
if (snapshot.hub.role === 'Secondary') {
throw new HunterDouglasPowerViewApiConnectionError(`${snapshot.hub.name} is the Secondary Hub. Only the Primary can manage shades.`);
}
return snapshot;
}
public async execute(commandArg: IHunterDouglasPowerViewCommandRequest): 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 HunterDouglasPowerViewApiConnectionError('PowerView commands require config.host/config.url, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
}
const apiVersion = commandArg.apiVersion || this.config.apiVersion || (await this.getSnapshot()).hub.apiVersion;
switch (commandArg.action) {
case 'move_shade': {
if (!commandArg.shadeId || !commandArg.positions) {
throw new HunterDouglasPowerViewUnsupportedCommandError('PowerView move_shade requires shadeId and positions.');
}
const rawPositions = HunterDouglasPowerViewMapper.rawPositionsForApi(commandArg.positions, apiVersion);
const result = apiVersion >= 3
? await this.requestJson('home/shades/positions', { method: 'PUT', query: { ids: commandArg.shadeId }, body: { positions: rawPositions } })
: await this.requestJson(`api/shades/${encodeURIComponent(commandArg.shadeId)}`, { method: 'PUT', body: { shade: { id: numericId(commandArg.shadeId), positions: rawPositions } } });
this.currentSnapshot = undefined;
return result;
}
case 'stop_shade': {
if (!commandArg.shadeId) {
throw new HunterDouglasPowerViewUnsupportedCommandError('PowerView stop_shade requires shadeId.');
}
const result = apiVersion >= 3
? await this.requestJson('home/shades/stop', { method: 'PUT', query: { ids: commandArg.shadeId } })
: await this.requestJson(`api/shades/${encodeURIComponent(commandArg.shadeId)}`, { method: 'PUT', body: { shade: { motion: 'stop' } } });
this.currentSnapshot = undefined;
return result;
}
case 'activate_scene': {
if (!commandArg.sceneId) {
throw new HunterDouglasPowerViewUnsupportedCommandError('PowerView activate_scene requires sceneId.');
}
const result = apiVersion >= 3
? await this.requestJson(`home/scenes/${encodeURIComponent(commandArg.sceneId)}/activate`, { method: 'PUT' })
: await this.requestJson('api/scenes', { query: { sceneId: commandArg.sceneId } });
this.currentSnapshot = undefined;
return result;
}
case 'raw_request': {
if (!commandArg.path) {
throw new HunterDouglasPowerViewUnsupportedCommandError('PowerView raw_request requires path.');
}
const result = await this.requestJson(commandArg.path, { method: commandArg.method || 'GET', query: commandArg.query, body: commandArg.body });
this.currentSnapshot = undefined;
return result;
}
default:
throw new HunterDouglasPowerViewUnsupportedCommandError(`Unsupported PowerView command: ${commandArg.action}`);
}
}
public async destroy(): Promise<void> {
await this.config.client?.destroy?.();
}
private async detectApiVersion(): Promise<number> {
if (this.config.apiVersion) {
return this.config.apiVersion;
}
const legacy = await this.requestJson<IHunterDouglasPowerViewHubRawData>('api/fwversion').catch(() => undefined);
const legacyRevision = this.apiVersionFromHubData(legacy);
if (legacyRevision) {
return legacyRevision;
}
const generation3 = await this.requestJson<IHunterDouglasPowerViewHubRawData>('gateway/info').catch(() => undefined);
const generation3Revision = this.apiVersionFromHubData(generation3);
if (generation3Revision) {
return generation3Revision;
}
const gateway = await this.requestJson<IHunterDouglasPowerViewHubRawData>('gateway').catch(() => undefined);
const gatewayRevision = this.apiVersionFromHubData(gateway);
if (gatewayRevision) {
return gatewayRevision;
}
return 2;
}
private async fetchHubData(apiVersionArg: number): Promise<IHunterDouglasPowerViewHubRawData> {
if (apiVersionArg >= 3) {
return this.requestJson<IHunterDouglasPowerViewHubRawData>('gateway');
}
return this.requestJson<IHunterDouglasPowerViewHubRawData>('api/userdata');
}
private async fetchResources<TValue>(apiVersionArg: number, resourceArg: 'rooms' | 'scenes' | 'shades', legacyKeyArg: 'roomData' | 'sceneData' | 'shadeData'): Promise<TValue[]> {
const path = `${apiVersionArg >= 3 ? 'home' : 'api'}/${resourceArg}`;
const data = await this.requestJson<Record<string, unknown> | TValue[]>(path);
if (Array.isArray(data)) {
return data as TValue[];
}
const legacy = data[legacyKeyArg];
return Array.isArray(legacy) ? legacy as TValue[] : [];
}
private async snapshotFromClient(): Promise<IHunterDouglasPowerViewSnapshot> {
const client = this.config.client;
if (!client) {
throw new HunterDouglasPowerViewApiConnectionError('No PowerView client configured.');
}
if (client.getSnapshot) {
const result = await client.getSnapshot();
if (this.isSnapshot(result)) {
return HunterDouglasPowerViewMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
}
return HunterDouglasPowerViewMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
}
if (client.getRawData) {
return HunterDouglasPowerViewMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
}
throw new HunterDouglasPowerViewApiConnectionError('PowerView client must expose getSnapshot() or getRawData().');
}
private async requestJson<TValue = unknown>(pathArg: string, optionsArg: { method?: 'GET' | 'PUT' | 'POST' | 'DELETE'; body?: unknown; query?: Record<string, string | number | boolean | undefined> } = {}): Promise<TValue> {
const url = this.url(pathArg, optionsArg.query);
let response: Response;
try {
response = await globalThis.fetch(url, {
method: optionsArg.method || 'GET',
headers: optionsArg.body === undefined ? undefined : { 'content-type': 'application/json' },
body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
signal: AbortSignal.timeout(this.config.timeoutMs || hunterDouglasPowerViewDefaultTimeoutMs),
});
} catch (errorArg) {
throw new HunterDouglasPowerViewApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
}
if (response.status === 423) {
await response.arrayBuffer().catch(() => undefined);
throw new HunterDouglasPowerViewApiConnectionError('PowerView Hub is undergoing maintenance.');
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new HunterDouglasPowerViewApiConnectionError(`PowerView endpoint ${url} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
if (response.status === 204) {
await response.arrayBuffer().catch(() => undefined);
return {} as TValue;
}
const text = await response.text();
if (!text.trim()) {
return {} as TValue;
}
try {
return JSON.parse(text) as TValue;
} catch (errorArg) {
throw new HunterDouglasPowerViewApiConnectionError(`PowerView endpoint ${url} did not return JSON: ${this.errorMessage(errorArg)}`);
}
}
private url(pathArg: string, queryArg?: Record<string, string | number | boolean | undefined>): string {
if (/^https?:\/\//i.test(pathArg)) {
const absolute = new URL(pathArg);
this.applyQuery(absolute, queryArg);
return absolute.toString();
}
const url = new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${this.baseUrl()}/`);
this.applyQuery(url, queryArg);
return url.toString();
}
private applyQuery(urlArg: URL, queryArg?: Record<string, string | number | boolean | undefined>): void {
for (const [key, value] of Object.entries(queryArg || {})) {
if (value !== undefined) {
urlArg.searchParams.set(key, String(value));
}
}
}
private baseUrl(): string {
const endpoint = this.endpoint();
if (!endpoint) {
throw new HunterDouglasPowerViewApiConnectionError('PowerView request requires config.host or config.url.');
}
const port = endpoint.port && !((endpoint.protocol === 'http' && endpoint.port === 80) || (endpoint.protocol === 'https' && endpoint.port === 443)) ? `:${endpoint.port}` : '';
return `${endpoint.protocol}://${this.hostForUrl(endpoint.host)}${port}`;
}
private endpoint(): IHunterDouglasPowerViewEndpoint | undefined {
const configured = this.config.url || this.config.host;
if (!configured) {
return undefined;
}
try {
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) ? configured : `http://${configured}`);
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
host: url.hostname,
port: this.config.port || (url.port ? Number(url.port) : undefined),
};
} catch {
return { protocol: 'http', host: configured, port: this.config.port || hunterDouglasPowerViewDefaultPort };
}
}
private apiVersionFromHubData(valueArg: IHunterDouglasPowerViewHubRawData | undefined): number | undefined {
if (!valueArg) {
return undefined;
}
const revision = numberValue(valueAt(valueArg, ['userData', 'firmware', 'mainProcessor', 'revision']))
|| numberValue(valueAt(valueArg, ['config', 'firmware', 'mainProcessor', 'revision']))
|| numberValue(valueAt(valueArg, ['firmware', 'mainProcessor', 'revision']));
if (revision) {
return revision;
}
const fwVersion = stringValue(valueArg.fwVersion);
if (fwVersion) {
return Number(fwVersion.split('.')[0]) || undefined;
}
return undefined;
}
private hasEndpoint(): boolean {
return Boolean(this.config.host || this.config.url);
}
private hasManualData(): boolean {
return Boolean(this.config.rawData || this.config.snapshot);
}
private isSnapshot(valueArg: unknown): valueArg is IHunterDouglasPowerViewSnapshot {
return Boolean(valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'shades' in valueArg && 'scenes' in valueArg);
}
private hostForUrl(hostArg: string): string {
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
private cloneSnapshot(snapshotArg: IHunterDouglasPowerViewSnapshot): IHunterDouglasPowerViewSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IHunterDouglasPowerViewSnapshot;
}
}
const numericId = (valueArg: string): string | number => /^\d+$/.test(valueArg) ? Number(valueArg) : valueArg;
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 valueAt = (valueArg: Record<string, unknown> | undefined, pathArg: string[]): unknown => {
let current: unknown = valueArg;
for (const key of pathArg) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
@@ -0,0 +1,102 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IHunterDouglasPowerViewConfig, IHunterDouglasPowerViewRawData, IHunterDouglasPowerViewSnapshot } from './hunterdouglas_powerview.types.js';
import { hunterDouglasPowerViewDefaultPort, hunterDouglasPowerViewDefaultTimeoutMs } from './hunterdouglas_powerview.types.js';
export class HunterDouglasPowerViewConfigFlow implements IConfigFlow<IHunterDouglasPowerViewConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHunterDouglasPowerViewConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Configure Hunter Douglas PowerView',
description: 'Configure a local PowerView Hub endpoint. A host, injected client, snapshot, or raw hub data is required; static snapshots/manual data are read-only unless a command executor is supplied.',
fields: [
{ name: 'host', label: 'Host', type: 'text' },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'apiVersion', label: 'API version', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IHunterDouglasPowerViewConfig>> {
const metadata = candidateArg.metadata || {};
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringMetadata(metadata, 'url'));
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.hub.host;
const port = this.numberValue(valuesArg.port) || parsed?.port || candidateArg.port || snapshot?.hub.port || hunterDouglasPowerViewDefaultPort;
const apiVersion = this.numberValue(valuesArg.apiVersion) || this.numberMetadata(metadata, 'apiVersion') || snapshot?.hub.apiVersion;
const hasManualData = Boolean(snapshot || rawData || metadata.client);
if (!host && !hasManualData) {
return { kind: 'error', title: 'PowerView setup failed', error: 'PowerView host, injected client, snapshot, or manual raw data is required.' };
}
if (!this.validPort(port)) {
return { kind: 'error', title: 'PowerView setup failed', error: 'PowerView port must be between 1 and 65535.' };
}
if (apiVersion !== undefined && ![1, 2, 3].includes(apiVersion)) {
return { kind: 'error', title: 'PowerView setup failed', error: 'PowerView API version must be 1, 2, or 3.' };
}
const config: IHunterDouglasPowerViewConfig = {
host,
port,
apiVersion,
timeoutMs: hunterDouglasPowerViewDefaultTimeoutMs,
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.hub.name || this.stringMetadata(metadata, 'name'),
manufacturer: candidateArg.manufacturer || snapshot?.hub.manufacturer,
model: candidateArg.model || snapshot?.hub.model,
uniqueId: candidateArg.id || snapshot?.hub.id || (host ? `${host}:${port}` : undefined),
serialNumber: candidateArg.serialNumber || snapshot?.hub.serialNumber,
macAddress: candidateArg.macAddress || snapshot?.hub.macAddress,
snapshot,
rawData,
client: metadata.client as IHunterDouglasPowerViewConfig['client'],
commandExecutor: metadata.commandExecutor as IHunterDouglasPowerViewConfig['commandExecutor'],
};
return { kind: 'done', title: 'PowerView 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 stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
return this.stringValue(metadataArg[keyArg]);
}
private numberMetadata(metadataArg: Record<string, unknown>, keyArg: string): number | undefined {
return this.numberValue(metadataArg[keyArg]);
}
private validPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
const parseEndpoint = (valueArg: string | undefined): { 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 { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IHunterDouglasPowerViewSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'shades' in valueArg ? valueArg as IHunterDouglasPowerViewSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IHunterDouglasPowerViewRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHunterDouglasPowerViewRawData> : undefined;
@@ -1,28 +1,112 @@
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 { HunterDouglasPowerViewClient } from './hunterdouglas_powerview.classes.client.js';
import { HunterDouglasPowerViewConfigFlow } from './hunterdouglas_powerview.classes.configflow.js';
import { createHunterDouglasPowerViewDiscoveryDescriptor } from './hunterdouglas_powerview.discovery.js';
import { HunterDouglasPowerViewMapper } from './hunterdouglas_powerview.mapper.js';
import type { IHunterDouglasPowerViewConfig } from './hunterdouglas_powerview.types.js';
import { hunterDouglasPowerViewDefaultPort, hunterDouglasPowerViewDhcpMacPrefix, hunterDouglasPowerViewDisplayName, hunterDouglasPowerViewDomain, hunterDouglasPowerViewZeroconfTypes } from './hunterdouglas_powerview.types.js';
export class HomeAssistantHunterdouglasPowerviewIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "hunterdouglas_powerview",
displayName: "Hunter Douglas PowerView",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/hunterdouglas_powerview",
"upstreamDomain": "hunterdouglas_powerview",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"aiopvapi==3.3.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bdraco",
"@kingy444",
"@trullock"
]
},
});
export class HunterDouglasPowerViewIntegration extends BaseIntegration<IHunterDouglasPowerViewConfig> {
public readonly domain = hunterDouglasPowerViewDomain;
public readonly displayName = hunterDouglasPowerViewDisplayName;
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createHunterDouglasPowerViewDiscoveryDescriptor();
public readonly configFlow = new HunterDouglasPowerViewConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/hunterdouglas_powerview',
upstreamDomain: hunterDouglasPowerViewDomain,
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['aiopvapi==3.3.0'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@bdraco', '@kingy444', '@trullock'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/hunterdouglas_powerview',
zeroconf: hunterDouglasPowerViewZeroconfTypes,
dhcp: [
{ registered_devices: true },
{ hostname: 'hunter*', macaddress: `${hunterDouglasPowerViewDhcpMacPrefix}*` },
],
discovery: {
zeroconf: hunterDouglasPowerViewZeroconfTypes,
dhcp: 'registered PowerView devices, plus hunter* leases with 002674* MAC prefix',
manual: true,
},
runtime: {
type: 'control-runtime',
polling: 'local PowerView Hub HTTP snapshots from /api or /home resources',
services: ['snapshot', 'status', 'refresh', 'cover open/close/stop/position/tilt', 'scene activation', 'raw local request'],
controls: true,
},
localApi: {
implemented: [
'API version detection via /api/fwversion, /gateway/info, and /gateway',
'Hub snapshots from /api/userdata or /gateway plus /home gateway names',
'Rooms, scenes, and shades from /api/* legacy resources or /home/* Generation 3 resources',
'Legacy shade moves through PUT /api/shades/{id}',
'Generation 3 shade moves through PUT /home/shades/positions?ids={id}',
'Legacy scene activation through GET /api/scenes?sceneId={id}',
'Generation 3 scene activation through PUT /home/scenes/{id}/activate',
'snapshot/manual-data and injected client/executor operation',
],
explicitUnsupported: [
'cloud PowerView account APIs',
'faking command success for snapshot/manual-only configurations',
'full Home Assistant entity split behavior for complex dual-rail/overlapped shades; those are represented as one cover with raw position attributes',
],
},
};
public async setup(configArg: IHunterDouglasPowerViewConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new HunterDouglasPowerViewRuntime(new HunterDouglasPowerViewClient({ port: hunterDouglasPowerViewDefaultPort, ...configArg }));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantHunterdouglasPowerviewIntegration extends HunterDouglasPowerViewIntegration {}
class HunterDouglasPowerViewRuntime implements IIntegrationRuntime {
public domain = hunterDouglasPowerViewDomain;
constructor(private readonly client: HunterDouglasPowerViewClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return HunterDouglasPowerViewMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return HunterDouglasPowerViewMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === hunterDouglasPowerViewDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
return { success: true, data: await this.client.getSnapshot() };
}
if (requestArg.domain === hunterDouglasPowerViewDomain && 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 = HunterDouglasPowerViewMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported PowerView service or target: ${requestArg.domain}.${requestArg.service}` };
}
const data = await this.client.execute({ ...command, apiVersion: snapshot.hub.apiVersion });
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,228 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import { HunterDouglasPowerViewMapper } from './hunterdouglas_powerview.mapper.js';
import type { IHunterDouglasPowerViewDhcpRecord, IHunterDouglasPowerViewManualEntry, IHunterDouglasPowerViewRawData, IHunterDouglasPowerViewSnapshot, IHunterDouglasPowerViewZeroconfRecord } from './hunterdouglas_powerview.types.js';
import { hunterDouglasPowerViewDefaultPort, hunterDouglasPowerViewDhcpHostnamePrefix, hunterDouglasPowerViewDhcpMacPrefix, hunterDouglasPowerViewDisplayName, hunterDouglasPowerViewDomain, hunterDouglasPowerViewManufacturer, hunterDouglasPowerViewZeroconfTypes } from './hunterdouglas_powerview.types.js';
export class HunterDouglasPowerViewZeroconfMatcher implements IDiscoveryMatcher<IHunterDouglasPowerViewZeroconfRecord> {
public id = 'hunterdouglas_powerview-zeroconf-match';
public source = 'mdns' as const;
public description = 'Recognize PowerView _powerview._tcp.local and _PowerView-G3._tcp.local zeroconf advertisements from the Home Assistant manifest.';
public async matches(recordArg: IHunterDouglasPowerViewZeroconfRecord): Promise<IDiscoveryMatch> {
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const knownTypes = hunterDouglasPowerViewZeroconfTypes.map((typeArg) => normalizeMdnsType(typeArg));
const matched = knownTypes.includes(type) || type.includes('_powerview') || type.includes('_powerview-g3') || recordArg.metadata?.hunterDouglasPowerView === true;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a PowerView advertisement.' };
}
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
const name = cleanServiceName(recordArg.name) || stringValue(txt.name) || hunterDouglasPowerViewDisplayName;
const id = stringValue(txt.serialnumber) || stringValue(txt.serial) || stringValue(txt.id) || cleanServiceName(recordArg.name) || host;
return {
matched: true,
confidence: host && id ? 'certain' : host ? 'high' : 'medium',
reason: 'mDNS service matches PowerView zeroconf service type.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: hunterDouglasPowerViewDomain,
id,
host,
port: recordArg.port || hunterDouglasPowerViewDefaultPort,
name,
manufacturer: hunterDouglasPowerViewManufacturer,
model: type.includes('g3') ? 'Powerview Generation 3' : 'PowerView Hub',
serialNumber: stringValue(txt.serialnumber) || stringValue(txt.serial),
metadata: {
...recordArg.metadata,
hunterDouglasPowerView: true,
discoveryProtocol: 'zeroconf',
mdnsType: type,
mdnsName: recordArg.name,
apiVersion: type.includes('g3') ? 3 : undefined,
txt,
},
},
};
}
}
export class HunterDouglasPowerViewDhcpMatcher implements IDiscoveryMatcher<IHunterDouglasPowerViewDhcpRecord> {
public id = 'hunterdouglas_powerview-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize PowerView DHCP leases from registered devices or hunter* hostnames with 002674* MAC addresses from the Home Assistant manifest.';
public async matches(recordArg: IHunterDouglasPowerViewDhcpRecord): Promise<IDiscoveryMatch> {
const hostname = stringValue(recordArg.hostname) || stringValue(recordArg.hostName) || '';
const normalizedHostname = hostname.toLowerCase();
const normalizedMac = normalizeMacPrefix(recordArg.macaddress || recordArg.macAddress);
const registeredDevice = Boolean(recordArg.registeredDevices || recordArg.registered_devices || recordArg.metadata?.registeredDevices || recordArg.metadata?.registered_devices);
const explicitMatch = normalizedHostname.startsWith(hunterDouglasPowerViewDhcpHostnamePrefix) && Boolean(normalizedMac?.startsWith(hunterDouglasPowerViewDhcpMacPrefix));
const metadataMatch = recordArg.metadata?.hunterDouglasPowerView === true;
const text = [recordArg.manufacturer, recordArg.model, hostname, recordArg.metadata?.manufacturer, recordArg.metadata?.model].filter(Boolean).join(' ').toLowerCase();
const registeredPowerView = registeredDevice && (text.includes('powerview') || text.includes('hunter') || text.includes('douglas'));
const matched = explicitMatch || metadataMatch || registeredPowerView;
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP lease does not match PowerView registered-device, hostname, or MAC patterns.' };
}
const host = recordArg.ip || recordArg.address;
const id = normalizedMac || hostname || host;
return {
matched: true,
confidence: host && explicitMatch ? 'certain' : host ? 'high' : 'medium',
reason: explicitMatch ? 'DHCP lease matches hunter* hostname and 002674* MAC prefix.' : 'DHCP lease matches a registered PowerView device hint.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: hunterDouglasPowerViewDomain,
id,
host,
port: hunterDouglasPowerViewDefaultPort,
name: hostname || hunterDouglasPowerViewDisplayName,
manufacturer: hunterDouglasPowerViewManufacturer,
macAddress: HunterDouglasPowerViewMapper.normalizeMac(normalizedMac),
model: 'PowerView Hub',
metadata: {
...recordArg.metadata,
hunterDouglasPowerView: true,
discoveryProtocol: 'dhcp',
hostname,
registeredDevice,
macAddress: HunterDouglasPowerViewMapper.normalizeMac(normalizedMac),
},
},
};
}
}
export class HunterDouglasPowerViewManualMatcher implements IDiscoveryMatcher<IHunterDouglasPowerViewManualEntry> {
public id = 'hunterdouglas_powerview-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual PowerView host, API version, snapshot, injected client, and raw-data setup entries.';
public async matches(inputArg: IHunterDouglasPowerViewManualEntry): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const parsed = parseEndpoint(inputArg.url || inputArg.host);
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
const hasManualData = Boolean(snapshot || rawData || inputArg.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 || inputArg.apiVersion || metadata.hunterDouglasPowerView || hasManualData || text.includes('powerview') || text.includes('hunter douglas'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain PowerView setup hints.' };
}
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.hub.host;
const port = inputArg.port || parsed?.port || snapshot?.hub.port || hunterDouglasPowerViewDefaultPort;
const id = inputArg.id || inputArg.uniqueId || inputArg.serialNumber || snapshot?.hub.id || (host ? `${host}:${port}` : undefined);
return {
matched: true,
confidence: host || hasManualData ? 'high' : 'medium',
reason: 'Manual entry can start PowerView setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: hunterDouglasPowerViewDomain,
id,
host,
port,
name: inputArg.name || snapshot?.hub.name || hunterDouglasPowerViewDisplayName,
manufacturer: inputArg.manufacturer || snapshot?.hub.manufacturer || hunterDouglasPowerViewManufacturer,
model: inputArg.model || snapshot?.hub.model,
serialNumber: inputArg.serialNumber || snapshot?.hub.serialNumber,
macAddress: inputArg.macAddress || snapshot?.hub.macAddress,
metadata: {
...metadata,
hunterDouglasPowerView: true,
discoveryProtocol: 'manual',
apiVersion: inputArg.apiVersion || metadata.apiVersion || snapshot?.hub.apiVersion,
snapshot,
rawData,
client: inputArg.client || metadata.client,
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
},
},
};
}
}
export class HunterDouglasPowerViewCandidateValidator implements IDiscoveryValidator {
public id = 'hunterdouglas_powerview-candidate-validator';
public description = 'Validate PowerView candidates from zeroconf, DHCP, and manual setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const snapshot = snapshotValue(metadata.snapshot);
const rawData = rawDataValue(metadata.rawData);
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : '';
const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, mdnsType].filter(Boolean).join(' ').toLowerCase();
const matched = candidateArg.integrationDomain === hunterDouglasPowerViewDomain
|| metadata.hunterDouglasPowerView === true
|| text.includes('powerview')
|| text.includes('hunter douglas')
|| mdnsType.includes('_powerview')
|| discoveryProtocol === 'dhcp';
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
const normalizedDeviceId = candidateArg.serialNumber || candidateArg.id || snapshot?.hub.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || hunterDouglasPowerViewDefaultPort}` : undefined);
if (!matched || !hasUsableSource) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'PowerView candidate lacks a host, injected client, snapshot, or manual raw data.' : 'Candidate is not PowerView.',
normalizedDeviceId,
};
}
return {
matched: true,
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
reason: 'Candidate has PowerView metadata and a usable local endpoint, client, snapshot, or manual raw data.',
normalizedDeviceId,
candidate: {
...candidateArg,
integrationDomain: hunterDouglasPowerViewDomain,
port: candidateArg.port || hunterDouglasPowerViewDefaultPort,
manufacturer: candidateArg.manufacturer || hunterDouglasPowerViewManufacturer,
},
};
}
}
export const createHunterDouglasPowerViewDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: hunterDouglasPowerViewDomain, displayName: hunterDouglasPowerViewDisplayName })
.addMatcher(new HunterDouglasPowerViewZeroconfMatcher())
.addMatcher(new HunterDouglasPowerViewDhcpMatcher())
.addMatcher(new HunterDouglasPowerViewManualMatcher())
.addValidator(new HunterDouglasPowerViewCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(recordArg)) {
normalized[key.toLowerCase()] = value;
}
return normalized;
};
const cleanServiceName = (valueArg: string | undefined): string | undefined => {
if (!valueArg) {
return undefined;
}
return valueArg.replace(/\._.*$/u, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const normalizeMacPrefix = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg.replace(/[^0-9a-f]/gi, '').toUpperCase() : undefined;
const parseEndpoint = (valueArg: string | undefined): { 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 { host: url.hostname, port: url.port ? Number(url.port) : undefined };
} catch {
return undefined;
}
};
const snapshotValue = (valueArg: unknown): IHunterDouglasPowerViewSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'shades' in valueArg ? valueArg as IHunterDouglasPowerViewSnapshot : undefined;
const rawDataValue = (valueArg: unknown): Partial<IHunterDouglasPowerViewRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHunterDouglasPowerViewRawData> : undefined;
@@ -0,0 +1,860 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
import type {
IHunterDouglasPowerViewCommandRequest,
IHunterDouglasPowerViewConfig,
IHunterDouglasPowerViewHubRawData,
IHunterDouglasPowerViewRawData,
IHunterDouglasPowerViewRoomRaw,
IHunterDouglasPowerViewSceneRaw,
IHunterDouglasPowerViewShadeCapabilities,
IHunterDouglasPowerViewShadePosition,
IHunterDouglasPowerViewShadeRaw,
IHunterDouglasPowerViewShadeSnapshot,
IHunterDouglasPowerViewSnapshot,
THunterDouglasPowerViewSnapshotSource,
} from './hunterdouglas_powerview.types.js';
import { hunterDouglasPowerViewDefaultPort, hunterDouglasPowerViewDisplayName, hunterDouglasPowerViewDomain, hunterDouglasPowerViewManufacturer } from './hunterdouglas_powerview.types.js';
interface IHunterDouglasPowerViewSnapshotOptions {
config: IHunterDouglasPowerViewConfig;
rawData?: Partial<IHunterDouglasPowerViewRawData>;
online?: boolean;
source?: THunterDouglasPowerViewSnapshotSource;
error?: string;
}
const maxPositionV2 = 65535;
const capabilityNames: Record<number, string> = {
0: 'Bottom Up',
1: 'Bottom Up Tilt On Closed',
2: 'Bottom Up Tilt Anywhere',
3: 'Vertical',
4: 'Vertical Tilt Anywhere',
5: 'Tilt Only',
6: 'Top Down',
7: 'Top Down Bottom Up',
8: 'Dual Overlapped',
9: 'Dual Overlapped Tilt 90',
10: 'Dual Overlapped Tilt 180',
11: 'Illuminated Shades',
};
const shadeTypeNames: Record<number, string> = {
1: 'Designer Roller',
4: 'Roman',
5: 'Bottom Up',
6: 'Duette',
7: 'Top Down',
8: 'Duette Top Down Bottom Up',
9: 'Duette DuoLite Top Down Bottom Up',
10: 'Duette and Applause SkyLift',
18: 'Pirouette',
19: 'Provenance Woven Wood',
23: 'Silhouette',
26: 'Skyline Panel Left Stack',
27: 'Skyline Panel Right Stack',
28: 'Skyline Panel Split Stack',
31: 'Vignette',
32: 'Vignette',
33: 'Duette Architella Top Down Bottom Up',
38: 'Silhouette Duolite',
40: 'Everwood Alternative Wood Blinds',
42: 'M25T Roller Blind',
43: 'Facette',
44: 'Twist',
47: 'Pleated Top Down Bottom Up',
49: 'AC Roller',
51: 'Venetian Tilt Anywhere',
52: 'Banded Shades',
53: 'Sonnette',
54: 'Vertical Slats Left Stack',
55: 'Vertical Slats Right Stack',
56: 'Vertical Slats Split Stack',
57: 'Carole Roman Shades',
62: 'Venetian Tilt Anywhere',
65: 'Vignette Duolite',
66: 'Palm Beach Shutters',
69: 'Curtain Left Stack',
70: 'Curtain Right Stack',
71: 'Curtain Split Stack',
72: 'Silhouette',
79: 'Duolite Lift',
84: 'Vignette',
95: 'Aura Illuminated Roller',
};
export class HunterDouglasPowerViewMapper {
public static toSnapshot(optionsArg: IHunterDouglasPowerViewSnapshotOptions): IHunterDouglasPowerViewSnapshot {
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 apiVersion = optionsArg.config.apiVersion || rawData.apiVersion || this.apiVersionFromRaw(rawData) || 2;
const hubRaw = rawData.hub || {};
const homeRaw = rawData.home || {};
const hubNameFromHome = this.hubNameFromHome(hubRaw, homeRaw);
const hubId = stringValue(optionsArg.config.uniqueId)
|| stringValue(optionsArg.config.serialNumber)
|| this.hubSerial(hubRaw)
|| this.hubMac(hubRaw)
|| endpointHost(optionsArg.config.host || optionsArg.config.url)
|| 'powerview';
const hubName = optionsArg.config.name
|| hubNameFromHome
|| this.hubName(hubRaw)
|| (hubId !== 'powerview' ? `PowerView ${hubId}` : hunterDouglasPowerViewDisplayName);
const rooms = this.rooms(rawData.rooms || []);
const roomNames = new Map(rooms.map((roomArg) => [roomArg.id, roomArg.name]));
const shades = this.shades(rawData.shades || [], apiVersion, roomNames);
const scenes = this.scenes(rawData.scenes || [], roomNames);
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData.hub || rawData.shades?.length || rawData.scenes?.length || optionsArg.source === 'http' || optionsArg.source === 'client');
const source = optionsArg.source || (rawData.hub ? 'http' : Object.keys(rawData).length ? 'manual' : 'runtime');
const snapshot: IHunterDouglasPowerViewSnapshot = {
hub: {
id: hubId,
name: hubName,
host: endpointHost(optionsArg.config.host || optionsArg.config.url),
port: endpointPort(optionsArg.config.host || optionsArg.config.url, optionsArg.config.port),
manufacturer: optionsArg.config.manufacturer || hunterDouglasPowerViewManufacturer,
model: optionsArg.config.model || this.hubModel(hubRaw, apiVersion),
firmware: this.firmwareString(this.hubFirmware(hubRaw)),
hardware: stringValue(this.hubFirmware(hubRaw)?.name),
apiVersion,
role: this.hubRole(hubRaw, apiVersion),
macAddress: optionsArg.config.macAddress || this.normalizeMac(this.hubMac(hubRaw)),
serialNumber: stringValue(optionsArg.config.serialNumber) || this.hubSerial(hubRaw),
rawData: Object.keys(hubRaw).length ? hubRaw : undefined,
},
rooms,
scenes,
shades,
capabilities: {
localControl: this.hasLocalControl(optionsArg.config),
covers: shades.length > 0,
scenes: scenes.length > 0,
rooms: rooms.length > 0,
tilt: shades.some((shadeArg) => shadeArg.capabilities.tilt),
stop: true,
},
rawData: Object.keys(rawData).length ? rawData as IHunterDouglasPowerViewRawData : undefined,
online,
updatedAt: rawData.fetchedAt || new Date().toISOString(),
source,
error: optionsArg.error,
};
return this.normalizeSnapshot(snapshot, optionsArg.config, source, optionsArg.error);
}
public static toDevices(snapshotArg: IHunterDouglasPowerViewSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const writable = snapshotArg.capabilities.localControl;
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: this.hubDeviceId(snapshotArg),
integrationDomain: hunterDouglasPowerViewDomain,
name: snapshotArg.hub.name,
protocol: snapshotArg.hub.host ? 'http' : 'unknown',
manufacturer: snapshotArg.hub.manufacturer,
model: snapshotArg.hub.model || `PowerView Generation ${snapshotArg.hub.apiVersion}`,
online: snapshotArg.online,
features: [
{ id: 'online', capability: 'sensor', name: 'Online', readable: true, writable: false },
{ id: 'shade_count', capability: 'sensor', name: 'Shade count', readable: true, writable: false },
{ id: 'scene_count', capability: 'sensor', name: 'Scene count', readable: true, writable: false },
],
state: [
{ featureId: 'online', value: snapshotArg.online, updatedAt },
{ featureId: 'shade_count', value: snapshotArg.shades.length, updatedAt },
{ featureId: 'scene_count', value: snapshotArg.scenes.length, updatedAt },
],
metadata: this.cleanAttributes({
apiVersion: snapshotArg.hub.apiVersion,
role: snapshotArg.hub.role,
macAddress: snapshotArg.hub.macAddress,
serialNumber: snapshotArg.hub.serialNumber,
firmware: snapshotArg.hub.firmware,
source: snapshotArg.source,
error: snapshotArg.error,
}),
}];
for (const shade of snapshotArg.shades) {
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'position', capability: 'cover', name: 'Position', readable: true, writable, unit: '%' },
];
if (shade.capabilities.tilt) {
features.push({ id: 'tilt', capability: 'cover', name: 'Tilt', readable: true, writable, unit: '%' });
}
if (shade.batteryPercent !== undefined) {
features.push({ id: 'battery', capability: 'sensor', name: 'Battery', readable: true, writable: false, unit: '%' });
}
devices.push({
id: this.shadeDeviceId(snapshotArg, shade),
integrationDomain: hunterDouglasPowerViewDomain,
name: shade.name,
room: shade.roomName,
protocol: snapshotArg.hub.host ? 'http' : 'unknown',
manufacturer: snapshotArg.hub.manufacturer,
model: shade.typeName || capabilityNames[Number(shade.capabilityType)] || 'PowerView Shade',
online: snapshotArg.online,
features,
state: this.stateForShade(shade, features, updatedAt),
metadata: this.cleanAttributes({
shadeId: shade.shadeId,
roomId: shade.roomId,
capabilityType: shade.capabilityType,
type: shade.type,
typeName: shade.typeName,
firmware: shade.firmware,
powerSource: shade.powerSource,
signalStrength: shade.signalStrength,
viaDevice: this.hubDeviceId(snapshotArg),
}),
});
}
return devices;
}
public static toEntities(snapshotArg: IHunterDouglasPowerViewSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
for (const shade of snapshotArg.shades) {
entities.push({
id: `cover.${slug(shade.name)}`,
uniqueId: `${snapshotArg.hub.id}_${shade.shadeId}_cover`,
integrationDomain: hunterDouglasPowerViewDomain,
deviceId: this.shadeDeviceId(snapshotArg, shade),
platform: 'cover',
name: shade.name,
state: this.coverState(shade),
available: snapshotArg.online,
attributes: this.cleanAttributes({
shadeId: shade.shadeId,
roomId: shade.roomId,
roomName: shade.roomName,
currentPosition: this.homeAssistantPrimaryPosition(shade),
currentTiltPosition: shade.positions.tilt,
primaryPosition: shade.positions.primary,
secondaryPosition: shade.positions.secondary,
batteryPercent: shade.batteryPercent,
powerSource: shade.powerSource,
assumedState: shade.powerSource ? shade.powerSource !== 'Hardwired' : undefined,
capabilityType: shade.capabilityType,
supportedFeatures: this.supportedCoverFeatures(shade),
}),
});
}
for (const scene of snapshotArg.scenes) {
entities.push({
id: `button.${slug(scene.name)}_scene`,
uniqueId: `${snapshotArg.hub.id}_${scene.sceneId}_scene`,
integrationDomain: hunterDouglasPowerViewDomain,
deviceId: this.hubDeviceId(snapshotArg),
platform: 'button',
name: `${scene.name} Scene`,
state: 'available',
available: snapshotArg.online,
attributes: this.cleanAttributes({
sceneId: scene.sceneId,
sceneEntityId: `scene.${slug(scene.name)}`,
roomId: scene.roomId,
roomName: scene.roomName,
}),
});
}
return entities;
}
public static commandForService(snapshotArg: IHunterDouglasPowerViewSnapshot, requestArg: IServiceCallRequest): IHunterDouglasPowerViewCommandRequest | undefined {
if (requestArg.domain === hunterDouglasPowerViewDomain && requestArg.service === 'refresh') {
return { action: 'refresh', service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === hunterDouglasPowerViewDomain && requestArg.service === 'raw_request') {
const path = stringValue(requestArg.data?.path);
if (!path) {
return undefined;
}
return {
action: 'raw_request',
path,
method: this.methodValue(requestArg.data?.method) || 'GET',
body: requestArg.data?.body,
query: recordValue(requestArg.data?.query) as Record<string, string | number | boolean | undefined> | undefined,
service: `${requestArg.domain}.${requestArg.service}`,
target: requestArg.target,
data: requestArg.data,
};
}
const scene = this.sceneForTarget(snapshotArg, requestArg);
if (scene && ((requestArg.domain === 'scene' && ['turn_on', 'activate'].includes(requestArg.service)) || (requestArg.domain === 'button' && requestArg.service === 'press') || (requestArg.domain === hunterDouglasPowerViewDomain && ['activate_scene', 'scene_activate'].includes(requestArg.service)))) {
return { action: 'activate_scene', sceneId: scene.sceneId, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
}
if (requestArg.domain === hunterDouglasPowerViewDomain && ['activate_scene', 'scene_activate'].includes(requestArg.service)) {
const sceneId = stringValue(requestArg.data?.sceneId) || stringValue(requestArg.data?.scene_id);
return sceneId ? { action: 'activate_scene', sceneId, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data } : undefined;
}
const shade = this.shadeForTarget(snapshotArg, requestArg);
if (!shade) {
return undefined;
}
const service = `${requestArg.domain}.${requestArg.service}`;
if (requestArg.domain === 'cover' || requestArg.domain === hunterDouglasPowerViewDomain) {
if (requestArg.service === 'open_cover') {
return { action: 'move_shade', shadeId: shade.shadeId, positions: this.openPosition(shade), service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'close_cover') {
return { action: 'move_shade', shadeId: shade.shadeId, positions: this.closePosition(shade), service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'stop_cover' || requestArg.service === 'stop_cover_tilt') {
return { action: 'stop_shade', shadeId: shade.shadeId, service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'set_cover_position') {
const position = numberValue(requestArg.data?.position);
return position === undefined ? undefined : { action: 'move_shade', shadeId: shade.shadeId, positions: this.targetPrimaryPosition(shade, position), service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'open_cover_tilt') {
return { action: 'move_shade', shadeId: shade.shadeId, positions: { tilt: 100 }, service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'close_cover_tilt') {
return { action: 'move_shade', shadeId: shade.shadeId, positions: { tilt: 0 }, service, target: requestArg.target, data: requestArg.data };
}
if (requestArg.service === 'set_cover_tilt_position') {
const tilt = numberValue(requestArg.data?.tilt_position ?? requestArg.data?.tiltPosition);
return tilt === undefined ? undefined : { action: 'move_shade', shadeId: shade.shadeId, positions: { tilt }, service, target: requestArg.target, data: requestArg.data };
}
}
return undefined;
}
public static offlineSnapshot(configArg: IHunterDouglasPowerViewConfig, errorArg: string): IHunterDouglasPowerViewSnapshot {
return this.toSnapshot({ config: configArg, online: false, source: 'runtime', error: errorArg });
}
public static normalizeMac(valueArg: unknown): string | undefined {
if (typeof valueArg !== 'string') {
return undefined;
}
const hex = valueArg.replace(/[^0-9a-f]/gi, '').toUpperCase();
if (hex.length < 6) {
return undefined;
}
return hex.length === 12 ? hex.match(/.{1,2}/g)?.join(':') : hex;
}
public static hubDeviceId(snapshotArg: IHunterDouglasPowerViewSnapshot): string {
return `hunterdouglas_powerview.hub.${slug(snapshotArg.hub.id)}`;
}
public static shadeDeviceId(snapshotArg: IHunterDouglasPowerViewSnapshot, shadeArg: IHunterDouglasPowerViewShadeSnapshot): string {
return `hunterdouglas_powerview.shade.${slug(`${snapshotArg.hub.id}_${shadeArg.shadeId}`)}`;
}
public static rawPositionsForApi(positionsArg: IHunterDouglasPowerViewShadePosition, apiVersionArg: number): Record<string, unknown> {
if (apiVersionArg >= 3) {
const raw: Record<string, unknown> = {};
for (const key of ['primary', 'secondary', 'tilt', 'velocity'] as const) {
const value = positionsArg[key];
if (value !== undefined) {
raw[key] = key === 'velocity' ? value : Math.round(clamp(value, 0, 100)) / 100;
}
}
return raw;
}
const raw: Record<string, unknown> = {};
if (positionsArg.primary !== undefined) {
raw.posKind1 = 1;
raw.position1 = this.percentToV2(positionsArg.primary);
}
if (positionsArg.secondary !== undefined) {
const kindKey = positionsArg.primary === undefined ? 'posKind1' : 'posKind2';
const positionKey = positionsArg.primary === undefined ? 'position1' : 'position2';
raw[kindKey] = 2;
raw[positionKey] = this.percentToV2(positionsArg.secondary);
}
if (positionsArg.tilt !== undefined && !(positionsArg.primary !== undefined && positionsArg.secondary !== undefined)) {
const hasPrimaryOrSecondary = positionsArg.primary !== undefined || positionsArg.secondary !== undefined;
raw[hasPrimaryOrSecondary ? 'posKind2' : 'posKind1'] = 3;
raw[hasPrimaryOrSecondary ? 'position2' : 'position1'] = this.percentToV2(positionsArg.tilt);
}
return raw;
}
private static normalizeSnapshot(snapshotArg: IHunterDouglasPowerViewSnapshot, configArg: IHunterDouglasPowerViewConfig, sourceArg: THunterDouglasPowerViewSnapshotSource, errorArg?: string): IHunterDouglasPowerViewSnapshot {
return {
...snapshotArg,
hub: {
...snapshotArg.hub,
host: snapshotArg.hub.host || endpointHost(configArg.host || configArg.url),
port: snapshotArg.hub.port || endpointPort(configArg.host || configArg.url, configArg.port),
url: snapshotArg.hub.url || endpointUrl(configArg.host || configArg.url, configArg.port),
},
capabilities: {
...snapshotArg.capabilities,
localControl: this.hasLocalControl(configArg),
},
source: sourceArg,
error: errorArg ?? snapshotArg.error,
};
}
private static rawData(configArg: IHunterDouglasPowerViewConfig, rawDataArg: Partial<IHunterDouglasPowerViewRawData> | undefined): Partial<IHunterDouglasPowerViewRawData> {
const rawData = rawDataArg || configArg.rawData || {};
return {
apiVersion: rawData.apiVersion,
hub: rawData.hub,
home: rawData.home,
rooms: rawData.rooms,
scenes: rawData.scenes,
shades: rawData.shades,
fetchedAt: rawData.fetchedAt,
};
}
private static rooms(roomsArg: IHunterDouglasPowerViewRoomRaw[]): IHunterDouglasPowerViewSnapshot['rooms'] {
return roomsArg.map((roomArg) => unwrapRoom(roomArg)).filter((roomArg) => roomArg.id !== undefined).map((roomArg) => ({
id: String(roomArg.id),
name: decodePowerViewName(stringValue(roomArg.ptName) || stringValue(roomArg.name) || `Room ${roomArg.id}`),
rawData: roomArg,
}));
}
private static scenes(scenesArg: IHunterDouglasPowerViewSceneRaw[], roomNamesArg: Map<string, string>): IHunterDouglasPowerViewSnapshot['scenes'] {
return scenesArg.map((sceneArg) => unwrapScene(sceneArg)).filter((sceneArg) => sceneArg.id !== undefined).map((sceneArg) => {
const roomId = this.roomId(sceneArg);
return {
sceneId: String(sceneArg.id),
name: decodePowerViewName(stringValue(sceneArg.ptName) || stringValue(sceneArg.name) || `Scene ${sceneArg.id}`),
roomId,
roomName: roomId ? roomNamesArg.get(roomId) : undefined,
rawData: sceneArg,
};
});
}
private static shades(shadesArg: IHunterDouglasPowerViewShadeRaw[], apiVersionArg: number, roomNamesArg: Map<string, string>): IHunterDouglasPowerViewShadeSnapshot[] {
return shadesArg.map((shadeArg) => unwrapShade(shadeArg)).filter((shadeArg) => shadeArg.id !== undefined).map((shadeArg) => {
const roomId = this.roomId(shadeArg);
const capabilityType = numberValue(shadeArg.capabilities);
const type = numberValue(shadeArg.type);
return {
shadeId: String(shadeArg.id),
name: decodePowerViewName(stringValue(shadeArg.ptName) || stringValue(shadeArg.name) || `Shade ${shadeArg.id}`),
roomId,
roomName: roomId ? roomNamesArg.get(roomId) : undefined,
type: shadeArg.type,
typeName: type !== undefined ? shadeTypeNames[type] : undefined,
capabilityType: shadeArg.capabilities,
capabilities: this.capabilities(capabilityType),
positions: this.positions(shadeArg.positions, apiVersionArg),
firmware: this.firmwareString(shadeArg.firmware),
batteryPercent: this.batteryPercent(shadeArg, apiVersionArg),
powerSource: this.powerSource(shadeArg, apiVersionArg),
signalStrength: this.signalStrength(shadeArg, apiVersionArg),
rawData: shadeArg,
};
});
}
private static positions(rawArg: Record<string, unknown> | undefined, apiVersionArg: number): IHunterDouglasPowerViewShadePosition {
if (!rawArg) {
return {};
}
if (apiVersionArg >= 3 || 'primary' in rawArg || 'secondary' in rawArg || 'tilt' in rawArg) {
return this.cleanPosition({
primary: this.apiV3ToPercent(numberValue(rawArg.primary)),
secondary: this.apiV3ToPercent(numberValue(rawArg.secondary)),
tilt: this.apiV3ToPercent(numberValue(rawArg.tilt)),
velocity: numberValue(rawArg.velocity),
});
}
const position: IHunterDouglasPowerViewShadePosition = {};
const pairs = [
{ kind: numberValue(rawArg.posKind1), value: numberValue(rawArg.position1) },
{ kind: numberValue(rawArg.posKind2), value: numberValue(rawArg.position2) },
];
for (const pair of pairs) {
if (pair.kind === undefined || pair.value === undefined) {
continue;
}
if (pair.kind === 1) {
position.primary = this.v2ToPercent(pair.value);
} else if (pair.kind === 2) {
position.secondary = this.v2ToPercent(pair.value);
} else if (pair.kind === 3) {
position.tilt = this.v2ToPercent(pair.value);
}
}
return this.cleanPosition(position);
}
private static capabilities(capabilityTypeArg: number | undefined): IHunterDouglasPowerViewShadeCapabilities {
const type = capabilityTypeArg ?? 0;
return {
primary: type !== 5,
secondary: [7, 8, 9, 10, 11].includes(type),
tilt: [1, 2, 4, 5, 9, 10].includes(type),
stop: true,
topDown: type === 6,
tiltOnly: type === 5,
dualRail: type === 7,
dualOverlapped: [8, 9, 10, 11].includes(type),
light: type === 11,
};
}
private static supportedCoverFeatures(shadeArg: IHunterDouglasPowerViewShadeSnapshot): string[] {
const features = ['open', 'close', 'set_position'];
if (shadeArg.capabilities.stop) {
features.push('stop');
}
if (shadeArg.capabilities.tilt) {
features.push('open_tilt', 'close_tilt', 'set_tilt_position', 'stop_tilt');
}
return features;
}
private static homeAssistantPrimaryPosition(shadeArg: IHunterDouglasPowerViewShadeSnapshot): number | undefined {
if (shadeArg.capabilities.tiltOnly) {
return undefined;
}
const primary = shadeArg.positions.primary;
if (primary === undefined) {
return undefined;
}
return shadeArg.capabilities.topDown ? 100 - primary : primary;
}
private static coverState(shadeArg: IHunterDouglasPowerViewShadeSnapshot): string {
const position = this.homeAssistantPrimaryPosition(shadeArg) ?? shadeArg.positions.tilt;
if (position === undefined) {
return 'unknown';
}
return position <= 0 ? 'closed' : 'open';
}
private static targetPrimaryPosition(shadeArg: IHunterDouglasPowerViewShadeSnapshot, positionArg: number): IHunterDouglasPowerViewShadePosition {
return { primary: shadeArg.capabilities.topDown ? 100 - clamp(positionArg, 0, 100) : clamp(positionArg, 0, 100) };
}
private static openPosition(shadeArg: IHunterDouglasPowerViewShadeSnapshot): IHunterDouglasPowerViewShadePosition {
if (shadeArg.capabilities.tiltOnly) {
return { tilt: 100 };
}
if (shadeArg.capabilities.topDown) {
return { primary: 0 };
}
if (shadeArg.capabilities.dualRail) {
return { primary: 100, secondary: 0 };
}
return { primary: 100 };
}
private static closePosition(shadeArg: IHunterDouglasPowerViewShadeSnapshot): IHunterDouglasPowerViewShadePosition {
if (shadeArg.capabilities.tiltOnly) {
return { tilt: 0 };
}
if (shadeArg.capabilities.topDown) {
return { primary: 100 };
}
if (shadeArg.capabilities.dualRail) {
return { primary: 0, secondary: 0 };
}
return { primary: 0 };
}
private static stateForShade(shadeArg: IHunterDouglasPowerViewShadeSnapshot, featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], updatedAtArg: string): plugins.shxInterfaces.data.IDeviceState[] {
return featuresArg.map((featureArg) => ({
featureId: featureArg.id,
value: this.deviceStateValue(this.featureValue(shadeArg, featureArg.id)),
updatedAt: updatedAtArg,
}));
}
private static featureValue(shadeArg: IHunterDouglasPowerViewShadeSnapshot, featureArg: string): unknown {
if (featureArg === 'position') {
return this.homeAssistantPrimaryPosition(shadeArg) ?? null;
}
if (featureArg === 'tilt') {
return shadeArg.positions.tilt ?? null;
}
if (featureArg === 'battery') {
return shadeArg.batteryPercent ?? null;
}
return null;
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (valueArg === undefined || valueArg === null) {
return null;
}
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'object' && !Array.isArray(valueArg)) {
return valueArg as Record<string, unknown>;
}
return String(valueArg);
}
private static sceneForTarget(snapshotArg: IHunterDouglasPowerViewSnapshot, requestArg: IServiceCallRequest): IHunterDouglasPowerViewSnapshot['scenes'][number] | undefined {
const dataSceneId = stringValue(requestArg.data?.sceneId) || stringValue(requestArg.data?.scene_id);
if (dataSceneId) {
return snapshotArg.scenes.find((sceneArg) => sceneArg.sceneId === dataSceneId);
}
const target = requestArg.target.entityId || requestArg.target.deviceId;
if (!target) {
return undefined;
}
return snapshotArg.scenes.find((sceneArg) => target === sceneArg.sceneId || target === `scene.${slug(sceneArg.name)}` || target === `button.${slug(sceneArg.name)}_scene` || target === `${snapshotArg.hub.id}_${sceneArg.sceneId}_scene`);
}
private static shadeForTarget(snapshotArg: IHunterDouglasPowerViewSnapshot, requestArg: IServiceCallRequest): IHunterDouglasPowerViewShadeSnapshot | undefined {
const dataShadeId = stringValue(requestArg.data?.shadeId) || stringValue(requestArg.data?.shade_id);
if (dataShadeId) {
return snapshotArg.shades.find((shadeArg) => shadeArg.shadeId === dataShadeId);
}
const target = requestArg.target.entityId || requestArg.target.deviceId;
if (!target) {
return undefined;
}
return snapshotArg.shades.find((shadeArg) => target === shadeArg.shadeId || target === `cover.${slug(shadeArg.name)}` || target === this.shadeDeviceId(snapshotArg, shadeArg) || target === `${snapshotArg.hub.id}_${shadeArg.shadeId}_cover`);
}
private static hubSerial(rawArg: IHunterDouglasPowerViewHubRawData): string | undefined {
return stringValue(valueAt(rawArg, ['userData', 'serialNumber'])) || stringValue(valueAt(rawArg, ['config', 'serialNumber']));
}
private static hubMac(rawArg: IHunterDouglasPowerViewHubRawData): string | undefined {
return stringValue(valueAt(rawArg, ['userData', 'macAddress'])) || stringValue(valueAt(rawArg, ['config', 'networkStatus', 'primaryMacAddress']));
}
private static hubName(rawArg: IHunterDouglasPowerViewHubRawData): string | undefined {
const name = stringValue(valueAt(rawArg, ['userData', 'hubName'])) || stringValue(valueAt(rawArg, ['config', 'hubName']));
return name ? decodePowerViewName(name) : undefined;
}
private static hubNameFromHome(hubRawArg: IHunterDouglasPowerViewHubRawData, homeRawArg: IHunterDouglasPowerViewHubRawData): string | undefined {
const serial = this.hubSerial(hubRawArg);
const mac = this.hubMac(hubRawArg);
const gateways = Array.isArray(homeRawArg.gateways) ? homeRawArg.gateways : [];
const gateway = gateways.find((gatewayArg) => gatewayArg.serial === serial || gatewayArg.mac === mac);
return stringValue(gateway?.name);
}
private static hubFirmware(rawArg: IHunterDouglasPowerViewHubRawData): IHunterDouglasPowerViewHubRawData['firmware'] | undefined {
return recordValue(valueAt(rawArg, ['userData', 'firmware', 'mainProcessor']))
|| recordValue(valueAt(rawArg, ['config', 'firmware', 'mainProcessor']))
|| recordValue(valueAt(rawArg, ['firmware', 'mainProcessor']));
}
private static hubModel(rawArg: IHunterDouglasPowerViewHubRawData, apiVersionArg: number): string {
const firmwareName = stringValue(this.hubFirmware(rawArg)?.name);
if (firmwareName === 'PV_Gen3') {
return 'Powerview Generation 3';
}
if (firmwareName === 'PV Hub2.0') {
return 'Powerview Generation 2';
}
if (firmwareName === 'PowerView Hub') {
return 'Powerview Generation 1';
}
return firmwareName || `Powerview Generation ${apiVersionArg}`;
}
private static hubRole(rawArg: IHunterDouglasPowerViewHubRawData, apiVersionArg: number): 'Primary' | 'Secondary' | 'Unknown' {
if (apiVersionArg <= 2) {
return 'Primary';
}
const mgwStatus = valueAt(rawArg, ['config', 'mgwStatus', 'running']);
const primary = valueAt(rawArg, ['config', 'mgwConfig', 'primary']);
if (mgwStatus === false || (mgwStatus === true && primary === true)) {
return 'Primary';
}
if (mgwStatus === true && primary === false) {
return 'Secondary';
}
return 'Unknown';
}
private static apiVersionFromRaw(rawArg: Partial<IHunterDouglasPowerViewRawData>): number | undefined {
const revision = numberValue(this.hubFirmware(rawArg.hub || {})?.revision);
if (revision !== undefined) {
return revision;
}
const fwVersion = stringValue(rawArg.hub?.fwVersion);
if (fwVersion) {
return Number(fwVersion.split('.')[0]) || undefined;
}
return undefined;
}
private static roomId(rawArg: IHunterDouglasPowerViewSceneRaw | IHunterDouglasPowerViewShadeRaw): string | undefined {
const roomId = rawArg.roomId ?? (Array.isArray(rawArg.roomIds) ? rawArg.roomIds[0] : undefined);
return roomId === undefined ? undefined : String(roomId);
}
private static firmwareString(firmwareArg: Record<string, unknown> | undefined): string | undefined {
if (!firmwareArg) {
return undefined;
}
const revision = numberValue(firmwareArg.revision);
const subRevision = numberValue(firmwareArg.subRevision);
const build = numberValue(firmwareArg.build);
return revision !== undefined && subRevision !== undefined && build !== undefined ? `${revision}.${subRevision}.${build}` : undefined;
}
private static batteryPercent(shadeArg: IHunterDouglasPowerViewShadeRaw, apiVersionArg: number): number | undefined {
if (apiVersionArg >= 3) {
const levels: Record<number, number> = { 4: 100, 3: 100, 2: 50, 1: 20, 0: 0 };
const status = numberValue(shadeArg.batteryStatus);
return status === undefined ? undefined : levels[status] ?? 0;
}
const strength = numberValue(shadeArg.batteryStrength);
return strength === undefined ? undefined : Math.min(100, Math.round((strength / 180) * 100));
}
private static powerSource(shadeArg: IHunterDouglasPowerViewShadeRaw, apiVersionArg: number): string | undefined {
const raw = numberValue(apiVersionArg >= 3 ? shadeArg.powerType : shadeArg.batteryKind);
if (raw === undefined) {
return undefined;
}
if (apiVersionArg >= 3) {
return ({ 0: 'Battery', 1: 'Hardwired', 2: 'Rechargable', 11: 'Rechargable-Fixed', 12: 'Hardwired-Fixed' } as Record<number, string>)[raw];
}
return ({ 1: 'Hardwired', 2: 'Battery', 3: 'Rechargable', 11: 'Rechargable-Fixed', 12: 'Hardwired-Fixed' } as Record<number, string>)[raw];
}
private static signalStrength(shadeArg: IHunterDouglasPowerViewShadeRaw, apiVersionArg: number): number | undefined {
const value = numberValue(shadeArg.signalStrength);
if (value === undefined) {
return undefined;
}
return apiVersionArg >= 3 ? value : Math.round((value / 4) * 100);
}
private static apiV3ToPercent(valueArg: number | undefined): number | undefined {
if (valueArg === undefined) {
return undefined;
}
return Math.round(clamp(valueArg <= 1 ? valueArg * 100 : valueArg, 0, 100));
}
private static v2ToPercent(valueArg: number): number {
return Math.round(clamp((valueArg / maxPositionV2) * 100, 0, 100));
}
private static percentToV2(valueArg: number): number {
return Math.round((clamp(valueArg, 0, 100) / 100) * maxPositionV2);
}
private static cleanPosition(positionArg: IHunterDouglasPowerViewShadePosition): IHunterDouglasPowerViewShadePosition {
const result: IHunterDouglasPowerViewShadePosition = {};
for (const [key, value] of Object.entries(positionArg)) {
if (value !== undefined && Number.isFinite(value)) {
(result as Record<string, number>)[key] = value;
}
}
return result;
}
private static methodValue(valueArg: unknown): IHunterDouglasPowerViewCommandRequest['method'] | undefined {
const method = stringValue(valueArg)?.toUpperCase();
return method === 'GET' || method === 'PUT' || method === 'POST' || method === 'DELETE' ? method : undefined;
}
private static hasLocalControl(configArg: IHunterDouglasPowerViewConfig): boolean {
return Boolean(configArg.host || configArg.url || configArg.commandExecutor || configArg.client?.execute);
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attributesArg)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
private static clone<TValue>(valueArg: TValue): TValue {
return JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
const unwrapRoom = (valueArg: IHunterDouglasPowerViewRoomRaw): IHunterDouglasPowerViewRoomRaw => valueArg.room || valueArg;
const unwrapScene = (valueArg: IHunterDouglasPowerViewSceneRaw): IHunterDouglasPowerViewSceneRaw => valueArg.scene || valueArg;
const unwrapShade = (valueArg: IHunterDouglasPowerViewShadeRaw): IHunterDouglasPowerViewShadeRaw => valueArg.shade || valueArg;
const endpointHost = (valueArg: string | undefined): string | undefined => parseEndpoint(valueArg)?.host || valueArg?.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/:\d+$/, '');
const endpointPort = (valueArg: string | undefined, fallbackArg?: number): number | undefined => parseEndpoint(valueArg)?.port || fallbackArg || (valueArg ? hunterDouglasPowerViewDefaultPort : undefined);
const endpointUrl = (valueArg: string | undefined, portArg?: number): string | undefined => {
const parsed = parseEndpoint(valueArg);
const host = parsed?.host || endpointHost(valueArg);
if (!host) {
return undefined;
}
const protocol = parsed?.protocol || 'http';
const port = portArg || parsed?.port;
const portSuffix = port && !((protocol === 'http' && port === 80) || (protocol === 'https' && port === 443)) ? `:${port}` : '';
return `${protocol}://${host}${portSuffix}`;
};
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;
}
};
const slug = (valueArg: unknown): string => String(valueArg ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'powerview';
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 recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
const valueAt = (valueArg: Record<string, unknown> | undefined, pathArg: string[]): unknown => {
let current: unknown = valueArg;
for (const key of pathArg) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.min(maxArg, Math.max(minArg, valueArg));
export const decodePowerViewName = (valueArg: string): string => {
const trimmed = valueArg.trim();
if (!trimmed || !/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed) || trimmed.length % 4 !== 0) {
return valueArg;
}
try {
const decoded = Buffer.from(trimmed, 'base64').toString('utf8');
const encoded = Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '');
return encoded === trimmed.replace(/=+$/, '') && decoded.trim() ? decoded : valueArg;
} catch {
return valueArg;
}
};
@@ -1,4 +1,256 @@
export interface IHomeAssistantHunterdouglasPowerviewConfig {
// TODO: replace with the TypeScript-native config for hunterdouglas_powerview.
export const hunterDouglasPowerViewDomain = 'hunterdouglas_powerview';
export const hunterDouglasPowerViewDisplayName = 'Hunter Douglas PowerView';
export const hunterDouglasPowerViewManufacturer = 'Hunter Douglas';
export const hunterDouglasPowerViewDefaultPort = 80;
export const hunterDouglasPowerViewDefaultTimeoutMs = 15000;
export const hunterDouglasPowerViewZeroconfTypes = ['_powerview._tcp.local.', '_PowerView-G3._tcp.local.'] as const;
export const hunterDouglasPowerViewDhcpHostnamePrefix = 'hunter';
export const hunterDouglasPowerViewDhcpMacPrefix = '002674';
export type THunterDouglasPowerViewSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
export type THunterDouglasPowerViewCommandAction =
| 'move_shade'
| 'stop_shade'
| 'activate_scene'
| 'refresh'
| 'raw_request';
export interface IHunterDouglasPowerViewVersionInfo {
revision?: number;
subRevision?: number;
build?: number;
name?: string;
[key: string]: unknown;
}
export interface IHunterDouglasPowerViewHubRawData {
userData?: Record<string, unknown>;
config?: Record<string, unknown>;
firmware?: Record<string, unknown>;
fwVersion?: string;
gateways?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
export interface IHunterDouglasPowerViewRoomRaw {
id?: number | string;
name?: string;
ptName?: string;
room?: IHunterDouglasPowerViewRoomRaw;
[key: string]: unknown;
}
export interface IHunterDouglasPowerViewSceneRaw {
id?: number | string;
name?: string;
ptName?: string;
roomId?: number | string;
roomIds?: Array<number | string>;
scene?: IHunterDouglasPowerViewSceneRaw;
[key: string]: unknown;
}
export interface IHunterDouglasPowerViewShadeRaw {
id?: number | string;
name?: string;
ptName?: string;
roomId?: number | string;
roomIds?: Array<number | string>;
type?: number | string;
capabilities?: number | string;
positions?: Record<string, unknown>;
firmware?: IHunterDouglasPowerViewVersionInfo;
batteryKind?: number;
batteryStrength?: number;
batteryStatus?: number;
powerType?: number;
signalStrength?: number;
shade?: IHunterDouglasPowerViewShadeRaw;
[key: string]: unknown;
}
export interface IHunterDouglasPowerViewRawData {
apiVersion?: number;
hub?: IHunterDouglasPowerViewHubRawData;
home?: IHunterDouglasPowerViewHubRawData;
rooms?: IHunterDouglasPowerViewRoomRaw[];
scenes?: IHunterDouglasPowerViewSceneRaw[];
shades?: IHunterDouglasPowerViewShadeRaw[];
fetchedAt?: string;
}
export interface IHunterDouglasPowerViewHubSnapshot {
id: string;
name: string;
host?: string;
port?: number;
manufacturer: string;
model?: string;
firmware?: string;
hardware?: string;
apiVersion: number;
role: 'Primary' | 'Secondary' | 'Unknown';
macAddress?: string;
serialNumber?: string;
url?: string;
rawData?: IHunterDouglasPowerViewHubRawData;
}
export interface IHunterDouglasPowerViewRoomSnapshot {
id: string;
name: string;
rawData: IHunterDouglasPowerViewRoomRaw;
}
export interface IHunterDouglasPowerViewSceneSnapshot {
sceneId: string;
name: string;
roomId?: string;
roomName?: string;
rawData: IHunterDouglasPowerViewSceneRaw;
}
export interface IHunterDouglasPowerViewShadePosition {
primary?: number;
secondary?: number;
tilt?: number;
velocity?: number;
}
export interface IHunterDouglasPowerViewShadeCapabilities {
primary: boolean;
secondary: boolean;
tilt: boolean;
stop: boolean;
topDown: boolean;
tiltOnly: boolean;
dualRail: boolean;
dualOverlapped: boolean;
light: boolean;
}
export interface IHunterDouglasPowerViewShadeSnapshot {
shadeId: string;
name: string;
roomId?: string;
roomName?: string;
type?: number | string;
typeName?: string;
capabilityType?: number | string;
capabilities: IHunterDouglasPowerViewShadeCapabilities;
positions: IHunterDouglasPowerViewShadePosition;
firmware?: string;
batteryPercent?: number;
powerSource?: string;
signalStrength?: number;
rawData: IHunterDouglasPowerViewShadeRaw;
}
export interface IHunterDouglasPowerViewCapabilities {
localControl: boolean;
covers: boolean;
scenes: boolean;
rooms: boolean;
tilt: boolean;
stop: boolean;
}
export interface IHunterDouglasPowerViewSnapshot {
hub: IHunterDouglasPowerViewHubSnapshot;
rooms: IHunterDouglasPowerViewRoomSnapshot[];
scenes: IHunterDouglasPowerViewSceneSnapshot[];
shades: IHunterDouglasPowerViewShadeSnapshot[];
capabilities: IHunterDouglasPowerViewCapabilities;
rawData?: IHunterDouglasPowerViewRawData;
online: boolean;
updatedAt: string;
source: THunterDouglasPowerViewSnapshotSource;
error?: string;
}
export interface IHunterDouglasPowerViewCommandRequest {
action: THunterDouglasPowerViewCommandAction;
shadeId?: string;
sceneId?: string;
positions?: IHunterDouglasPowerViewShadePosition;
path?: string;
method?: 'GET' | 'PUT' | 'POST' | 'DELETE';
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
apiVersion?: number;
service?: string;
target?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface IHunterDouglasPowerViewCommandExecutor {
execute(requestArg: IHunterDouglasPowerViewCommandRequest): Promise<unknown>;
}
export interface IHunterDouglasPowerViewClientLike {
getSnapshot?: () => Promise<IHunterDouglasPowerViewSnapshot | Partial<IHunterDouglasPowerViewRawData>>;
getRawData?: () => Promise<Partial<IHunterDouglasPowerViewRawData>>;
execute?: (requestArg: IHunterDouglasPowerViewCommandRequest) => Promise<unknown>;
destroy?: () => Promise<void>;
}
export interface IHunterDouglasPowerViewConfig {
host?: string;
url?: string;
port?: number;
apiVersion?: number;
timeoutMs?: number;
name?: string;
manufacturer?: string;
model?: string;
uniqueId?: string;
serialNumber?: string;
macAddress?: string;
online?: boolean;
snapshot?: IHunterDouglasPowerViewSnapshot;
rawData?: Partial<IHunterDouglasPowerViewRawData>;
client?: IHunterDouglasPowerViewClientLike;
commandExecutor?: IHunterDouglasPowerViewCommandExecutor;
}
export interface IHomeAssistantHunterdouglasPowerviewConfig extends IHunterDouglasPowerViewConfig {}
export interface IHunterDouglasPowerViewManualEntry extends IHunterDouglasPowerViewConfig {
id?: string;
metadata?: Record<string, unknown>;
}
export interface IHunterDouglasPowerViewZeroconfRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export interface IHunterDouglasPowerViewDhcpRecord {
hostname?: string;
hostName?: string;
ip?: string;
address?: string;
macaddress?: string;
macAddress?: string;
registeredDevices?: boolean;
registered_devices?: boolean;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export interface IHunterDouglasPowerViewRefreshResult {
success: boolean;
snapshot?: IHunterDouglasPowerViewSnapshot;
error?: string;
data?: unknown;
}
@@ -1,2 +1,6 @@
export * from './hunterdouglas_powerview.classes.integration.js';
export * from './hunterdouglas_powerview.classes.client.js';
export * from './hunterdouglas_powerview.classes.configflow.js';
export * from './hunterdouglas_powerview.discovery.js';
export * from './hunterdouglas_powerview.mapper.js';
export * from './hunterdouglas_powerview.types.js';
+6
View File
@@ -25,6 +25,7 @@ export * from './denon/index.js';
export * from './denonavr/index.js';
export * from './devolo_home_network/index.js';
export * from './directv/index.js';
export * from './dlink/index.js';
export * from './dlna_dmr/index.js';
export * from './dlna_dms/index.js';
export * from './doorbird/index.js';
@@ -33,17 +34,22 @@ export * from './dunehd/index.js';
export * from './elgato/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 './frontier_silicon/index.js';
export * from './fully_kiosk/index.js';
export * from './glances/index.js';
export * from './go2rtc/index.js';
export * from './goodwe/index.js';
export * from './harmony/index.js';
export * from './heos/index.js';
export * from './hikvision/index.js';
export * from './homekit_controller/index.js';
export * from './homematic/index.js';
export * from './homewizard/index.js';
export * from './huawei_lte/index.js';
export * from './hue/index.js';
export * from './hunterdouglas_powerview/index.js';
export * from './hyperion/index.js';
export * from './ipp/index.js';
export * from './jellyfin/index.js';