Add TypeScript integrations package
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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> {}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>>;
|
||||
}
|
||||
Reference in New Issue
Block a user