Add native local appliance and energy integrations
This commit is contained in:
+12
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const unescapeXml = (valueArg: string): string => {
|
||||
return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+102
@@ -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;
|
||||
+109
-25
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user