Add native local edge service integrations

This commit is contained in:
2026-05-08 11:23:08 +00:00
parent 23f988eae7
commit d7a332ec60
274 changed files with 9451 additions and 841 deletions
+60
View File
@@ -131,7 +131,37 @@ import { Elkm1Integration } from './integrations/elkm1/index.js';
import { ElvIntegration } from './integrations/elv/index.js';
import { EmbyIntegration } from './integrations/emby/index.js';
import { EmoncmsIntegration } from './integrations/emoncms/index.js';
import { EmoncmsHistoryIntegration } from './integrations/emoncms_history/index.js';
import { EmonitorIntegration } from './integrations/emonitor/index.js';
import { EmulatedHueIntegration } from './integrations/emulated_hue/index.js';
import { EmulatedKasaIntegration } from './integrations/emulated_kasa/index.js';
import { EmulatedRokuIntegration } from './integrations/emulated_roku/index.js';
import { EnergeniePowerSocketsIntegration } from './integrations/energenie_power_sockets/index.js';
import { Enigma2Integration } from './integrations/enigma2/index.js';
import { EnoceanIntegration } from './integrations/enocean/index.js';
import { EnphaseEnvoyIntegration } from './integrations/enphase_envoy/index.js';
import { EnvisalinkIntegration } from './integrations/envisalink/index.js';
import { EphemberIntegration } from './integrations/ephember/index.js';
import { EpsonIntegration } from './integrations/epson/index.js';
import { Eq3btsmartIntegration } from './integrations/eq3btsmart/index.js';
import { EsceaIntegration } from './integrations/escea/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js';
import { EufyIntegration } from './integrations/eufy/index.js';
import { EufylifeBleIntegration } from './integrations/eufylife_ble/index.js';
import { EurotronicCometblueIntegration } from './integrations/eurotronic_cometblue/index.js';
import { EverlightsIntegration } from './integrations/everlights/index.js';
import { EvilGeniusLabsIntegration } from './integrations/evil_genius_labs/index.js';
import { Fail2banIntegration } from './integrations/fail2ban/index.js';
import { FamilyhubIntegration } from './integrations/familyhub/index.js';
import { FibaroIntegration } from './integrations/fibaro/index.js';
import { FileIntegration } from './integrations/file/index.js';
import { FilesizeIntegration } from './integrations/filesize/index.js';
import { FingIntegration } from './integrations/fing/index.js';
import { FireflyIiiIntegration } from './integrations/firefly_iii/index.js';
import { FirmataIntegration } from './integrations/firmata/index.js';
import { FivemIntegration } from './integrations/fivem/index.js';
import { FjaraskupanIntegration } from './integrations/fjaraskupan/index.js';
import { FlexitIntegration } from './integrations/flexit/index.js';
import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js';
import { FoscamIntegration } from './integrations/foscam/index.js';
import { FreeboxIntegration } from './integrations/freebox/index.js';
@@ -362,7 +392,37 @@ export const integrations = [
new ElvIntegration(),
new EmbyIntegration(),
new EmoncmsIntegration(),
new EmoncmsHistoryIntegration(),
new EmonitorIntegration(),
new EmulatedHueIntegration(),
new EmulatedKasaIntegration(),
new EmulatedRokuIntegration(),
new EnergeniePowerSocketsIntegration(),
new Enigma2Integration(),
new EnoceanIntegration(),
new EnphaseEnvoyIntegration(),
new EnvisalinkIntegration(),
new EphemberIntegration(),
new EpsonIntegration(),
new Eq3btsmartIntegration(),
new EsceaIntegration(),
new EsphomeIntegration(),
new EufyIntegration(),
new EufylifeBleIntegration(),
new EurotronicCometblueIntegration(),
new EverlightsIntegration(),
new EvilGeniusLabsIntegration(),
new Fail2banIntegration(),
new FamilyhubIntegration(),
new FibaroIntegration(),
new FileIntegration(),
new FilesizeIntegration(),
new FingIntegration(),
new FireflyIiiIntegration(),
new FirmataIntegration(),
new FivemIntegration(),
new FjaraskupanIntegration(),
new FlexitIntegration(),
new ForkedDaapdIntegration(),
new FoscamIntegration(),
new FreeboxIntegration(),
@@ -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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmoncmsHistoryMapper } from './emoncms_history.mapper.js';
import type { IEmoncmsHistoryConfig, IEmoncmsHistorySnapshot } from './emoncms_history.types.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryClient extends SimpleLocalClient<IEmoncmsHistoryConfig> {
constructor(private readonly configArg: IEmoncmsHistoryConfig) {
super(emoncmsHistoryProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmoncmsHistorySnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmoncmsHistoryMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmoncmsHistoryMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryConfigFlow extends SimpleLocalConfigFlow<IEmoncmsHistoryConfig> {
constructor() {
super(emoncmsHistoryProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmoncmsHistoryClient } from './emoncms_history.classes.client.js';
import { EmoncmsHistoryConfigFlow } from './emoncms_history.classes.configflow.js';
import { createEmoncmsHistoryDiscoveryDescriptor } from './emoncms_history.discovery.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryDomain, emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryIntegration extends SimpleLocalIntegration<IEmoncmsHistoryConfig> {
public readonly domain = emoncmsHistoryDomain;
public readonly discoveryDescriptor = createEmoncmsHistoryDiscoveryDescriptor();
public readonly configFlow = new EmoncmsHistoryConfigFlow();
export class HomeAssistantEmoncmsHistoryIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emoncms_history",
displayName: "Emoncms History",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emoncms_history",
"upstreamDomain": "emoncms_history",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"pyemoncms==0.1.3"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@alexandrecuer"
]
},
});
super(emoncmsHistoryProfile);
}
public async setup(configArg: IEmoncmsHistoryConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(emoncmsHistoryProfile, new EmoncmsHistoryClient(configArg));
}
}
export class HomeAssistantEmoncmsHistoryIntegration extends EmoncmsHistoryIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export const createEmoncmsHistoryDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emoncmsHistoryProfile);
@@ -0,0 +1,122 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryDefaultName, emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmoncmsHistoryConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emoncmsHistoryProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmoncmsHistoryConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emoncmsHistoryProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emoncmsHistoryProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmoncmsHistoryConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const payload = recordValue(rawDataArg.payload_dict) || recordValue(rawDataArg.payload) || recordValue(rawDataArg.data) || recordValue(rawDataArg.values) || recordValue(rawDataArg.states) || primitiveRecord(rawDataArg);
const readings = Object.entries(payload).filter((entryArg): entryArg is [string, string | number | boolean | null] => isPrimitive(entryArg[1]));
if (!readings.length) {
return rawDataArg;
}
const endpoint = endpointInfo(configArg, rawDataArg);
const inputNode = configArg.inputNode ?? configArg.inputnode ?? rawDataArg.inputnode ?? rawDataArg.inputNode ?? rawDataArg.node;
const whitelist = stringArray(configArg.whitelist) || stringArray(rawDataArg.whitelist);
const units = recordValue(rawDataArg.units) || {};
const name = configArg.name || stringValue(rawDataArg.name) || emoncmsHistoryDefaultName;
const entities: ISimpleLocalEntitySnapshot[] = readings.map(([keyArg, valueArg]) => ({
id: SimpleLocalMapper.slug(keyArg),
uniqueId: `${emoncmsHistoryProfile.domain}_${SimpleLocalMapper.slug(endpoint.host || name)}_${SimpleLocalMapper.slug(keyArg)}`,
name: titleCase(keyArg),
platform: 'sensor',
state: valueArg,
available: true,
writable: false,
unit: stringValue(units[keyArg]),
attributes: {
entityId: keyArg,
inputNode,
whitelisted: whitelist ? whitelist.includes(keyArg) : undefined,
},
}));
return {
device: {
id: configArg.uniqueId || (endpoint.host ? `${endpoint.host}:${endpoint.port || ''}` : undefined) || stringValue(inputNode) || name,
name,
manufacturer: emoncmsHistoryProfile.manufacturer,
model: emoncmsHistoryProfile.model,
host: endpoint.host,
port: endpoint.port,
protocol: endpoint.useTls ? 'https' : emoncmsHistoryProfile.defaultProtocol,
configurationUrl: endpoint.url,
attributes: {
inputNode,
scanInterval: configArg.scanInterval ?? configArg.scan_interval ?? rawDataArg.scan_interval ?? rawDataArg.scanInterval,
whitelist,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const ignoredPayloadKeys = new Set(['api_key', 'apiKey', 'client', 'commandExecutor', 'device', 'host', 'inputnode', 'inputNode', 'metadata', 'node', 'password', 'scan_interval', 'scanInterval', 'snapshot', 'snapshotProvider', 'token', 'units', 'url', 'username', 'whitelist']);
const primitiveRecord = (valueArg: Record<string, unknown>): Record<string, unknown> => Object.fromEntries(Object.entries(valueArg).filter(([keyArg, rawValueArg]) => !ignoredPayloadKeys.has(keyArg) && isPrimitive(rawValueArg)));
const endpointInfo = (configArg: IEmoncmsHistoryConfig, rawDataArg: Record<string, unknown>): { host?: string; port?: number; useTls?: boolean; url?: string } => {
const urlValue = configArg.url || stringValue(rawDataArg.url);
const parsed = parseUrl(urlValue);
return {
host: configArg.host || parsed?.host,
port: configArg.port || parsed?.port,
useTls: configArg.useTls ?? parsed?.useTls,
url: parsed?.url || urlValue,
};
};
const parseUrl = (valueArg?: string): { host: string; port?: number; useTls: boolean; url: string } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, useTls: url.protocol === 'https:', url: url.toString() };
} catch {
return undefined;
}
};
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg);
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const isPrimitive = (valueArg: unknown): valueArg is string | number | boolean | null => valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg);
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined;
const titleCase = (valueArg: string): string => valueArg.replace(/[_./-]+/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
@@ -1,4 +1,93 @@
export interface IHomeAssistantEmoncmsHistoryConfig {
// TODO: replace with the TypeScript-native config for emoncms_history.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const emoncmsHistoryDomain = 'emoncms_history';
export const emoncmsHistoryDefaultName = 'Emoncms History';
export type TEmoncmsHistoryRawData = TSimpleLocalRawData;
export interface IEmoncmsHistorySnapshot extends ISimpleLocalSnapshot {}
export interface IEmoncmsHistoryConfig extends ISimpleLocalConfig {
url?: string;
inputNode?: string | number;
inputnode?: string | number;
whitelist?: string[];
scanInterval?: number;
scan_interval?: number;
}
export interface IHomeAssistantEmoncmsHistoryConfig extends IEmoncmsHistoryConfig {}
const emoncmsHistoryControlServices = [
'input_post',
'send',
'send_history',
];
export const emoncmsHistoryProfile: ISimpleLocalIntegrationProfile = {
domain: 'emoncms_history',
displayName: 'Emoncms History',
manufacturer: 'OpenEnergyMonitor',
model: 'Emoncms History',
defaultName: emoncmsHistoryDefaultName,
defaultHttpPath: '/input/post.json',
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'sensor',
],
serviceDomains: [
'emoncms_history',
],
controlServices: emoncmsHistoryControlServices,
discoverySources: [
'manual',
'http',
'custom',
],
discoveryKeywords: [
'emoncms',
'emoncms history',
'openenergymonitor',
'inputnode',
'history export',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emoncms_history',
upstreamDomain: 'emoncms_history',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: [
'pyemoncms==0.1.3',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@alexandrecuer',
],
configFlow: false,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...emoncmsHistoryControlServices,
],
platforms: [
'sensor',
],
controls: true,
},
localApi: {
implemented: [
'manual local Emoncms URL or host setup, snapshots, raw data, snapshotProvider, and injected native clients',
'mapping configured or raw entity values into outbound history payload snapshots',
'executor-gated Emoncms history/input posting through injected client.execute or commandExecutor',
],
explicitUnsupported: [
'claiming input_post, send, or send_history success without injected client.execute or commandExecutor',
'collecting live Home Assistant state from a Home Assistant state machine',
'performing pyemoncms writes without an injected native client or commandExecutor',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emoncms_history.classes.client.js';
export * from './emoncms_history.classes.configflow.js';
export * from './emoncms_history.classes.integration.js';
export * from './emoncms_history.discovery.js';
export * from './emoncms_history.mapper.js';
export * from './emoncms_history.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmonitorMapper } from './emonitor.mapper.js';
import type { IEmonitorConfig, IEmonitorSnapshot } from './emonitor.types.js';
import { emonitorProfile } from './emonitor.types.js';
export class EmonitorClient extends SimpleLocalClient<IEmonitorConfig> {
constructor(private readonly configArg: IEmonitorConfig) {
super(emonitorProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmonitorSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmonitorMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmonitorMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorProfile } from './emonitor.types.js';
export class EmonitorConfigFlow extends SimpleLocalConfigFlow<IEmonitorConfig> {
constructor() {
super(emonitorProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmonitorClient } from './emonitor.classes.client.js';
import { EmonitorConfigFlow } from './emonitor.classes.configflow.js';
import { createEmonitorDiscoveryDescriptor } from './emonitor.discovery.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorDomain, emonitorProfile } from './emonitor.types.js';
export class EmonitorIntegration extends SimpleLocalIntegration<IEmonitorConfig> {
public readonly domain = emonitorDomain;
public readonly discoveryDescriptor = createEmonitorDiscoveryDescriptor();
public readonly configFlow = new EmonitorConfigFlow();
export class HomeAssistantEmonitorIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emonitor",
displayName: "SiteSage Emonitor",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emonitor",
"upstreamDomain": "emonitor",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"aioemonitor==1.0.5"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bdraco"
]
},
});
super(emonitorProfile);
}
public async setup(configArg: IEmonitorConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(emonitorProfile, new EmonitorClient(configArg));
}
}
export class HomeAssistantEmonitorIntegration extends EmonitorIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emonitorProfile } from './emonitor.types.js';
export const createEmonitorDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emonitorProfile);
+134
View File
@@ -0,0 +1,134 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorDefaultName, emonitorProfile } from './emonitor.types.js';
export class EmonitorMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmonitorConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emonitorProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmonitorConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emonitorProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emonitorProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmonitorConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const channels = channelRecords(rawDataArg.channels);
if (!channels.length) {
return rawDataArg;
}
const network = recordValue(rawDataArg.network) || {};
const hardware = recordValue(rawDataArg.hardware) || {};
const macAddress = configArg.macAddress || configArg.mac_address || stringValue(network.mac_address) || stringValue(network.macAddress) || stringValue(rawDataArg.mac_address) || stringValue(rawDataArg.macAddress);
const serialNumber = stringValue(hardware.serial_number) || stringValue(hardware.serialNumber) || stringValue(rawDataArg.serial_number) || stringValue(rawDataArg.serialNumber);
const firmwareVersion = stringValue(hardware.firmware_version) || stringValue(hardware.firmwareVersion) || stringValue(rawDataArg.firmware_version) || stringValue(rawDataArg.firmwareVersion);
const deviceName = configArg.name || stringValue(rawDataArg.name) || (macAddress ? `Emonitor ${shortMac(macAddress)}` : emonitorDefaultName);
const entities: ISimpleLocalEntitySnapshot[] = [];
const seenChannels = new Set<string>();
for (const channel of channels) {
seenChannels.add(channel.number);
if (channel.data.active === false) {
continue;
}
const pairedChannel = stringValue(channel.data.paired_with_channel) || stringValue(channel.data.pairedWithChannel) || numberString(channel.data.paired_with_channel) || numberString(channel.data.pairedWithChannel);
if (pairedChannel && seenChannels.has(pairedChannel)) {
continue;
}
const label = stringValue(channel.data.label) || channel.number;
entities.push(...powerEntities({ channelNumber: channel.number, label, data: channel.data, pairedData: pairedChannel ? channels.find((candidateArg) => candidateArg.number === pairedChannel)?.data : undefined, macAddress, deviceName }));
}
return {
device: {
id: configArg.uniqueId || macAddress || serialNumber || configArg.host || deviceName,
name: deviceName,
manufacturer: emonitorProfile.manufacturer,
model: emonitorProfile.model,
serialNumber,
host: configArg.host,
port: configArg.port,
protocol: emonitorProfile.defaultProtocol,
attributes: {
firmwareVersion,
macAddress,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const powerEntities = (optionsArg: { channelNumber: string; label: string; data: Record<string, unknown>; pairedData?: Record<string, unknown>; macAddress?: string; deviceName: string }): ISimpleLocalEntitySnapshot[] => {
const base = SimpleLocalMapper.slug(optionsArg.macAddress || optionsArg.deviceName);
return [
['inst_power', optionsArg.label],
['avg_power', `${optionsArg.label} average`],
['max_power', `${optionsArg.label} max`],
].map(([keyArg, nameArg]) => ({
id: `channel_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`,
uniqueId: `${emonitorProfile.domain}_${base}_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`,
name: nameArg,
platform: 'sensor',
state: sumPower(optionsArg.data, optionsArg.pairedData, keyArg),
available: true,
writable: false,
unit: 'W',
deviceClass: 'power',
stateClass: 'measurement',
attributes: {
channel: Number(optionsArg.channelNumber),
pairedWithChannel: numberValue(optionsArg.data.paired_with_channel) ?? numberValue(optionsArg.data.pairedWithChannel),
},
}));
};
const sumPower = (channelArg: Record<string, unknown>, pairedChannelArg: Record<string, unknown> | undefined, keyArg: string): number => (numberValue(channelArg[keyArg]) || 0) + (pairedChannelArg ? numberValue(pairedChannelArg[keyArg]) || 0 : 0);
const channelRecords = (valueArg: unknown): Array<{ number: string; data: Record<string, unknown> }> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord).map((channelArg, indexArg) => ({ number: stringValue(channelArg.channel) || stringValue(channelArg.channel_number) || String(indexArg + 1), data: channelArg }));
}
if (isRecord(valueArg)) {
return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record<string, unknown>] => isRecord(entryArg[1])).map(([numberArg, dataArg]) => ({ number: numberArg, data: dataArg }));
}
return [];
};
const shortMac = (valueArg: string): string => valueArg.replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg);
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberString = (valueArg: unknown): string | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? String(valueArg) : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
+88 -3
View File
@@ -1,4 +1,89 @@
export interface IHomeAssistantEmonitorConfig {
// TODO: replace with the TypeScript-native config for emonitor.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const emonitorDomain = 'emonitor';
export const emonitorDefaultName = 'SiteSage Emonitor';
export type TEmonitorRawData = TSimpleLocalRawData;
export interface IEmonitorSnapshot extends ISimpleLocalSnapshot {}
export interface IEmonitorConfig extends ISimpleLocalConfig {
macAddress?: string;
mac_address?: string;
}
export interface IHomeAssistantEmonitorConfig extends IEmonitorConfig {}
export const emonitorProfile: ISimpleLocalIntegrationProfile = {
domain: 'emonitor',
displayName: 'SiteSage Emonitor',
manufacturer: 'Powerhouse Dynamics, Inc.',
model: 'SiteSage Emonitor',
defaultName: emonitorDefaultName,
defaultProtocol: 'http',
status: 'read-only-runtime',
platforms: [
'sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'dhcp',
'http',
'custom',
],
discoveryKeywords: [
'emonitor',
'sitesage',
'powerhouse dynamics',
'0090c2',
'power monitor',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emonitor',
upstreamDomain: 'emonitor',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'aioemonitor==1.0.5',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@bdraco',
],
configFlow: true,
dhcp: [
{
hostname: 'emonitor*',
macaddress: '0090C2*',
},
{
registered_devices: true,
},
],
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local host setup, snapshots, raw data, snapshotProvider, and injected native clients',
'SiteSage Emonitor status/channel snapshot mapping compatible with aioemonitor status data',
],
explicitUnsupported: [
'claiming live control success without injected client.execute or commandExecutor',
'guessing undocumented aioemonitor HTTP endpoints for direct polling',
'cloud account flows and remote API polling',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emonitor.classes.client.js';
export * from './emonitor.classes.configflow.js';
export * from './emonitor.classes.integration.js';
export * from './emonitor.discovery.js';
export * from './emonitor.mapper.js';
export * from './emonitor.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedHueMapper } from './emulated_hue.mapper.js';
import type { IEmulatedHueConfig, IEmulatedHueSnapshot } from './emulated_hue.types.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueClient extends SimpleLocalClient<IEmulatedHueConfig> {
constructor(private readonly configArg: IEmulatedHueConfig) {
super(emulatedHueProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedHueSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedHueMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedHueMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueConfigFlow extends SimpleLocalConfigFlow<IEmulatedHueConfig> {
constructor() {
super(emulatedHueProfile);
}
}
@@ -1,29 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedHueClient } from './emulated_hue.classes.client.js';
import { EmulatedHueConfigFlow } from './emulated_hue.classes.configflow.js';
import { createEmulatedHueDiscoveryDescriptor } from './emulated_hue.discovery.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueDomain, emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueIntegration extends SimpleLocalIntegration<IEmulatedHueConfig> {
public readonly domain = emulatedHueDomain;
public readonly discoveryDescriptor = createEmulatedHueDiscoveryDescriptor();
public readonly configFlow = new EmulatedHueConfigFlow();
export class HomeAssistantEmulatedHueIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emulated_hue",
displayName: "Emulated Hue",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emulated_hue",
"upstreamDomain": "emulated_hue",
"iotClass": "local_push",
"qualityScale": "internal",
"requirements": [],
"dependencies": [
"network"
],
"afterDependencies": [
"http"
],
"codeowners": [
"@bdraco",
"@Tho85"
]
},
});
super(emulatedHueProfile);
}
public async setup(configArg: IEmulatedHueConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(emulatedHueProfile, new EmulatedHueClient(configArg));
}
}
export class HomeAssistantEmulatedHueIntegration extends EmulatedHueIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export const createEmulatedHueDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedHueProfile);
@@ -0,0 +1,206 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueDefaultName, emulatedHueDefaultPort, emulatedHueProfile, emulatedHueSerialNumber, emulatedHueUuid } from './emulated_hue.types.js';
export class EmulatedHueMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedHueConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedHueProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedHueConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedHueProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedHueProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedHueConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const hueLights = hueLightEntities(rawDataArg.lights);
const haStates = stateRecords(rawDataArg.states ?? rawDataArg.hassStates ?? rawDataArg.entities);
const entities = haStates.length ? entitiesFromStates(configArg, rawDataArg, haStates) : hueLights;
if (!entities.length) {
return rawDataArg;
}
const config = recordValue(rawDataArg.config) || {};
const endpoint = endpointInfo(configArg, rawDataArg, config);
const name = configArg.name || stringValue(config.name) || stringValue(rawDataArg.name) || 'HASS BRIDGE';
return {
device: {
id: configArg.uniqueId || stringValue(config.mac) || emulatedHueSerialNumber,
name,
manufacturer: emulatedHueProfile.manufacturer,
model: emulatedHueProfile.model,
serialNumber: emulatedHueSerialNumber,
host: endpoint.host,
port: endpoint.port,
protocol: emulatedHueProfile.defaultProtocol,
configurationUrl: endpoint.host ? `http://${endpoint.host}:${endpoint.port || emulatedHueDefaultPort}` : undefined,
attributes: {
uuid: emulatedHueUuid,
type: configArg.type || stringValue(rawDataArg.type) || 'google_home',
exposeByDefault: configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault,
lightsAllDimmable: configArg.lightsAllDimmable ?? configArg.lights_all_dimmable ?? rawDataArg.lights_all_dimmable ?? rawDataArg.lightsAllDimmable,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const defaultExposedDomains = ['switch', 'light', 'group', 'input_boolean', 'media_player', 'fan'];
const platformByDomain: Record<string, TEntityPlatform> = {
climate: 'climate',
cover: 'cover',
fan: 'fan',
group: 'switch',
humidifier: 'fan',
input_boolean: 'switch',
light: 'light',
media_player: 'media_player',
scene: 'button',
script: 'button',
switch: 'switch',
};
const offStates = new Set(['closed', 'off', 'unavailable', 'unknown']);
const entitiesFromStates = (configArg: IEmulatedHueConfig, rawDataArg: Record<string, unknown>, stateRecordsArg: Array<Record<string, unknown>>): ISimpleLocalEntitySnapshot[] => {
const exposedDomains = stringArray(configArg.exposedDomains) || stringArray(configArg.exposed_domains) || stringArray(rawDataArg.exposed_domains) || stringArray(rawDataArg.exposedDomains) || defaultExposedDomains;
const exposeByDefault = configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault ?? true;
return stateRecordsArg.map((stateArg, indexArg) => entityFromState(configArg, exposedDomains, Boolean(exposeByDefault), stateArg, indexArg)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg));
};
const entityFromState = (configArg: IEmulatedHueConfig, exposedDomainsArg: string[], exposeByDefaultArg: boolean, stateArg: Record<string, unknown>, indexArg: number): ISimpleLocalEntitySnapshot | undefined => {
const entityId = stringValue(stateArg.entity_id) || stringValue(stateArg.entityId) || stringValue(stateArg.id);
if (!entityId) {
return undefined;
}
const domain = entityId.split('.')[0];
const platform = platformByDomain[domain];
if (!platform || (exposeByDefaultArg && !exposedDomainsArg.includes(domain)) || (!exposeByDefaultArg && !configArg.exposedEntities?.[entityId])) {
return undefined;
}
const attributes = recordValue(stateArg.attributes) || {};
const exposedEntityConfig = configArg.exposedEntities?.[entityId];
if (exposedEntityConfig?.hidden) {
return undefined;
}
return {
id: SimpleLocalMapper.slug(entityId),
uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(entityId)}`,
name: exposedEntityConfig?.name || stringValue(attributes.emulated_hue_name) || stringValue(attributes.friendly_name) || stringValue(stateArg.name) || entityId,
platform,
state: hueStateValue(domain, stateArg.state),
available: stringValue(stateArg.state) !== 'unavailable',
writable: true,
attributes: {
entityId,
hueNumber: indexArg + 1,
hueReachable: stringValue(stateArg.state) !== 'unavailable',
brightness: attributes.brightness,
colorTempKelvin: attributes.color_temp_kelvin,
hsColor: attributes.hs_color,
supportedColorModes: attributes.supported_color_modes,
supportedFeatures: attributes.supported_features,
},
};
};
const hueLightEntities = (valueArg: unknown): ISimpleLocalEntitySnapshot[] => {
if (!isRecord(valueArg)) {
return [];
}
return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record<string, unknown>] => isRecord(entryArg[1])).map(([numberArg, lightArg]) => {
const state = recordValue(lightArg.state) || {};
return {
id: `hue_${SimpleLocalMapper.slug(numberArg)}`,
uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(stringValue(lightArg.uniqueid) || numberArg)}`,
name: stringValue(lightArg.name) || `Hue ${numberArg}`,
platform: 'light',
state: state.on === true,
available: state.reachable !== false,
writable: true,
attributes: {
hueNumber: numberArg,
hueUniqueId: lightArg.uniqueid,
hueType: lightArg.type,
modelId: lightArg.modelid,
brightness: state.bri,
colorMode: state.colormode,
},
};
});
};
const endpointInfo = (configArg: IEmulatedHueConfig, rawDataArg: Record<string, unknown>, hueConfigArg: Record<string, unknown>): { host?: string; port?: number } => {
const ipAddress = stringValue(hueConfigArg.ipaddress) || stringValue(rawDataArg.ipaddress);
const parsed = parseHostPort(ipAddress);
const host = configArg.host || configArg.hostIp || configArg.host_ip || stringValue(rawDataArg.host_ip) || stringValue(rawDataArg.hostIp) || parsed?.host;
const port = configArg.port || configArg.listenPort || configArg.listen_port || numberValue(rawDataArg.listen_port) || numberValue(rawDataArg.listenPort) || parsed?.port || (host ? emulatedHueDefaultPort : undefined);
return { host, port };
};
const parseHostPort = (valueArg?: string): { host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
const [host, port] = valueArg.split(':');
return host ? { host, port: port ? Number(port) : undefined } : undefined;
};
const stateRecords = (valueArg: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord);
}
if (isRecord(valueArg)) {
return Object.values(valueArg).filter(isRecord);
}
return [];
};
const hueStateValue = (domainArg: string, valueArg: unknown): boolean | string => {
const value = stringValue(valueArg);
if (!value) {
return false;
}
if (domainArg === 'cover') {
return value !== 'closed';
}
return !offStates.has(value);
};
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg && !('entity_id' in entityArg));
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
@@ -1,4 +1,148 @@
export interface IHomeAssistantEmulatedHueConfig {
// TODO: replace with the TypeScript-native config for emulated_hue.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const emulatedHueDomain = 'emulated_hue';
export const emulatedHueDefaultName = 'Emulated Hue';
export const emulatedHueDefaultPort = 8300;
export const emulatedHueSerialNumber = '001788FFFE23BFC2';
export const emulatedHueUuid = '2f402f80-da50-11e1-9b23-001788255acc';
export type TEmulatedHueRawData = TSimpleLocalRawData;
export interface IEmulatedHueSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedHueConfig extends ISimpleLocalConfig {
hostIp?: string;
host_ip?: string;
listenPort?: number;
listen_port?: number;
advertiseIp?: string;
advertise_ip?: string;
advertisePort?: number;
advertise_port?: number;
exposeByDefault?: boolean;
expose_by_default?: boolean;
exposedDomains?: string[];
exposed_domains?: string[];
lightsAllDimmable?: boolean;
lights_all_dimmable?: boolean;
type?: 'alexa' | 'google_home' | string;
exposedEntities?: Record<string, { name?: string; hidden?: boolean }>;
}
export interface IHomeAssistantEmulatedHueConfig extends IEmulatedHueConfig {}
const emulatedHueControlServices = [
'turn_on',
'turn_off',
'toggle',
'set_level',
'set_temperature',
'open_cover',
'close_cover',
'set_value',
'select_source',
'volume_up',
'volume_down',
'volume_mute',
'media_play',
'media_pause',
'media_stop',
];
export const emulatedHueProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_hue',
displayName: 'Emulated Hue',
manufacturer: 'Home Assistant',
model: 'Emulated Hue Bridge',
defaultName: emulatedHueDefaultName,
defaultPort: emulatedHueDefaultPort,
defaultProtocol: 'upnp',
status: 'control-runtime',
platforms: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
serviceDomains: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
controlServices: emulatedHueControlServices,
discoverySources: [
'manual',
'ssdp',
'http',
'custom',
],
discoveryKeywords: [
'emulated hue',
'hass bridge',
'philips hue',
'hue bridge',
'upnp',
'alexa',
'google_home',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_hue',
upstreamDomain: 'emulated_hue',
iotClass: 'local_push',
qualityScale: 'internal',
requirements: [],
dependencies: [
'network',
],
afterDependencies: [
'http',
],
codeowners: [
'@bdraco',
'@Tho85',
],
configFlow: false,
hue: {
serialNumber: emulatedHueSerialNumber,
uuid: emulatedHueUuid,
defaultListenPort: emulatedHueDefaultPort,
defaultType: 'google_home',
},
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...emulatedHueControlServices,
],
platforms: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
controls: true,
},
localApi: {
implemented: [
'manual bridge setup, snapshots, raw data, snapshotProvider, and injected native clients',
'Hue API-compatible state mapping for exposed local Home Assistant entity snapshots',
'executor-gated local control dispatch through injected client.execute or commandExecutor',
],
explicitUnsupported: [
'starting the HTTP and UPNP responder without a host application',
'claiming Hue API command success without injected client.execute or commandExecutor',
'accepting remote non-local Hue API callers',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_hue.classes.client.js';
export * from './emulated_hue.classes.configflow.js';
export * from './emulated_hue.classes.integration.js';
export * from './emulated_hue.discovery.js';
export * from './emulated_hue.mapper.js';
export * from './emulated_hue.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedKasaMapper } from './emulated_kasa.mapper.js';
import type { IEmulatedKasaConfig, IEmulatedKasaSnapshot } from './emulated_kasa.types.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaClient extends SimpleLocalClient<IEmulatedKasaConfig> {
constructor(private readonly configArg: IEmulatedKasaConfig) {
super(emulatedKasaProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedKasaSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedKasaMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedKasaMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaConfigFlow extends SimpleLocalConfigFlow<IEmulatedKasaConfig> {
constructor() {
super(emulatedKasaProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedKasaClient } from './emulated_kasa.classes.client.js';
import { EmulatedKasaConfigFlow } from './emulated_kasa.classes.configflow.js';
import { createEmulatedKasaDiscoveryDescriptor } from './emulated_kasa.discovery.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaDomain, emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaIntegration extends SimpleLocalIntegration<IEmulatedKasaConfig> {
public readonly domain = emulatedKasaDomain;
public readonly discoveryDescriptor = createEmulatedKasaDiscoveryDescriptor();
public readonly configFlow = new EmulatedKasaConfigFlow();
export class HomeAssistantEmulatedKasaIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emulated_kasa",
displayName: "Emulated Kasa",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emulated_kasa",
"upstreamDomain": "emulated_kasa",
"iotClass": "local_push",
"qualityScale": "internal",
"requirements": [
"sense-energy==0.14.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@kbickar"
]
},
});
super(emulatedKasaProfile);
}
public async setup(configArg: IEmulatedKasaConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(emulatedKasaProfile, new EmulatedKasaClient(configArg));
}
}
export class HomeAssistantEmulatedKasaIntegration extends EmulatedKasaIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export const createEmulatedKasaDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedKasaProfile);
@@ -0,0 +1,185 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaDefaultName, emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedKasaConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedKasaProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedKasaConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedKasaProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedKasaProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedKasaConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const plugRecords = plugEntries(rawDataArg);
if (!plugRecords.length) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
const name = configArg.name || stringValue(rawRecord?.name) || emulatedKasaDefaultName;
const host = configArg.host || stringValue(rawRecord?.host);
const entities = plugRecords.flatMap(({ id, record }) => kasaEntities(id, record));
if (!entities.length) {
return rawDataArg;
}
return {
device: {
id: configArg.uniqueId || stringValue(rawRecord?.id) || host || emulatedKasaProfile.domain,
name,
manufacturer: emulatedKasaProfile.manufacturer,
model: emulatedKasaProfile.model,
host,
port: configArg.port || numberValue(rawRecord?.port),
protocol: emulatedKasaProfile.defaultProtocol,
attributes: {
emulatedPlugCount: plugRecords.length,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const kasaEntities = (fallbackIdArg: string, recordArg: Record<string, unknown>): ISimpleLocalEntitySnapshot[] => {
const entityId = stringValue(recordArg.entity_id) || stringValue(recordArg.entityId) || fallbackIdArg;
const alias = stringValue(recordArg.name) || stringValue(recordArg.alias) || titleFromEntityId(entityId);
const slug = SimpleLocalMapper.slug(entityId || alias);
const domain = stringValue(recordArg.domain) || entityId.split('.')[0];
const rawState = recordArg.state ?? recordArg.is_on ?? recordArg.isOn;
const onState = switchState(rawState);
const powerEntity = stringValue(recordArg.power_entity) || stringValue(recordArg.powerEntity);
let power = numberValue(recordArg.power ?? recordArg.power_w ?? recordArg.powerW ?? recordArg.watts ?? recordArg.value);
if (power === undefined && (domain === 'sensor' || entityId.startsWith('sensor.'))) {
power = numberValue(rawState);
}
if (power === undefined && onState === false) {
power = 0;
}
const entities: ISimpleLocalEntitySnapshot[] = [];
if (power !== undefined) {
entities.push({
id: `${slug}_power`,
uniqueId: `${emulatedKasaProfile.domain}_${slug}_power`,
name: `${alias} Power`,
platform: 'sensor',
state: power,
available: true,
writable: false,
unit: 'W',
deviceClass: 'power',
attributes: {
entityId,
powerEntity,
},
});
}
if (onState !== undefined && domain !== 'sensor') {
entities.push({
id: `${slug}_state`,
uniqueId: `${emulatedKasaProfile.domain}_${slug}_state`,
name: `${alias} State`,
platform: 'binary_sensor',
state: onState,
available: true,
writable: false,
attributes: {
entityId,
},
});
}
return entities;
};
const plugEntries = (valueArg: unknown): Array<{ id: string; record: Record<string, unknown> }> => {
if (Array.isArray(valueArg)) {
return valueArg.map((entryArg, indexArg) => ({ id: `plug_${indexArg + 1}`, record: recordValue(entryArg) })).filter((entryArg): entryArg is { id: string; record: Record<string, unknown> } => Boolean(entryArg.record));
}
const rawRecord = recordValue(valueArg);
if (!rawRecord) {
return [];
}
const nested = rawRecord.entities ?? rawRecord.plugs ?? rawRecord.devices;
if (Array.isArray(nested)) {
return plugEntries(nested);
}
const nestedRecord = recordValue(nested);
if (nestedRecord) {
return Object.entries(nestedRecord)
.map(([idArg, entryArg]) => ({ id: idArg, record: recordValue(entryArg) }))
.filter((entryArg): entryArg is { id: string; record: Record<string, unknown> } => Boolean(entryArg.record));
}
if ('power' in rawRecord || 'power_w' in rawRecord || 'powerW' in rawRecord || 'state' in rawRecord) {
return [{ id: stringValue(rawRecord.entity_id) || stringValue(rawRecord.entityId) || stringValue(rawRecord.id) || 'plug', record: rawRecord }];
}
return [];
};
const switchState = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['on', 'true', '1', 'open', 'home'].includes(value)) {
return true;
}
if (['off', 'false', '0', 'closed', 'not_home', 'unavailable', 'unknown'].includes(value)) {
return false;
}
return undefined;
};
const titleFromEntityId = (valueArg: string): string => {
const [, objectId = valueArg] = valueArg.split('.');
return objectId.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase());
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : 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() && Number.isFinite(Number(valueArg))) {
return Number(valueArg);
}
return undefined;
};
@@ -1,4 +1,78 @@
export interface IHomeAssistantEmulatedKasaConfig {
// TODO: replace with the TypeScript-native config for emulated_kasa.
[key: string]: unknown;
}
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const emulatedKasaDomain = 'emulated_kasa';
export const emulatedKasaDefaultName = 'Emulated Kasa';
export type TEmulatedKasaRawData = TSimpleLocalRawData;
export interface IEmulatedKasaSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedKasaConfig extends ISimpleLocalConfig {}
export interface IHomeAssistantEmulatedKasaConfig extends IEmulatedKasaConfig {}
export const emulatedKasaProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_kasa',
displayName: 'Emulated Kasa',
manufacturer: 'Home Assistant',
model: 'TP-Link Kasa Emulator',
defaultName: emulatedKasaDefaultName,
defaultPort: 9999,
defaultProtocol: 'local',
status: 'read-only-runtime',
platforms: [
'sensor',
'binary_sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'custom',
],
discoveryKeywords: [
'emulated kasa',
'kasa',
'tp-link',
'tplink',
'sense energy',
'power',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_kasa',
upstreamDomain: 'emulated_kasa',
iotClass: 'local_push',
qualityScale: 'internal',
requirements: [
'sense-energy==0.14.1',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@kbickar',
],
configFlow: false,
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
'binary_sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local setup for configured HA entities exposed as emulated Kasa plug power snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
],
explicitUnsupported: [
'claiming live Kasa emulation server startup or UDP discovery without an injected native client',
'claiming live command success without injected client.execute or commandExecutor',
'Sense Energy cloud account operations',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_kasa.classes.client.js';
export * from './emulated_kasa.classes.configflow.js';
export * from './emulated_kasa.classes.integration.js';
export * from './emulated_kasa.discovery.js';
export * from './emulated_kasa.mapper.js';
export * from './emulated_kasa.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedRokuMapper } from './emulated_roku.mapper.js';
import type { IEmulatedRokuConfig, IEmulatedRokuSnapshot } from './emulated_roku.types.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuClient extends SimpleLocalClient<IEmulatedRokuConfig> {
constructor(private readonly configArg: IEmulatedRokuConfig) {
super(emulatedRokuProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedRokuSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedRokuMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedRokuMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuConfigFlow extends SimpleLocalConfigFlow<IEmulatedRokuConfig> {
constructor() {
super(emulatedRokuProfile);
}
}
@@ -1,25 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedRokuClient } from './emulated_roku.classes.client.js';
import { EmulatedRokuConfigFlow } from './emulated_roku.classes.configflow.js';
import { createEmulatedRokuDiscoveryDescriptor } from './emulated_roku.discovery.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuDomain, emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuIntegration extends SimpleLocalIntegration<IEmulatedRokuConfig> {
public readonly domain = emulatedRokuDomain;
public readonly discoveryDescriptor = createEmulatedRokuDiscoveryDescriptor();
public readonly configFlow = new EmulatedRokuConfigFlow();
export class HomeAssistantEmulatedRokuIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "emulated_roku",
displayName: "Emulated Roku",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/emulated_roku",
"upstreamDomain": "emulated_roku",
"iotClass": "local_push",
"requirements": [
"emulated-roku==0.3.0"
],
"dependencies": [
"network"
],
"afterDependencies": [],
"codeowners": []
},
});
super(emulatedRokuProfile);
}
public async setup(configArg: IEmulatedRokuConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(emulatedRokuProfile, new EmulatedRokuClient(configArg));
}
}
export class HomeAssistantEmulatedRokuIntegration extends EmulatedRokuIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export const createEmulatedRokuDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedRokuProfile);
@@ -0,0 +1,158 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuDefaultName, emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedRokuConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedRokuProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedRokuConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedRokuProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedRokuProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedRokuConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const servers = serverEntries(configArg, rawDataArg);
if (!servers.length) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
const firstServer = servers[0];
const host = configArg.host || stringValue(rawRecord?.host_ip) || stringValue(rawRecord?.hostIp) || stringValue(firstServer.hostIp);
const listenPort = configArg.listenPort || configArg.port || firstServer.listenPort || emulatedRokuProfile.defaultPort;
const name = configArg.name || stringValue(rawRecord?.name) || firstServer.name || emulatedRokuDefaultName;
const entities = servers.map((serverArg) => serverEntity(serverArg));
return {
device: {
id: configArg.uniqueId || host || name,
name,
manufacturer: emulatedRokuProfile.manufacturer,
model: emulatedRokuProfile.model,
host,
port: listenPort,
protocol: emulatedRokuProfile.defaultProtocol,
attributes: {
serverCount: servers.length,
eventType: 'roku_command',
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
interface IServerRecord {
id: string;
name: string;
hostIp?: string;
listenPort?: number;
advertiseIp?: string;
advertisePort?: number;
upnpBindMulticast?: boolean;
state: string;
}
const serverEntity = (serverArg: IServerRecord): ISimpleLocalEntitySnapshot => {
const slug = SimpleLocalMapper.slug(serverArg.id || serverArg.name);
return {
id: `${slug}_server`,
uniqueId: `${emulatedRokuProfile.domain}_${slug}_server`,
name: `${serverArg.name} Server`,
platform: 'sensor',
state: serverArg.state,
available: true,
writable: false,
attributes: {
hostIp: serverArg.hostIp,
listenPort: serverArg.listenPort,
advertiseIp: serverArg.advertiseIp,
advertisePort: serverArg.advertisePort,
upnpBindMulticast: serverArg.upnpBindMulticast,
eventType: 'roku_command',
},
};
};
const serverEntries = (configArg: IEmulatedRokuConfig, valueArg: unknown): IServerRecord[] => {
const rawRecord = recordValue(valueArg);
const nested = rawRecord?.servers ?? rawRecord?.server;
const records = Array.isArray(nested) ? nested : nested ? [nested] : rawRecord ? [rawRecord] : [];
const servers = records.map((entryArg, indexArg) => toServerRecord(configArg, entryArg, indexArg)).filter((entryArg): entryArg is IServerRecord => Boolean(entryArg));
if (servers.length) {
return servers;
}
if (configArg.name || configArg.host || configArg.listenPort || configArg.port) {
return [{
id: configArg.uniqueId || configArg.name || configArg.host || emulatedRokuDefaultName,
name: configArg.name || emulatedRokuDefaultName,
hostIp: configArg.host,
listenPort: configArg.listenPort || configArg.port || emulatedRokuProfile.defaultPort,
advertiseIp: configArg.advertiseIp,
advertisePort: configArg.advertisePort,
upnpBindMulticast: configArg.upnpBindMulticast,
state: configArg.online === false ? 'stopped' : 'configured',
}];
}
return [];
};
const toServerRecord = (configArg: IEmulatedRokuConfig, valueArg: unknown, indexArg: number): IServerRecord | undefined => {
const record = recordValue(valueArg);
if (!record) {
return undefined;
}
const name = stringValue(record.name) || (indexArg === 0 ? configArg.name : undefined) || `${emulatedRokuDefaultName} ${indexArg + 1}`;
const listenPort = numberValue(record.listen_port) || numberValue(record.listenPort) || numberValue(record.port) || (indexArg === 0 ? configArg.listenPort || configArg.port : undefined) || emulatedRokuProfile.defaultPort;
const hostIp = stringValue(record.host_ip) || stringValue(record.hostIp) || stringValue(record.host) || (indexArg === 0 ? configArg.host : undefined);
return {
id: stringValue(record.id) || `${name}:${listenPort}`,
name,
hostIp,
listenPort,
advertiseIp: stringValue(record.advertise_ip) || stringValue(record.advertiseIp) || (indexArg === 0 ? configArg.advertiseIp : undefined),
advertisePort: numberValue(record.advertise_port) || numberValue(record.advertisePort) || (indexArg === 0 ? configArg.advertisePort : undefined),
upnpBindMulticast: booleanValue(record.upnp_bind_multicast) ?? booleanValue(record.upnpBindMulticast) ?? (indexArg === 0 ? configArg.upnpBindMulticast : undefined),
state: stringValue(record.state) || 'configured',
};
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : 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 Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
return Math.round(Number(valueArg));
}
return undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined;
@@ -1,4 +1,80 @@
export interface IHomeAssistantEmulatedRokuConfig {
// TODO: replace with the TypeScript-native config for emulated_roku.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const emulatedRokuDomain = 'emulated_roku';
export const emulatedRokuDefaultName = 'Home Assistant';
export type TEmulatedRokuRawData = TSimpleLocalRawData;
export interface IEmulatedRokuSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedRokuConfig extends ISimpleLocalConfig {
listenPort?: number;
advertiseIp?: string;
advertisePort?: number;
upnpBindMulticast?: boolean;
}
export interface IHomeAssistantEmulatedRokuConfig extends IEmulatedRokuConfig {}
export const emulatedRokuProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_roku',
displayName: 'Emulated Roku',
manufacturer: 'Home Assistant',
model: 'Roku API Emulator',
defaultName: emulatedRokuDefaultName,
defaultPort: 8060,
defaultProtocol: 'upnp',
status: 'read-only-runtime',
platforms: [
'sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'ssdp',
'custom',
],
discoveryKeywords: [
'emulated roku',
'roku',
'upnp',
'remote',
'roku_command',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_roku',
upstreamDomain: 'emulated_roku',
iotClass: 'local_push',
qualityScale: undefined,
requirements: [
'emulated-roku==0.3.0',
],
dependencies: [
'network',
],
afterDependencies: [],
codeowners: [],
configFlow: true,
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local setup for configured Emulated Roku server snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
],
explicitUnsupported: [
'claiming live Emulated Roku server startup, SSDP advertisement, or HTTP endpoint binding without an injected native client',
'claiming live command success without injected client.execute or commandExecutor',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_roku.classes.client.js';
export * from './emulated_roku.classes.configflow.js';
export * from './emulated_roku.classes.integration.js';
export * from './emulated_roku.discovery.js';
export * from './emulated_roku.mapper.js';
export * from './emulated_roku.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnergeniePowerSocketsMapper } from './energenie_power_sockets.mapper.js';
import type { IEnergeniePowerSocketsConfig, IEnergeniePowerSocketsSnapshot } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsClient extends SimpleLocalClient<IEnergeniePowerSocketsConfig> {
constructor(private readonly configArg: IEnergeniePowerSocketsConfig) {
super(energeniePowerSocketsProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnergeniePowerSocketsSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnergeniePowerSocketsMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnergeniePowerSocketsMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsConfigFlow extends SimpleLocalConfigFlow<IEnergeniePowerSocketsConfig> {
constructor() {
super(energeniePowerSocketsProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnergeniePowerSocketsClient } from './energenie_power_sockets.classes.client.js';
import { EnergeniePowerSocketsConfigFlow } from './energenie_power_sockets.classes.configflow.js';
import { createEnergeniePowerSocketsDiscoveryDescriptor } from './energenie_power_sockets.discovery.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsDomain, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsIntegration extends SimpleLocalIntegration<IEnergeniePowerSocketsConfig> {
public readonly domain = energeniePowerSocketsDomain;
public readonly discoveryDescriptor = createEnergeniePowerSocketsDiscoveryDescriptor();
public readonly configFlow = new EnergeniePowerSocketsConfigFlow();
export class HomeAssistantEnergeniePowerSocketsIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "energenie_power_sockets",
displayName: "Energenie Power Sockets",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/energenie_power_sockets",
"upstreamDomain": "energenie_power_sockets",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pyegps==0.2.5"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@gnumpi"
]
},
});
super(energeniePowerSocketsProfile);
}
public async setup(configArg: IEnergeniePowerSocketsConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(energeniePowerSocketsProfile, new EnergeniePowerSocketsClient(configArg));
}
}
export class HomeAssistantEnergeniePowerSocketsIntegration extends EnergeniePowerSocketsIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export const createEnergeniePowerSocketsDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(energeniePowerSocketsProfile);
@@ -0,0 +1,162 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsDefaultName, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnergeniePowerSocketsConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: energeniePowerSocketsProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnergeniePowerSocketsConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(energeniePowerSocketsProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(energeniePowerSocketsProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnergeniePowerSocketsConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
if (!rawRecord) {
return rawDataArg;
}
const deviceApiId = configArg.deviceApiId || stringValue(configArg['api-device-id']) || stringValue(rawRecord.device_id) || stringValue(rawRecord.deviceId) || stringValue(rawRecord.id);
const numberOfSockets = configArg.numberOfSockets || numberValue(rawRecord.numberOfSockets) || numberValue(rawRecord.number_of_sockets) || socketCount(rawRecord);
const sockets = socketEntries(rawRecord, numberOfSockets);
if (!sockets.length) {
return rawDataArg;
}
const name = configArg.name || stringValue(rawRecord.name) || energeniePowerSocketsDefaultName;
const manufacturer = stringValue(rawRecord.manufacturer) || energeniePowerSocketsProfile.manufacturer;
const entities = sockets.map((socketArg) => socketEntity(deviceApiId || name, socketArg));
return {
device: {
id: configArg.uniqueId || deviceApiId || name,
name,
manufacturer,
model: stringValue(rawRecord.model) || stringValue(rawRecord.name) || energeniePowerSocketsProfile.model,
serialNumber: deviceApiId,
protocol: energeniePowerSocketsProfile.defaultProtocol,
attributes: {
deviceApiId,
numberOfSockets: sockets.length,
swVersion: stringValue(rawRecord.sw_version) || stringValue(rawRecord.swVersion),
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
interface ISocketRecord {
socketId: number;
state: boolean | null;
available: boolean;
}
const socketEntity = (deviceIdArg: string, socketArg: ISocketRecord): ISimpleLocalEntitySnapshot => {
const base = `${SimpleLocalMapper.slug(deviceIdArg)}_${socketArg.socketId}`;
return {
id: `socket_${socketArg.socketId}`,
uniqueId: `${energeniePowerSocketsProfile.domain}_${base}`,
name: `Socket ${socketArg.socketId}`,
platform: 'switch',
state: socketArg.state,
available: socketArg.available,
writable: true,
attributes: {
socketId: socketArg.socketId,
},
};
};
const socketEntries = (rawRecordArg: Record<string, unknown>, numberOfSocketsArg: number | undefined): ISocketRecord[] => {
const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states;
if (Array.isArray(sockets)) {
return sockets.map((valueArg, indexArg) => toSocketRecord(indexArg, valueArg));
}
const socketRecord = recordValue(sockets);
if (socketRecord) {
return Object.entries(socketRecord).map(([socketIdArg, valueArg]) => toSocketRecord(numberValue(socketIdArg) ?? 0, valueArg));
}
if (numberOfSocketsArg && numberOfSocketsArg > 0) {
return Array.from({ length: numberOfSocketsArg }, (_valueArg, indexArg) => ({ socketId: indexArg, state: null, available: false }));
}
return [];
};
const toSocketRecord = (socketIdArg: number, valueArg: unknown): ISocketRecord => {
const record = recordValue(valueArg);
const state = switchState(record ? record.state ?? record.is_on ?? record.isOn ?? record.on ?? record.status : valueArg);
return {
socketId: socketIdArg,
state: state ?? null,
available: state !== undefined,
};
};
const socketCount = (rawRecordArg: Record<string, unknown>): number | undefined => {
const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states;
if (Array.isArray(sockets)) {
return sockets.length;
}
const socketRecord = recordValue(sockets);
return socketRecord ? Object.keys(socketRecord).length : undefined;
};
const switchState = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['on', 'true', '1'].includes(value)) {
return true;
}
if (['off', 'false', '0'].includes(value)) {
return false;
}
return undefined;
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : 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 Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
return Math.round(Number(valueArg));
}
return undefined;
};
@@ -1,4 +1,88 @@
export interface IHomeAssistantEnergeniePowerSocketsConfig {
// TODO: replace with the TypeScript-native config for energenie_power_sockets.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const energeniePowerSocketsDomain = 'energenie_power_sockets';
export const energeniePowerSocketsDefaultName = 'Energenie Power Sockets';
export type TEnergeniePowerSocketsRawData = TSimpleLocalRawData;
export interface IEnergeniePowerSocketsSnapshot extends ISimpleLocalSnapshot {}
export interface IEnergeniePowerSocketsConfig extends ISimpleLocalConfig {
deviceApiId?: string;
numberOfSockets?: number;
}
export interface IHomeAssistantEnergeniePowerSocketsConfig extends IEnergeniePowerSocketsConfig {}
export const energeniePowerSocketsProfile: ISimpleLocalIntegrationProfile = {
domain: 'energenie_power_sockets',
displayName: 'Energenie Power Sockets',
manufacturer: 'Energenie',
model: 'PowerStrip USB',
defaultName: energeniePowerSocketsDefaultName,
defaultProtocol: 'local',
status: 'control-runtime',
platforms: [
'switch',
],
serviceDomains: [
'switch',
],
controlServices: [
'turn_on',
'turn_off',
'toggle',
],
discoverySources: [
'manual',
'usb',
'custom',
],
discoveryKeywords: [
'energenie',
'power sockets',
'powerstrip',
'pyegps',
'usb',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/energenie_power_sockets',
upstreamDomain: 'energenie_power_sockets',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'pyegps==0.2.5',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@gnumpi',
],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
'turn_on',
'turn_off',
'toggle',
],
platforms: [
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual local USB power-strip setup from raw pyegps-style snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
'executor-routed socket switch control when client.execute or commandExecutor is supplied',
],
explicitUnsupported: [
'claiming live socket switch success without injected client.execute or commandExecutor',
'direct pyegps USB access without an injected native client',
],
},
},
};
@@ -1,2 +1,6 @@
export * from './energenie_power_sockets.classes.client.js';
export * from './energenie_power_sockets.classes.configflow.js';
export * from './energenie_power_sockets.classes.integration.js';
export * from './energenie_power_sockets.discovery.js';
export * from './energenie_power_sockets.mapper.js';
export * from './energenie_power_sockets.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { Enigma2Mapper } from './enigma2.mapper.js';
import type { IEnigma2Config, IEnigma2Snapshot } from './enigma2.types.js';
import { enigma2Profile } from './enigma2.types.js';
export class Enigma2Client extends SimpleLocalClient<IEnigma2Config> {
constructor(private readonly configArg: IEnigma2Config) {
super(enigma2Profile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnigma2Snapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return Enigma2Mapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return Enigma2Mapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2Profile } from './enigma2.types.js';
export class Enigma2ConfigFlow extends SimpleLocalConfigFlow<IEnigma2Config> {
constructor() {
super(enigma2Profile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { Enigma2Client } from './enigma2.classes.client.js';
import { Enigma2ConfigFlow } from './enigma2.classes.configflow.js';
import { createEnigma2DiscoveryDescriptor } from './enigma2.discovery.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2Domain, enigma2Profile } from './enigma2.types.js';
export class Enigma2Integration extends SimpleLocalIntegration<IEnigma2Config> {
public readonly domain = enigma2Domain;
public readonly discoveryDescriptor = createEnigma2DiscoveryDescriptor();
public readonly configFlow = new Enigma2ConfigFlow();
export class HomeAssistantEnigma2Integration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "enigma2",
displayName: "Enigma2 (OpenWebif)",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/enigma2",
"upstreamDomain": "enigma2",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"openwebifpy==4.3.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@autinerd"
]
},
});
super(enigma2Profile);
}
public async setup(configArg: IEnigma2Config, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(enigma2Profile, new Enigma2Client(configArg));
}
}
export class HomeAssistantEnigma2Integration extends Enigma2Integration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { enigma2Profile } from './enigma2.types.js';
export const createEnigma2DiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enigma2Profile);
+138
View File
@@ -0,0 +1,138 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2DefaultName, enigma2DefaultPort, enigma2Profile } from './enigma2.types.js';
export class Enigma2Mapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnigma2Config>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: enigma2Profile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnigma2Config, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(enigma2Profile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(enigma2Profile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnigma2Config, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) {
return rawDataArg;
}
const status = recordValue(rawDataArg.status) || recordValue(rawDataArg.data) || rawDataArg;
const currentService = recordValue(status.currservice) || recordValue(status.currentService) || recordValue(status.current_service) || {};
const hasStatus = 'in_standby' in status || 'inStandby' in status || 'volume' in status || 'muted' in status || 'state' in status || Object.keys(currentService).length > 0;
if (!hasStatus) {
return rawDataArg;
}
const about = recordValue(rawDataArg.about) || rawDataArg;
const info = recordValue(about.info) || recordValue(rawDataArg.info) || about;
const iface = firstRecord(info.ifaces) || firstRecord(info.interfaces);
const host = configArg.host || stringValue(rawDataArg.host) || stringValue(info.host);
const port = configArg.port || numberValue(rawDataArg.port) || (host ? enigma2DefaultPort : undefined);
const mac = configArg.macAddress || configArg.mac_address || stringValue(rawDataArg.mac) || stringValue(rawDataArg.macAddress) || stringValue(iface?.mac);
const name = configArg.name || stringValue(rawDataArg.name) || stringValue(status.name) || enigma2DefaultName;
const inStandby = booleanValue(status.in_standby ?? status.inStandby ?? status.standby);
const mediaState = stringValue(status.state) || (inStandby === undefined ? 'unknown' : inStandby ? 'off' : 'on');
const station = stringValue(currentService.station);
const seriesTitle = stringValue(currentService.name);
const volume = numberValue(status.volume);
const entities: ISimpleLocalEntitySnapshot[] = [{
id: 'media_player',
uniqueId: `${enigma2Profile.domain}_${SimpleLocalMapper.slug(mac || host || name)}_media_player`,
name,
platform: 'media_player',
state: mediaState,
available: configArg.online ?? true,
writable: true,
attributes: {
mediaTitle: station || seriesTitle,
mediaSeriesTitle: seriesTitle,
mediaChannel: station,
mediaContentId: stringValue(currentService.serviceref) || stringValue(currentService.serviceRef),
mediaDescription: stringValue(currentService.fulldescription) || stringValue(currentService.description),
mediaStartTime: currentService.begin,
mediaEndTime: currentService.end,
mediaCurrentlyRecording: booleanValue(status.is_recording ?? status.isRecording),
isVolumeMuted: booleanValue(status.muted),
volumeLevel: volume === undefined ? undefined : volume / 100,
sourceList: arrayValue(status.source_list) || arrayValue(status.sourceList) || arrayValue(rawDataArg.source_list) || arrayValue(rawDataArg.sourceList),
sourceBouquet: configArg.sourceBouquet || configArg.source_bouquet,
useChannelIcon: configArg.useChannelIcon ?? configArg.use_channel_icon,
deepStandby: configArg.deepStandby ?? configArg.deep_standby,
},
}];
return {
device: {
id: configArg.uniqueId || mac || (host ? `${host}:${port || ''}` : undefined) || name,
name,
manufacturer: stringValue(info.brand) || enigma2Profile.manufacturer,
model: stringValue(info.model) || enigma2Profile.model,
serialNumber: mac,
host,
port,
protocol: configArg.useTls || configArg.ssl ? 'https' : enigma2Profile.defaultProtocol,
attributes: {
mac,
verifySsl: configArg.verifySsl ?? configArg.verify_ssl,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const firstRecord = (valueArg: unknown): Record<string, unknown> | undefined => Array.isArray(valueArg) ? valueArg.find(isRecord) : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['1', 'true', 'yes', 'on', 'standby'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off', 'active'].includes(value)) {
return false;
}
return undefined;
};
const arrayValue = (valueArg: unknown): unknown[] | undefined => Array.isArray(valueArg) ? valueArg : undefined;
+109 -3
View File
@@ -1,4 +1,110 @@
export interface IHomeAssistantEnigma2Config {
// TODO: replace with the TypeScript-native config for enigma2.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const enigma2Domain = 'enigma2';
export const enigma2DefaultName = 'Enigma2 Media Player';
export const enigma2DefaultPort = 80;
export type TEnigma2RawData = TSimpleLocalRawData;
export interface IEnigma2Snapshot extends ISimpleLocalSnapshot {}
export interface IEnigma2Config extends ISimpleLocalConfig {
ssl?: boolean;
verifySsl?: boolean;
verify_ssl?: boolean;
useChannelIcon?: boolean;
use_channel_icon?: boolean;
deepStandby?: boolean;
deep_standby?: boolean;
sourceBouquet?: string;
source_bouquet?: string;
macAddress?: string;
mac_address?: string;
}
export interface IHomeAssistantEnigma2Config extends IEnigma2Config {}
const enigma2ControlServices = [
'turn_on',
'turn_off',
'volume_set',
'volume_up',
'volume_down',
'volume_mute',
'media_stop',
'media_play',
'media_pause',
'media_next_track',
'media_previous_track',
'select_source',
];
export const enigma2Profile: ISimpleLocalIntegrationProfile = {
domain: 'enigma2',
displayName: 'Enigma2 (OpenWebif)',
manufacturer: 'Enigma2',
model: 'OpenWebif receiver',
defaultName: enigma2DefaultName,
defaultPort: enigma2DefaultPort,
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'media_player',
],
serviceDomains: [
'media_player',
],
controlServices: enigma2ControlServices,
discoverySources: [
'manual',
'http',
'custom',
],
discoveryKeywords: [
'enigma2',
'openwebif',
'dreambox',
'receiver',
'media player',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/enigma2',
upstreamDomain: 'enigma2',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'openwebifpy==4.3.1',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@autinerd',
],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...enigma2ControlServices,
],
platforms: [
'media_player',
],
controls: true,
},
localApi: {
implemented: [
'manual local OpenWebif host setup with optional credentials and TLS flags',
'OpenWebif about/status raw data mapping for media player snapshots',
'snapshot, rawData, snapshotProvider, and injected native client operation',
'commandExecutor-backed media player controls only when a real executor is injected',
],
explicitUnsupported: [
'claiming live media player command success without injected client.execute or commandExecutor',
'direct OpenWebif control calls from this package without an injected native client',
'automatic bouquet option discovery without an injected native client snapshotProvider',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './enigma2.classes.client.js';
export * from './enigma2.classes.configflow.js';
export * from './enigma2.classes.integration.js';
export * from './enigma2.discovery.js';
export * from './enigma2.mapper.js';
export * from './enigma2.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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnoceanMapper } from './enocean.mapper.js';
import type { IEnoceanConfig, IEnoceanSnapshot } from './enocean.types.js';
import { enoceanProfile } from './enocean.types.js';
export class EnoceanClient extends SimpleLocalClient<IEnoceanConfig> {
constructor(private readonly configArg: IEnoceanConfig) {
super(enoceanProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnoceanSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnoceanMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnoceanMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanProfile } from './enocean.types.js';
export class EnoceanConfigFlow extends SimpleLocalConfigFlow<IEnoceanConfig> {
constructor() {
super(enoceanProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnoceanClient } from './enocean.classes.client.js';
import { EnoceanConfigFlow } from './enocean.classes.configflow.js';
import { createEnoceanDiscoveryDescriptor } from './enocean.discovery.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanDomain, enoceanProfile } from './enocean.types.js';
export class EnoceanIntegration extends SimpleLocalIntegration<IEnoceanConfig> {
public readonly domain = enoceanDomain;
public readonly discoveryDescriptor = createEnoceanDiscoveryDescriptor();
public readonly configFlow = new EnoceanConfigFlow();
export class HomeAssistantEnoceanIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "enocean",
displayName: "EnOcean",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/enocean",
"upstreamDomain": "enocean",
"integrationType": "hub",
"iotClass": "local_push",
"requirements": [
"enocean-async==0.4.2"
],
"dependencies": [
"usb"
],
"afterDependencies": [],
"codeowners": []
},
});
super(enoceanProfile);
}
public async setup(configArg: IEnoceanConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(enoceanProfile, new EnoceanClient(configArg));
}
}
export class HomeAssistantEnoceanIntegration extends EnoceanIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { enoceanProfile } from './enocean.types.js';
export const createEnoceanDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enoceanProfile);
+232
View File
@@ -0,0 +1,232 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanDefaultName, enoceanProfile } from './enocean.types.js';
export class EnoceanMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnoceanConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: enoceanProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnoceanConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(enoceanProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(enoceanProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnoceanConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) {
return rawDataArg;
}
const gateway = recordValue(rawDataArg.gateway) || rawDataArg;
const records = recordsFrom(rawDataArg.telegrams ?? rawDataArg.devices ?? rawDataArg.sensors ?? rawDataArg.values);
if (!records.length) {
return rawDataArg;
}
const devicePath = configArg.device || stringValue(rawDataArg.device) || stringValue(gateway.device) || configArg.host;
const name = configArg.name || stringValue(rawDataArg.name) || stringValue(gateway.name) || enoceanDefaultName;
const entities = records.map((recordArg) => this.entityFromRecord(recordArg, configArg, devicePath || name)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg));
return {
device: {
id: configArg.uniqueId || devicePath || name,
name,
manufacturer: stringValue(gateway.manufacturer) || enoceanProfile.manufacturer,
model: stringValue(gateway.model) || enoceanProfile.model,
serialNumber: stringValue(gateway.serialNumber) || stringValue(gateway.serial_number),
host: configArg.host,
protocol: enoceanProfile.defaultProtocol,
attributes: {
device: devicePath,
baseAddress: addressValue(gateway.baseAddress ?? gateway.base_address),
signalReceiveMessage: 'enocean.receive_message',
signalSendMessage: 'enocean.send_message',
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
private static entityFromRecord(recordArg: Record<string, unknown>, configArg: IEnoceanConfig, uniqueBaseArg: string): ISimpleLocalEntitySnapshot | undefined {
const address = addressValue(recordArg.id ?? recordArg.dev_id ?? recordArg.deviceId ?? recordArg.address ?? recordArg.sender);
const id = stringValue(recordArg.entityId) || stringValue(recordArg.entity_id) || address || stringValue(recordArg.name);
if (!id) {
return undefined;
}
const deviceClass = stringValue(recordArg.deviceClass) || stringValue(recordArg.device_class) || stringValue(recordArg.type) || configArg.deviceClass || configArg.device_class;
const platform = platformValue(recordArg.platform) || platformFromDeviceClass(deviceClass, recordArg.state);
const state = stateValue(recordArg, deviceClass);
return {
id: `${platform}_${SimpleLocalMapper.slug(id)}`,
uniqueId: `${enoceanProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_${SimpleLocalMapper.slug(id)}`,
name: stringValue(recordArg.name) || titleCase(id),
platform,
state,
available: recordArg.available === undefined ? true : booleanValue(recordArg.available),
writable: platform === 'light' || platform === 'switch',
unit: unitForDeviceClass(deviceClass),
deviceClass: normalizedDeviceClass(deviceClass),
attributes: {
address,
channel: numberValue(recordArg.channel ?? configArg.channel),
eep: stringValue(recordArg.eep),
rorg: recordArg.rorg,
rawAction: recordArg.action,
which: recordArg.which,
onoff: recordArg.onoff,
},
};
}
}
const stateValue = (recordArg: Record<string, unknown>, deviceClassArg: string | undefined): unknown => {
const explicit = recordArg.state ?? recordArg.value ?? recordArg.native_value ?? recordArg.nativeValue;
const normalized = normalizedDeviceClass(deviceClassArg);
if (explicit !== undefined) {
if (['light', 'motion', 'opening', 'switch'].includes(normalized || '')) {
return booleanValue(explicit) ?? explicit;
}
return explicit;
}
const telegramData = Array.isArray(recordArg.telegram_data) ? recordArg.telegram_data : Array.isArray(recordArg.telegramData) ? recordArg.telegramData : undefined;
if (telegramData && normalized === 'temperature') {
return numberValue(telegramData[2]);
}
if (telegramData && normalized === 'humidity') {
const value = numberValue(telegramData[1]);
return value === undefined ? undefined : Math.round((value * 100 / 250) * 10) / 10;
}
if (telegramData && normalized === 'power') {
return numberValue(telegramData[0]);
}
return booleanValue(recordArg.is_on ?? recordArg.isOn ?? recordArg.on) ?? null;
};
const recordsFrom = (valueArg: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord);
}
if (isRecord(valueArg)) {
return Object.entries(valueArg).flatMap(([keyArg, entryArg]) => isRecord(entryArg) ? [{ id: keyArg, ...entryArg }] : []);
}
return [];
};
const platformValue = (valueArg: unknown): TEntityPlatform | undefined => {
const value = stringValue(valueArg);
return value && ['binary_sensor', 'light', 'sensor', 'switch'].includes(value) ? value as TEntityPlatform : undefined;
};
const platformFromDeviceClass = (deviceClassArg: string | undefined, stateArg: unknown): TEntityPlatform => {
const value = normalizedDeviceClass(deviceClassArg);
if (value === 'switch') {
return 'switch';
}
if (value === 'light') {
return 'light';
}
if (value === 'opening' || value === 'motion') {
return 'binary_sensor';
}
return typeof stateArg === 'boolean' ? 'binary_sensor' : 'sensor';
};
const normalizedDeviceClass = (valueArg: unknown): string | undefined => {
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
const map: Record<string, string> = {
humidity: 'humidity',
powersensor: 'power',
power: 'power',
temperature: 'temperature',
windowhandle: 'opening',
window_handle: 'opening',
switch: 'switch',
light: 'light',
contact: 'opening',
motion: 'motion',
};
return map[value] || value;
};
const unitForDeviceClass = (valueArg: unknown): string | undefined => {
const value = normalizedDeviceClass(valueArg);
if (value === 'temperature') {
return 'C';
}
if (value === 'humidity') {
return '%';
}
if (value === 'power') {
return 'W';
}
return undefined;
};
const addressValue = (valueArg: unknown): string | undefined => {
if (Array.isArray(valueArg)) {
return valueArg.map((partArg) => Number(partArg).toString(16).padStart(2, '0')).join('').toUpperCase();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg).toString(16).toUpperCase();
}
return stringValue(valueArg);
};
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['1', 'true', 'yes', 'on', 'open', 'opened', 'active'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off', 'closed', 'close', 'inactive'].includes(value)) {
return false;
}
return undefined;
};
const titleCase = (valueArg: string): string => valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase());
+110 -3
View File
@@ -1,4 +1,111 @@
export interface IHomeAssistantEnoceanConfig {
// TODO: replace with the TypeScript-native config for enocean.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const enoceanDomain = 'enocean';
export const enoceanDefaultName = 'EnOcean Gateway';
export type TEnoceanRawData = TSimpleLocalRawData;
export interface IEnoceanSnapshot extends ISimpleLocalSnapshot {}
export interface IEnoceanConfig extends ISimpleLocalConfig {
device?: string;
id?: number[];
senderId?: number[];
sender_id?: number[];
channel?: number;
deviceClass?: string;
device_class?: string;
minTemp?: number;
min_temp?: number;
maxTemp?: number;
max_temp?: number;
rangeFrom?: number;
range_from?: number;
rangeTo?: number;
range_to?: number;
}
export interface IHomeAssistantEnoceanConfig extends IEnoceanConfig {}
const enoceanControlServices = [
'turn_on',
'turn_off',
'toggle',
'set_level',
];
export const enoceanProfile: ISimpleLocalIntegrationProfile = {
domain: 'enocean',
displayName: 'EnOcean',
manufacturer: 'EnOcean',
model: 'USB 300 Gateway',
defaultName: enoceanDefaultName,
defaultProtocol: 'local',
status: 'control-runtime',
platforms: [
'binary_sensor',
'light',
'sensor',
'switch',
],
serviceDomains: [
'light',
'switch',
],
controlServices: enoceanControlServices,
discoverySources: [
'manual',
'usb',
'custom',
],
discoveryKeywords: [
'enocean',
'usb 300',
'erp1',
'dongle',
'gateway',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/enocean',
upstreamDomain: 'enocean',
integrationType: 'hub',
iotClass: 'local_push',
qualityScale: undefined,
requirements: [
'enocean-async==0.4.2',
],
dependencies: [
'usb',
],
afterDependencies: [],
codeowners: [],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...enoceanControlServices,
],
platforms: [
'binary_sensor',
'light',
'sensor',
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual USB dongle path setup and USB discovery record matching',
'EnOcean raw gateway/device/telegram snapshot mapping for sensors, binary sensors, switches, and lights',
'snapshot, rawData, snapshotProvider, and injected native client operation',
'commandExecutor-backed gateway sends only when a real executor is injected',
],
explicitUnsupported: [
'claiming live EnOcean send command success without injected client.execute or commandExecutor',
'opening and managing a serial Gateway event loop inside this package runtime',
'decoding every EEP beyond values represented in supplied snapshots/rawData',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './enocean.classes.client.js';
export * from './enocean.classes.configflow.js';
export * from './enocean.classes.integration.js';
export * from './enocean.discovery.js';
export * from './enocean.mapper.js';
export * from './enocean.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,23 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnphaseEnvoyMapper } from './enphase_envoy.mapper.js';
import type { IEnphaseEnvoyConfig, IEnphaseEnvoySnapshot } from './enphase_envoy.types.js';
import { enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyClient extends SimpleLocalClient<IEnphaseEnvoyConfig> {
private readonly configArg: IEnphaseEnvoyConfig;
constructor(configArg: IEnphaseEnvoyConfig) {
const runtimeConfig = configArg.rawData !== undefined || configArg.entities?.length || configArg.snapshot ? { ...configArg, host: undefined, path: undefined, transport: 'snapshot' as const } : configArg;
super(enphaseEnvoyProfile, runtimeConfig);
this.configArg = configArg;
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnphaseEnvoySnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnphaseEnvoyMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnphaseEnvoyMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js';
import { enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyConfigFlow extends SimpleLocalConfigFlow<IEnphaseEnvoyConfig> {
constructor() {
super(enphaseEnvoyProfile);
}
}
@@ -1,29 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnphaseEnvoyClient } from './enphase_envoy.classes.client.js';
import { EnphaseEnvoyConfigFlow } from './enphase_envoy.classes.configflow.js';
import { createEnphaseEnvoyDiscoveryDescriptor } from './enphase_envoy.discovery.js';
import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js';
import { enphaseEnvoyDomain, enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyIntegration extends SimpleLocalIntegration<IEnphaseEnvoyConfig> {
public readonly domain = enphaseEnvoyDomain;
public readonly discoveryDescriptor = createEnphaseEnvoyDiscoveryDescriptor();
public readonly configFlow = new EnphaseEnvoyConfigFlow();
export class HomeAssistantEnphaseEnvoyIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "enphase_envoy",
displayName: "Enphase Envoy",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/enphase_envoy",
"upstreamDomain": "enphase_envoy",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"pyenphase==2.4.8"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bdraco",
"@cgarwood",
"@catsmanac"
]
},
});
super(enphaseEnvoyProfile);
}
public async setup(configArg: IEnphaseEnvoyConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SimpleLocalRuntime(enphaseEnvoyProfile, new EnphaseEnvoyClient(configArg));
}
}
export class HomeAssistantEnphaseEnvoyIntegration extends EnphaseEnvoyIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { enphaseEnvoyProfile } from './enphase_envoy.types.js';
export const createEnphaseEnvoyDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enphaseEnvoyProfile);
@@ -0,0 +1,283 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js';
import { enphaseEnvoyDefaultName, enphaseEnvoyDefaultPort, enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnphaseEnvoyConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: enphaseEnvoyProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnphaseEnvoyConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(enphaseEnvoyProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(enphaseEnvoyProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnphaseEnvoyConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) {
return rawDataArg;
}
const envoyData = recordValue(rawDataArg.envoy_data) || recordValue(rawDataArg.envoyData) || recordValue(rawDataArg.data) || rawDataArg;
const hasEnvoyData = Boolean(envoyData.system_production || envoyData.systemProduction || envoyData.production || envoyData.inverters || envoyData.encharge_aggregate || envoyData.enpower || envoyData.dry_contact_status || envoyData.tariff);
if (!hasEnvoyData) {
return rawDataArg;
}
const host = configArg.host || stringValue(rawDataArg.host) || stringValue(envoyData.host);
const port = configArg.port || numberValue(rawDataArg.port) || (host ? enphaseEnvoyDefaultPort : undefined);
const serial = configArg.serial || configArg.uniqueId || stringValue(rawDataArg.serialNumber) || stringValue(rawDataArg.serial_number) || stringValue(envoyData.serialNumber) || stringValue(envoyData.serial_number);
const name = configArg.name || stringValue(rawDataArg.name) || stringValue(envoyData.name) || (serial ? `${enphaseEnvoyDefaultName} ${serial}` : enphaseEnvoyDefaultName);
const entities = this.entitiesFromEnvoyData(envoyData, serial || host || name);
return {
device: {
id: configArg.uniqueId || serial || (host ? `${host}:${port || ''}` : undefined) || name,
name,
manufacturer: enphaseEnvoyProfile.manufacturer,
model: stringValue(rawDataArg.envoyModel) || stringValue(rawDataArg.envoy_model) || stringValue(envoyData.envoyModel) || stringValue(envoyData.envoy_model) || enphaseEnvoyProfile.model,
serialNumber: serial,
host,
port,
protocol: configArg.useTls ? 'https' : enphaseEnvoyProfile.defaultProtocol,
attributes: {
firmware: stringValue(rawDataArg.firmware) || stringValue(envoyData.firmware),
partNumber: stringValue(rawDataArg.partNumber) || stringValue(rawDataArg.part_number) || stringValue(envoyData.partNumber) || stringValue(envoyData.part_number),
disableKeepAlive: configArg.disableKeepAlive ?? configArg.disable_keep_alive,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
private static entitiesFromEnvoyData(envoyDataArg: Record<string, unknown>, uniqueBaseArg: string): ISimpleLocalEntitySnapshot[] {
const entities: ISimpleLocalEntitySnapshot[] = [];
const production = recordValue(envoyDataArg.system_production) || recordValue(envoyDataArg.systemProduction) || recordValue(envoyDataArg.production);
const consumption = recordValue(envoyDataArg.system_consumption) || recordValue(envoyDataArg.systemConsumption) || recordValue(envoyDataArg.consumption);
const netConsumption = recordValue(envoyDataArg.system_net_consumption) || recordValue(envoyDataArg.systemNetConsumption) || recordValue(envoyDataArg.net_consumption);
const enchargeAggregate = recordValue(envoyDataArg.encharge_aggregate) || recordValue(envoyDataArg.enchargeAggregate) || recordValue(envoyDataArg.battery_aggregate) || recordValue(envoyDataArg.batteryAggregate);
const enpower = recordValue(envoyDataArg.enpower);
const tariff = recordValue(envoyDataArg.tariff);
const storageSettings = recordValue(tariff?.storage_settings) || recordValue(tariff?.storageSettings) || recordValue(envoyDataArg.storage_settings) || recordValue(envoyDataArg.storageSettings);
this.addSystemPowerEntities(entities, uniqueBaseArg, production, 'production', 'Production');
this.addSystemPowerEntities(entities, uniqueBaseArg, consumption, 'consumption', 'Consumption');
this.addSystemPowerEntities(entities, uniqueBaseArg, netConsumption, 'net_consumption', 'Net Consumption');
for (const inverter of recordsFrom(envoyDataArg.inverters)) {
const serial = stringValue(inverter.serialNumber) || stringValue(inverter.serial_number) || stringValue(inverter.id);
const watts = numberValue(inverter.last_report_watts ?? inverter.lastReportWatts ?? inverter.watts);
if (serial && watts !== undefined) {
entities.push(sensorEntity(uniqueBaseArg, `inverter_${serial}_power`, `Inverter ${serial} Power`, watts, 'W', 'power', 'measurement', { serialNumber: serial }));
}
}
const batteryLevel = numberValue(enchargeAggregate?.state_of_charge ?? enchargeAggregate?.stateOfCharge ?? enchargeAggregate?.soc);
if (batteryLevel !== undefined) {
entities.push(sensorEntity(uniqueBaseArg, 'battery_level', 'Battery Level', batteryLevel, '%', 'battery', 'measurement'));
}
const reserveSoc = numberValue(enchargeAggregate?.reserve_state_of_charge ?? enchargeAggregate?.reserveStateOfCharge ?? storageSettings?.reserved_soc ?? storageSettings?.reservedSoc);
if (reserveSoc !== undefined) {
entities.push({
...sensorEntity(uniqueBaseArg, 'reserve_soc', 'Reserve Battery Level', reserveSoc, '%', 'battery', 'measurement'),
platform: 'number',
writable: true,
});
}
const gridClosed = booleanFromClosed(enpower?.mains_oper_state ?? enpower?.mainsOperState ?? enpower?.grid_status ?? enpower?.gridStatus);
if (gridClosed !== undefined) {
entities.push({
id: 'grid_status',
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_grid_status`,
name: 'Grid Status',
platform: 'binary_sensor',
state: gridClosed,
available: true,
writable: false,
deviceClass: 'connectivity',
attributes: {
serialNumber: stringValue(enpower?.serial_number) || stringValue(enpower?.serialNumber),
rawState: enpower?.mains_oper_state ?? enpower?.mainsOperState,
},
});
}
const gridEnabled = booleanFromClosed(enpower?.mains_admin_state ?? enpower?.mainsAdminState);
if (gridEnabled !== undefined) {
entities.push({
id: 'grid_enabled',
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_grid_enabled`,
name: 'Grid Enabled',
platform: 'switch',
state: gridEnabled,
available: true,
writable: true,
attributes: {
serialNumber: stringValue(enpower?.serial_number) || stringValue(enpower?.serialNumber),
serviceDomain: 'switch',
},
});
}
for (const relay of recordsFrom(envoyDataArg.dry_contact_status ?? envoyDataArg.dryContactStatus)) {
const relayId = stringValue(relay.id) || stringValue(relay.relay_id) || stringValue(relay.relayId);
if (!relayId) {
continue;
}
entities.push({
id: `relay_${SimpleLocalMapper.slug(relayId)}`,
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_relay_${SimpleLocalMapper.slug(relayId)}`,
name: stringValue(relay.name) || `Relay ${relayId}`,
platform: 'switch',
state: booleanFromClosed(relay.status ?? relay.state),
available: true,
writable: true,
attributes: {
relayId,
},
});
}
const chargeFromGrid = booleanValue(storageSettings?.charge_from_grid ?? storageSettings?.chargeFromGrid);
if (chargeFromGrid !== undefined) {
entities.push({
id: 'charge_from_grid',
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_charge_from_grid`,
name: 'Charge From Grid',
platform: 'switch',
state: chargeFromGrid,
available: true,
writable: true,
});
}
const storageMode = stringValue(storageSettings?.mode ?? storageSettings?.storage_mode ?? storageSettings?.storageMode);
if (storageMode) {
entities.push({
id: 'storage_mode',
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_storage_mode`,
name: 'Storage Mode',
platform: 'select',
state: storageMode.toLowerCase(),
available: true,
writable: true,
attributes: {
options: ['backup', 'self_consumption', 'savings'],
},
});
}
return entities;
}
private static addSystemPowerEntities(entitiesArg: ISimpleLocalEntitySnapshot[], uniqueBaseArg: string, dataArg: Record<string, unknown> | undefined, keyArg: string, labelArg: string): void {
if (!dataArg) {
return;
}
const wattsNow = numberValue(dataArg.watts_now ?? dataArg.wattsNow ?? dataArg.current_power ?? dataArg.currentPower);
if (wattsNow !== undefined) {
entitiesArg.push(sensorEntity(uniqueBaseArg, keyArg, `Current Power ${labelArg}`, wattsNow, 'W', 'power', 'measurement'));
}
const today = numberValue(dataArg.watt_hours_today ?? dataArg.wattHoursToday ?? dataArg.energy_today ?? dataArg.energyToday);
if (today !== undefined) {
entitiesArg.push(sensorEntity(uniqueBaseArg, `daily_${keyArg}`, `${labelArg} Today`, today, 'Wh', 'energy', 'total_increasing'));
}
const lifetime = numberValue(dataArg.watt_hours_lifetime ?? dataArg.wattHoursLifetime ?? dataArg.lifetime_energy ?? dataArg.lifetimeEnergy);
if (lifetime !== undefined) {
entitiesArg.push(sensorEntity(uniqueBaseArg, `lifetime_${keyArg}`, `Lifetime ${labelArg}`, lifetime, 'Wh', 'energy', 'total_increasing'));
}
}
}
const sensorEntity = (uniqueBaseArg: string, idArg: string, nameArg: string, stateArg: unknown, unitArg?: string, deviceClassArg?: string, stateClassArg?: string, attributesArg?: Record<string, unknown>): ISimpleLocalEntitySnapshot => ({
id: SimpleLocalMapper.slug(idArg),
uniqueId: `${enphaseEnvoyProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_${SimpleLocalMapper.slug(idArg)}`,
name: nameArg,
platform: 'sensor',
state: stateArg,
available: true,
writable: false,
unit: unitArg,
deviceClass: deviceClassArg,
stateClass: stateClassArg,
attributes: attributesArg,
});
const recordsFrom = (valueArg: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord);
}
if (isRecord(valueArg)) {
return Object.entries(valueArg).flatMap(([keyArg, entryArg]) => isRecord(entryArg) ? [{ id: keyArg, ...entryArg }] : []);
}
return [];
};
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['1', 'true', 'yes', 'on', 'enabled', 'closed'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off', 'disabled', 'open'].includes(value)) {
return false;
}
return undefined;
};
const booleanFromClosed = (valueArg: unknown): boolean | undefined => {
const explicit = booleanValue(valueArg);
if (explicit !== undefined) {
return explicit;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['closed', 'close', 'on_grid', 'connected'].includes(value)) {
return true;
}
if (['open', 'opened', 'off_grid', 'disconnected'].includes(value)) {
return false;
}
return undefined;
};
@@ -1,4 +1,113 @@
export interface IHomeAssistantEnphaseEnvoyConfig {
// TODO: replace with the TypeScript-native config for enphase_envoy.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const enphaseEnvoyDomain = 'enphase_envoy';
export const enphaseEnvoyDefaultName = 'Envoy';
export const enphaseEnvoyDefaultPort = 80;
export type TEnphaseEnvoyRawData = TSimpleLocalRawData;
export interface IEnphaseEnvoySnapshot extends ISimpleLocalSnapshot {}
export interface IEnphaseEnvoyConfig extends ISimpleLocalConfig {
serial?: string;
diagnosticsIncludeFixtures?: boolean;
diagnostics_include_fixtures?: boolean;
disableKeepAlive?: boolean;
disable_keep_alive?: boolean;
}
export interface IHomeAssistantEnphaseEnvoyConfig extends IEnphaseEnvoyConfig {}
const enphaseEnvoyControlServices = [
'turn_on',
'turn_off',
'toggle',
'set_value',
'select_option',
];
export const enphaseEnvoyProfile: ISimpleLocalIntegrationProfile = {
domain: 'enphase_envoy',
displayName: 'Enphase Envoy',
manufacturer: 'Enphase',
model: 'Envoy',
defaultName: enphaseEnvoyDefaultName,
defaultPort: enphaseEnvoyDefaultPort,
defaultHttpPath: '/production.json?details=1',
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'binary_sensor',
'number',
'select',
'sensor',
'switch',
],
serviceDomains: [
'number',
'select',
'switch',
],
controlServices: enphaseEnvoyControlServices,
discoverySources: [
'manual',
'mdns',
'http',
'custom',
],
discoveryKeywords: [
'enphase',
'envoy',
'_enphase-envoy._tcp.local.',
'solar',
'inverter',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/enphase_envoy',
upstreamDomain: 'enphase_envoy',
integrationType: 'hub',
iotClass: 'local_polling',
qualityScale: 'platinum',
qualityScaleRulesPath: 'homeassistant/components/enphase_envoy/quality_scale.yaml',
requirements: [
'pyenphase==2.4.8',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@bdraco',
'@cgarwood',
'@catsmanac',
],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...enphaseEnvoyControlServices,
],
platforms: [
'binary_sensor',
'number',
'select',
'sensor',
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual and mDNS local Envoy host setup with optional credentials/token data',
'generic read-only HTTP production endpoint snapshots when config.path is reachable locally',
'pyenphase-style raw data mapping for production, consumption, inverters, batteries, grid, relays, and storage settings',
'snapshot, rawData, snapshotProvider, and injected native client operation',
'commandExecutor-backed switch, number, and select controls only when a real executor is injected',
],
explicitUnsupported: [
'claiming live grid, relay, reserve SOC, or storage mode command success without injected client.execute or commandExecutor',
'performing Enphase cloud authentication, token refresh, or firmware scheduling inside this package runtime',
'collecting Home Assistant diagnostic fixture endpoint bundles directly from this package runtime',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './enphase_envoy.classes.client.js';
export * from './enphase_envoy.classes.configflow.js';
export * from './enphase_envoy.classes.integration.js';
export * from './enphase_envoy.discovery.js';
export * from './enphase_envoy.mapper.js';
export * from './enphase_envoy.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,9 @@
import { SimpleLocalClient } from '../../core/index.js';
import type { IEnvisalinkConfig } from './envisalink.types.js';
import { envisalinkProfile } from './envisalink.types.js';
export class EnvisalinkClient extends SimpleLocalClient<IEnvisalinkConfig> {
constructor(configArg: IEnvisalinkConfig) {
super(envisalinkProfile, configArg);
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnvisalinkConfig } from './envisalink.types.js';
import { envisalinkProfile } from './envisalink.types.js';
export class EnvisalinkConfigFlow extends SimpleLocalConfigFlow<IEnvisalinkConfig> {
constructor() {
super(envisalinkProfile);
}
}
@@ -1,24 +1,17 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration } from '../../core/index.js';
import { EnvisalinkConfigFlow } from './envisalink.classes.configflow.js';
import { createEnvisalinkDiscoveryDescriptor } from './envisalink.discovery.js';
import type { IEnvisalinkConfig } from './envisalink.types.js';
import { envisalinkDomain, envisalinkProfile } from './envisalink.types.js';
export class EnvisalinkIntegration extends SimpleLocalIntegration<IEnvisalinkConfig> {
public readonly domain = envisalinkDomain;
public readonly discoveryDescriptor = createEnvisalinkDiscoveryDescriptor();
public readonly configFlow = new EnvisalinkConfigFlow();
export class HomeAssistantEnvisalinkIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "envisalink",
displayName: "Envisalink",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/envisalink",
"upstreamDomain": "envisalink",
"iotClass": "local_push",
"qualityScale": "legacy",
"requirements": [
"pyenvisalink==4.7"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": []
},
});
super(envisalinkProfile);
}
}
export class HomeAssistantEnvisalinkIntegration extends EnvisalinkIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { envisalinkProfile } from './envisalink.types.js';
export const createEnvisalinkDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(envisalinkProfile);
@@ -0,0 +1,26 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnvisalinkConfig } from './envisalink.types.js';
import { envisalinkProfile } from './envisalink.types.js';
export class EnvisalinkMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnvisalinkConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: envisalinkProfile });
}
public static toSnapshotFromRaw(configArg: IEnvisalinkConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ profile: envisalinkProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(envisalinkProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(envisalinkProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
}
+121 -3
View File
@@ -1,4 +1,122 @@
export interface IHomeAssistantEnvisalinkConfig {
// TODO: replace with the TypeScript-native config for envisalink.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const envisalinkDomain = 'envisalink';
export const envisalinkDefaultName = 'Envisalink';
export const envisalinkDefaultPort = 4025;
export type TEnvisalinkPanelType = 'HONEYWELL' | 'DSC' | string;
export type TEnvisalinkRawData = TSimpleLocalRawData;
export interface IEnvisalinkSnapshot extends ISimpleLocalSnapshot {}
export interface IEnvisalinkZoneConfig {
name: string;
type?: string;
}
export interface IEnvisalinkPartitionConfig {
name: string;
}
export interface IEnvisalinkConfig extends ISimpleLocalConfig {
panelType?: TEnvisalinkPanelType;
evlVersion?: number;
code?: string;
panicType?: string;
keepaliveInterval?: number;
zonedumpInterval?: number;
zones?: Record<string | number, IEnvisalinkZoneConfig>;
partitions?: Record<string | number, IEnvisalinkPartitionConfig>;
}
export interface IHomeAssistantEnvisalinkConfig extends IEnvisalinkConfig {}
const envisalinkControlServices = [
'alarm_keypress',
'invoke_custom_function',
'alarm_disarm',
'alarm_arm_home',
'alarm_arm_away',
'alarm_arm_night',
'alarm_trigger',
'turn_on',
'turn_off',
];
export const envisalinkProfile: ISimpleLocalIntegrationProfile = {
domain: envisalinkDomain,
displayName: 'Envisalink',
manufacturer: 'EyezOn',
model: 'Envisalink',
defaultName: envisalinkDefaultName,
defaultPort: envisalinkDefaultPort,
defaultProtocol: 'tcp',
status: 'control-runtime',
platforms: [
'binary_sensor',
'sensor',
'switch',
],
serviceDomains: [
'alarm_control_panel',
'switch',
'envisalink',
],
controlServices: envisalinkControlServices,
discoverySources: [
'manual',
'custom',
],
discoveryKeywords: [
'envisalink',
'evl',
'eyezon',
'honeywell',
'dsc',
'alarm',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/envisalink',
upstreamDomain: 'envisalink',
iotClass: 'local_push',
qualityScale: 'legacy',
requirements: [
'pyenvisalink==4.7',
],
dependencies: [],
afterDependencies: [],
codeowners: [],
configFlow: false,
documentation: 'https://www.home-assistant.io/integrations/envisalink',
haPlatforms: [
'alarm_control_panel',
'binary_sensor',
'sensor',
'switch',
],
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...envisalinkControlServices,
],
platforms: [
'alarm_control_panel',
'binary_sensor',
'sensor',
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual local endpoint setup for Envisalink host, port, panel metadata, snapshots, raw data, snapshotProvider, and injected native clients',
'TCP endpoint metadata for the documented Envisalink port without claiming protocol login support',
'executor-gated alarm, keypress, custom function, and bypass switch service dispatch',
],
explicitUnsupported: [
'claiming alarm or switch command success without injected client.execute or commandExecutor',
'full pyenvisalink login, callback, and keypad session emulation without an injected native client',
'YAML-only Home Assistant setup semantics beyond the local-first config flow wrapper',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './envisalink.classes.client.js';
export * from './envisalink.classes.configflow.js';
export * from './envisalink.classes.integration.js';
export * from './envisalink.discovery.js';
export * from './envisalink.mapper.js';
export * from './envisalink.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,9 @@
import { SimpleLocalClient } from '../../core/index.js';
import type { IEphemberConfig } from './ephember.types.js';
import { ephemberProfile } from './ephember.types.js';
export class EphemberClient extends SimpleLocalClient<IEphemberConfig> {
constructor(configArg: IEphemberConfig) {
super(ephemberProfile, configArg);
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEphemberConfig } from './ephember.types.js';
import { ephemberProfile } from './ephember.types.js';
export class EphemberConfigFlow extends SimpleLocalConfigFlow<IEphemberConfig> {
constructor() {
super(ephemberProfile);
}
}
@@ -1,27 +1,17 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration } from '../../core/index.js';
import { EphemberConfigFlow } from './ephember.classes.configflow.js';
import { createEphemberDiscoveryDescriptor } from './ephember.discovery.js';
import type { IEphemberConfig } from './ephember.types.js';
import { ephemberDomain, ephemberProfile } from './ephember.types.js';
export class EphemberIntegration extends SimpleLocalIntegration<IEphemberConfig> {
public readonly domain = ephemberDomain;
public readonly discoveryDescriptor = createEphemberDiscoveryDescriptor();
public readonly configFlow = new EphemberConfigFlow();
export class HomeAssistantEphemberIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "ephember",
displayName: "EPH Controls",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/ephember",
"upstreamDomain": "ephember",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"pyephember2==0.4.12"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@ttroy50",
"@roberty99"
]
},
});
super(ephemberProfile);
}
}
export class HomeAssistantEphemberIntegration extends EphemberIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { ephemberProfile } from './ephember.types.js';
export const createEphemberDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(ephemberProfile);
@@ -0,0 +1,26 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEphemberConfig } from './ephember.types.js';
import { ephemberProfile } from './ephember.types.js';
export class EphemberMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEphemberConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: ephemberProfile });
}
public static toSnapshotFromRaw(configArg: IEphemberConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ profile: ephemberProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(ephemberProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(ephemberProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
}
+92 -3
View File
@@ -1,4 +1,93 @@
export interface IHomeAssistantEphemberConfig {
// TODO: replace with the TypeScript-native config for ephember.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const ephemberDomain = 'ephember';
export const ephemberDefaultName = 'EPH Controls';
export type TEphemberRawData = TSimpleLocalRawData;
export interface IEphemberSnapshot extends ISimpleLocalSnapshot {}
export interface IEphemberConfig extends ISimpleLocalConfig {
homes?: Array<Record<string, unknown>>;
zoneIds?: Array<string | number>;
}
export interface IHomeAssistantEphemberConfig extends IEphemberConfig {}
const ephemberControlServices = [
'turn_on',
'turn_off',
'set_temperature',
'set_hvac_mode',
];
export const ephemberProfile: ISimpleLocalIntegrationProfile = {
domain: ephemberDomain,
displayName: 'EPH Controls',
manufacturer: 'EPH Controls',
model: 'Ember',
defaultName: ephemberDefaultName,
defaultProtocol: 'local',
status: 'control-runtime',
platforms: [
'climate',
],
serviceDomains: [
'climate',
],
controlServices: ephemberControlServices,
discoverySources: [
'manual',
'custom',
],
discoveryKeywords: [
'ephember',
'eph',
'ember',
'thermostat',
'pyephember2',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/ephember',
upstreamDomain: 'ephember',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: [
'pyephember2==0.4.12',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@ttroy50',
'@roberty99',
],
configFlow: false,
documentation: 'https://www.home-assistant.io/integrations/ephember',
haPlatforms: [
'climate',
],
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...ephemberControlServices,
],
platforms: [
'climate',
],
controls: true,
},
localApi: {
implemented: [
'manual EPH Controls setup with credentials or local data source metadata',
'snapshot, raw data, snapshotProvider, and injected native EphEmber client operation',
'executor-gated thermostat mode and target temperature service dispatch',
],
explicitUnsupported: [
'claiming thermostat command success without injected client.execute or commandExecutor',
'direct pyephember2 account session operation without an injected native client',
'background polling of the upstream API without a supplied snapshotProvider or client',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './ephember.classes.client.js';
export * from './ephember.classes.configflow.js';
export * from './ephember.classes.integration.js';
export * from './ephember.discovery.js';
export * from './ephember.mapper.js';
export * from './ephember.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,9 @@
import { SimpleLocalClient } from '../../core/index.js';
import type { IEpsonConfig } from './epson.types.js';
import { epsonProfile } from './epson.types.js';
export class EpsonClient extends SimpleLocalClient<IEpsonConfig> {
constructor(configArg: IEpsonConfig) {
super(epsonProfile, configArg);
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEpsonConfig } from './epson.types.js';
import { epsonProfile } from './epson.types.js';
export class EpsonConfigFlow extends SimpleLocalConfigFlow<IEpsonConfig> {
constructor() {
super(epsonProfile);
}
}
@@ -1,26 +1,17 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
import { SimpleLocalIntegration } from '../../core/index.js';
import { EpsonConfigFlow } from './epson.classes.configflow.js';
import { createEpsonDiscoveryDescriptor } from './epson.discovery.js';
import type { IEpsonConfig } from './epson.types.js';
import { epsonDomain, epsonProfile } from './epson.types.js';
export class EpsonIntegration extends SimpleLocalIntegration<IEpsonConfig> {
public readonly domain = epsonDomain;
public readonly discoveryDescriptor = createEpsonDiscoveryDescriptor();
public readonly configFlow = new EpsonConfigFlow();
export class HomeAssistantEpsonIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "epson",
displayName: "Epson",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/epson",
"upstreamDomain": "epson",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"epson-projector==0.6.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@pszafer"
]
},
});
super(epsonProfile);
}
}
export class HomeAssistantEpsonIntegration extends EpsonIntegration {}
+4
View File
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { epsonProfile } from './epson.types.js';
export const createEpsonDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(epsonProfile);
+26
View File
@@ -0,0 +1,26 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEpsonConfig } from './epson.types.js';
import { epsonProfile } from './epson.types.js';
export class EpsonMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEpsonConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ ...optionsArg, profile: epsonProfile });
}
public static toSnapshotFromRaw(configArg: IEpsonConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({ profile: epsonProfile, config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(epsonProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(epsonProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
}
+108 -3
View File
@@ -1,4 +1,109 @@
export interface IHomeAssistantEpsonConfig {
// TODO: replace with the TypeScript-native config for epson.
[key: string]: unknown;
import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
export const epsonDomain = 'epson';
export const epsonDefaultName = 'Epson Projector';
export type TEpsonConnectionType = 'http' | 'serial' | string;
export type TEpsonRawData = TSimpleLocalRawData;
export interface IEpsonSnapshot extends ISimpleLocalSnapshot {}
export interface IEpsonConfig extends ISimpleLocalConfig {
connectionType?: TEpsonConnectionType;
serialNumber?: string;
source?: string;
cmode?: string;
}
export interface IHomeAssistantEpsonConfig extends IEpsonConfig {}
const epsonControlServices = [
'turn_on',
'turn_off',
'select_source',
'volume_mute',
'volume_up',
'volume_down',
'media_play',
'media_pause',
'media_next_track',
'media_previous_track',
'select_cmode',
];
export const epsonProfile: ISimpleLocalIntegrationProfile = {
domain: epsonDomain,
displayName: 'Epson',
manufacturer: 'Epson',
model: 'Projector',
defaultName: epsonDefaultName,
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'media_player',
],
serviceDomains: [
'media_player',
'epson',
],
controlServices: epsonControlServices,
discoverySources: [
'manual',
'http',
'custom',
],
discoveryKeywords: [
'epson',
'projector',
'epson_projector',
'emp',
'serial',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/epson',
upstreamDomain: 'epson',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'epson-projector==0.6.0',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@pszafer',
],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/epson',
haPlatforms: [
'media_player',
],
connectionTypes: [
'http',
'serial',
],
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...epsonControlServices,
],
platforms: [
'media_player',
],
controls: true,
},
localApi: {
implemented: [
'manual projector endpoint setup for host or serial path metadata',
'snapshot, raw data, snapshotProvider, and injected native epson-projector client operation',
'executor-gated media player and Epson color mode service dispatch',
],
explicitUnsupported: [
'claiming projector command success without injected client.execute or commandExecutor',
'direct epson-projector HTTP or serial protocol operation without an injected native client',
'initial powered-on projector validation without a supplied native client',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './epson.classes.client.js';
export * from './epson.classes.configflow.js';
export * from './epson.classes.integration.js';
export * from './epson.discovery.js';
export * from './epson.mapper.js';
export * from './epson.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,9 @@
import { SimpleLocalClient } from '../../core/index.js';
import type { IEq3btsmartConfig } from './eq3btsmart.types.js';
import { eq3btsmartProfile } from './eq3btsmart.types.js';
export class Eq3btsmartClient extends SimpleLocalClient<IEq3btsmartConfig> {
constructor(configArg: IEq3btsmartConfig) {
super(eq3btsmartProfile, configArg);
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEq3btsmartConfig } from './eq3btsmart.types.js';
import { eq3btsmartProfile } from './eq3btsmart.types.js';
export class Eq3btsmartConfigFlow extends SimpleLocalConfigFlow<IEq3btsmartConfig> {
constructor() {
super(eq3btsmartProfile);
}
}

Some files were not shown because too many files have changed in this diff Show More