Add native local edge service integrations
This commit is contained in:
+60
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-22
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
|
||||
import { epsonProfile } from './epson.types.js';
|
||||
|
||||
export const createEpsonDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(epsonProfile);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user