Add TypeScript integrations package

This commit is contained in:
2026-05-05 12:01:30 +00:00
commit e91176fb9b
5889 changed files with 53433 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import type { DiscoveryDescriptor } from './classes.discoverydescriptor.js';
import type { IIntegrationRuntime, IIntegrationSetupContext, TIntegrationStatus } from './types.js';
export abstract class BaseIntegration<TConfig = unknown> {
public abstract readonly domain: string;
public abstract readonly displayName: string;
public abstract readonly status: TIntegrationStatus;
public abstract readonly discoveryDescriptor: DiscoveryDescriptor;
public abstract setup(
configArg: TConfig,
contextArg: IIntegrationSetupContext
): Promise<IIntegrationRuntime>;
public abstract destroy(): Promise<void>;
}
+42
View File
@@ -0,0 +1,42 @@
import * as plugins from '../plugins.js';
import type { IConfigStore } from './types.js';
export class JsonFileConfigStore implements IConfigStore {
constructor(private readonly filePath: string) {}
public async get<TValue>(keyArg: string): Promise<TValue | undefined> {
const data = await this.readFile();
return data[keyArg] as TValue | undefined;
}
public async set<TValue>(keyArg: string, valueArg: TValue): Promise<void> {
const data = await this.readFile();
data[keyArg] = valueArg;
await this.writeFile(data);
}
public async delete(keyArg: string): Promise<void> {
const data = await this.readFile();
delete data[keyArg];
await this.writeFile(data);
}
public async list(prefixArg = ''): Promise<string[]> {
const data = await this.readFile();
return Object.keys(data).filter((keyArg) => keyArg.startsWith(prefixArg));
}
private async readFile(): Promise<Record<string, unknown>> {
try {
const fileString = await plugins.fs.readFile(this.filePath, 'utf8');
return JSON.parse(fileString) as Record<string, unknown>;
} catch (error) {
return {};
}
}
private async writeFile(dataArg: Record<string, unknown>): Promise<void> {
await plugins.fs.mkdir(plugins.path.dirname(this.filePath), { recursive: true });
await plugins.fs.writeFile(this.filePath, `${JSON.stringify(dataArg, null, 2)}\n`);
}
}
@@ -0,0 +1,42 @@
import { BaseIntegration } from './classes.baseintegration.js';
import { DiscoveryDescriptor } from './classes.discoverydescriptor.js';
import { IntegrationError } from './errors.js';
import type { IIntegrationRuntime, IIntegrationSetupContext, TIntegrationStatus } from './types.js';
export interface IDescriptorOnlyIntegrationOptions {
domain: string;
displayName: string;
status?: TIntegrationStatus;
metadata?: Record<string, unknown>;
}
export class DescriptorOnlyIntegration extends BaseIntegration<unknown> {
public readonly domain: string;
public readonly displayName: string;
public readonly status: TIntegrationStatus;
public readonly discoveryDescriptor: DiscoveryDescriptor;
public readonly metadata: Record<string, unknown>;
constructor(optionsArg: IDescriptorOnlyIntegrationOptions) {
super();
this.domain = optionsArg.domain;
this.displayName = optionsArg.displayName;
this.status = optionsArg.status || 'descriptor-only';
this.metadata = optionsArg.metadata || {};
this.discoveryDescriptor = new DiscoveryDescriptor({
integrationDomain: this.domain,
displayName: this.displayName,
});
}
public async setup(configArg: unknown, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void configArg;
void contextArg;
throw new IntegrationError(
`Integration ${this.domain} is descriptor-only and has no TypeScript runtime yet.`,
'DESCRIPTOR_ONLY_INTEGRATION'
);
}
public async destroy(): Promise<void> {}
}
+47
View File
@@ -0,0 +1,47 @@
import type {
IDiscoveryDescriptorOptions,
IDiscoveryMatcher,
IDiscoveryProbe,
IDiscoveryValidator,
} from './types.js';
export class DiscoveryDescriptor {
public readonly integrationDomain: string;
public readonly displayName: string;
private readonly probes: IDiscoveryProbe[] = [];
private readonly matchers: IDiscoveryMatcher[] = [];
private readonly validators: IDiscoveryValidator[] = [];
constructor(optionsArg: IDiscoveryDescriptorOptions) {
this.integrationDomain = optionsArg.integrationDomain;
this.displayName = optionsArg.displayName;
}
public addProbe(probeArg: IDiscoveryProbe): this {
this.probes.push(probeArg);
return this;
}
public addMatcher<TInput>(matcherArg: IDiscoveryMatcher<TInput>): this {
this.matchers.push(matcherArg as IDiscoveryMatcher);
return this;
}
public addValidator(validatorArg: IDiscoveryValidator): this {
this.validators.push(validatorArg);
return this;
}
public getProbes(): IDiscoveryProbe[] {
return [...this.probes];
}
public getMatchers(): IDiscoveryMatcher[] {
return [...this.matchers];
}
public getValidators(): IDiscoveryValidator[] {
return [...this.validators];
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { IntegrationRegistry } from './classes.integrationregistry.js';
import type {
IDiscoveryCandidate,
IDiscoveryContext,
IDiscoveryMatch,
TDiscoverySource,
} from './types.js';
export class DiscoveryEngine {
constructor(private readonly integrationRegistry: IntegrationRegistry) {}
public async runActiveDiscovery(contextArg: IDiscoveryContext = {}): Promise<IDiscoveryCandidate[]> {
const candidates: IDiscoveryCandidate[] = [];
for (const integration of this.integrationRegistry.list()) {
const descriptor = integration.discoveryDescriptor;
for (const probe of descriptor.getProbes()) {
const result = await probe.probe(contextArg);
for (const candidate of result.candidates) {
candidates.push({
...candidate,
integrationDomain: candidate.integrationDomain ?? integration.domain,
});
}
}
}
return candidates;
}
public async matchExistingData<TInput>(
sourceArg: TDiscoverySource,
inputArg: TInput,
contextArg: IDiscoveryContext = {}
): Promise<IDiscoveryMatch[]> {
const matches: IDiscoveryMatch[] = [];
for (const integration of this.integrationRegistry.list()) {
const descriptor = integration.discoveryDescriptor;
for (const matcher of descriptor.getMatchers()) {
if (matcher.source !== sourceArg) {
continue;
}
const result = await matcher.matches(inputArg, contextArg);
if (result.matched) {
matches.push(result);
}
}
}
return matches;
}
public async validateCandidate(
candidateArg: IDiscoveryCandidate,
contextArg: IDiscoveryContext = {}
): Promise<IDiscoveryMatch[]> {
const matches: IDiscoveryMatch[] = [];
const integrations = candidateArg.integrationDomain
? this.integrationRegistry.list().filter((integrationArg) => integrationArg.domain === candidateArg.integrationDomain)
: this.integrationRegistry.list();
for (const integration of integrations) {
for (const validator of integration.discoveryDescriptor.getValidators()) {
const result = await validator.validate(candidateArg, contextArg);
if (result.matched) {
matches.push(result);
}
}
}
return matches;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { BaseIntegration } from './classes.baseintegration.js';
import type { DiscoveryDescriptor } from './classes.discoverydescriptor.js';
export class IntegrationRegistry {
private readonly integrations = new Map<string, BaseIntegration>();
public register(integrationArg: BaseIntegration): void {
if (this.integrations.has(integrationArg.domain)) {
throw new Error(`Integration already registered: ${integrationArg.domain}`);
}
this.integrations.set(integrationArg.domain, integrationArg);
}
public get(domainArg: string): BaseIntegration | undefined {
return this.integrations.get(domainArg);
}
public list(): BaseIntegration[] {
return [...this.integrations.values()];
}
public getDiscoveryDescriptors(): DiscoveryDescriptor[] {
return this.list().map((integrationArg) => integrationArg.discoveryDescriptor);
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { ILogger } from './types.js';
export class ConsoleLogger implements ILogger {
public log(levelArg: 'debug' | 'info' | 'warn' | 'error', messageArg: string, metadataArg?: Record<string, unknown>): void {
const payload = metadataArg ? ` ${JSON.stringify(metadataArg)}` : '';
console[levelArg === 'debug' ? 'log' : levelArg](`[${levelArg}] ${messageArg}${payload}`);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { BaseIntegration } from './classes.baseintegration.js';
import type { IIntegrationRuntime, IIntegrationSetupContext } from './types.js';
export class IntegrationRuntimeManager {
private readonly runtimes = new Map<string, IIntegrationRuntime>();
public async setupIntegration<TConfig>(
integrationArg: BaseIntegration<TConfig>,
configArg: TConfig,
contextArg: IIntegrationSetupContext = {}
): Promise<IIntegrationRuntime> {
const runtime = await integrationArg.setup(configArg, contextArg);
this.runtimes.set(integrationArg.domain, runtime);
return runtime;
}
public getRuntime(domainArg: string): IIntegrationRuntime | undefined {
return this.runtimes.get(domainArg);
}
public listRuntimes(): IIntegrationRuntime[] {
return [...this.runtimes.values()];
}
public async destroyAll(): Promise<void> {
for (const runtime of this.runtimes.values()) {
await runtime.destroy();
}
this.runtimes.clear();
}
}
+27
View File
@@ -0,0 +1,27 @@
export class IntegrationError extends Error {
constructor(
messageArg: string,
public readonly code: string,
public readonly cause?: unknown
) {
super(messageArg);
}
}
export class DiscoveryError extends IntegrationError {
constructor(messageArg: string, causeArg?: unknown) {
super(messageArg, 'DISCOVERY_ERROR', causeArg);
}
}
export class AuthenticationError extends IntegrationError {
constructor(messageArg: string, causeArg?: unknown) {
super(messageArg, 'AUTHENTICATION_ERROR', causeArg);
}
}
export class DeviceCommunicationError extends IntegrationError {
constructor(messageArg: string, causeArg?: unknown) {
super(messageArg, 'DEVICE_COMMUNICATION_ERROR', causeArg);
}
}
+10
View File
@@ -0,0 +1,10 @@
export * from './classes.baseintegration.js';
export * from './classes.configstore.js';
export * from './classes.descriptoronlyintegration.js';
export * from './classes.discoverydescriptor.js';
export * from './classes.discoveryengine.js';
export * from './classes.integrationregistry.js';
export * from './classes.logger.js';
export * from './classes.runtimemanager.js';
export * from './errors.js';
export * from './types.js';
+208
View File
@@ -0,0 +1,208 @@
import * as plugins from '../plugins.js';
export type TDiscoverySource =
| 'mdns'
| 'ssdp'
| 'dhcp'
| 'bluetooth'
| 'usb'
| 'mqtt'
| 'http'
| 'manual'
| 'broker'
| 'cloud'
| 'custom';
export type TDiscoveryConfidence = 'low' | 'medium' | 'high' | 'certain';
export type TIntegrationStatus =
| 'descriptor-only'
| 'discovery-supported'
| 'config-flow-supported'
| 'read-only-runtime'
| 'control-runtime'
| 'production-ready';
export interface ILogger {
log(levelArg: 'debug' | 'info' | 'warn' | 'error', messageArg: string, metadataArg?: Record<string, unknown>): void;
}
export interface INetworkInterface {
name: string;
address: string;
family: 'IPv4' | 'IPv6';
internal: boolean;
}
export interface IConfigStore {
get<TValue>(keyArg: string): Promise<TValue | undefined>;
set<TValue>(keyArg: string, valueArg: TValue): Promise<void>;
delete(keyArg: string): Promise<void>;
list(prefixArg?: string): Promise<string[]>;
}
export interface IDiscoveryContext {
abortSignal?: AbortSignal;
logger?: ILogger;
networkInterfaces?: INetworkInterface[];
configStore?: IConfigStore;
}
export interface IDiscoveryCandidate {
source: TDiscoverySource;
integrationDomain?: string;
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
export interface IDiscoveryProbeResult {
candidates: IDiscoveryCandidate[];
}
export interface IDiscoveryMatch {
matched: boolean;
confidence: TDiscoveryConfidence;
reason: string;
candidate?: IDiscoveryCandidate;
normalizedDeviceId?: string;
metadata?: Record<string, unknown>;
}
export interface IDiscoveryProbe {
id: string;
source: TDiscoverySource;
description?: string;
probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult>;
}
export interface IDiscoveryMatcher<TInput = unknown> {
id: string;
source: TDiscoverySource;
description?: string;
matches(inputArg: TInput, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch>;
}
export interface IDiscoveryValidator {
id: string;
description?: string;
validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch>;
}
export interface IDiscoveryDescriptorOptions {
integrationDomain: string;
displayName: string;
}
export interface IIntegrationSetupContext {
logger?: ILogger;
configStore?: IConfigStore;
abortSignal?: AbortSignal;
}
export type TEntityPlatform =
| 'light'
| 'switch'
| 'sensor'
| 'binary_sensor'
| 'button'
| 'climate'
| 'cover'
| 'fan'
| 'number'
| 'select'
| 'text'
| 'update';
export interface IIntegrationEntity<TState = unknown> {
id: string;
uniqueId: string;
integrationDomain: string;
deviceId: string;
platform: TEntityPlatform;
name: string;
state: TState;
attributes?: Record<string, unknown>;
available: boolean;
}
export interface IServiceCallRequest {
domain: string;
service: string;
target: {
entityId?: string;
deviceId?: string;
};
data?: Record<string, unknown>;
}
export interface IServiceCallResult {
success: boolean;
error?: string;
data?: unknown;
}
export type TIntegrationEventType =
| 'device_added'
| 'device_removed'
| 'entity_added'
| 'entity_removed'
| 'state_changed'
| 'availability_changed'
| 'error';
export interface IIntegrationEvent {
type: TIntegrationEventType;
integrationDomain: string;
deviceId?: string;
entityId?: string;
data?: unknown;
timestamp: number;
}
export interface IIntegrationRuntime {
domain: string;
devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]>;
entities(): Promise<IIntegrationEntity[]>;
subscribe?(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>>;
callService?(requestArg: IServiceCallRequest): Promise<IServiceCallResult>;
destroy(): Promise<void>;
}
export type TConfigFlowStepKind = 'form' | 'wait' | 'done' | 'error';
export interface IConfigFlowField {
name: string;
label: string;
type: 'text' | 'password' | 'number' | 'boolean' | 'select';
required?: boolean;
options?: Array<{
label: string;
value: string;
}>;
}
export interface IConfigFlowContext {
logger?: ILogger;
configStore?: IConfigStore;
}
export interface IConfigFlowStep<TConfig = unknown> {
kind: TConfigFlowStepKind;
title?: string;
description?: string;
fields?: IConfigFlowField[];
submit?(valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<TConfig>>;
config?: TConfig;
error?: string;
}
export interface IConfigFlow<TConfig = unknown> {
start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<TConfig>>;
}