228 lines
9.0 KiB
TypeScript
228 lines
9.0 KiB
TypeScript
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
|
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
|
import { NanoleafClient } from './nanoleaf.classes.client.js';
|
|
import { NanoleafConfigFlow } from './nanoleaf.classes.configflow.js';
|
|
import { createNanoleafDiscoveryDescriptor } from './nanoleaf.discovery.js';
|
|
import { NanoleafMapper } from './nanoleaf.mapper.js';
|
|
import type { INanoleafConfig } from './nanoleaf.types.js';
|
|
|
|
export class NanoleafIntegration extends BaseIntegration<INanoleafConfig> {
|
|
public readonly domain = 'nanoleaf';
|
|
public readonly displayName = 'Nanoleaf';
|
|
public readonly status = 'control-runtime' as const;
|
|
public readonly discoveryDescriptor = createNanoleafDiscoveryDescriptor();
|
|
public readonly configFlow = new NanoleafConfigFlow();
|
|
public readonly metadata = {
|
|
source: 'home-assistant/core',
|
|
upstreamPath: 'homeassistant/components/nanoleaf',
|
|
upstreamDomain: 'nanoleaf',
|
|
integrationType: 'device',
|
|
iotClass: 'local_push',
|
|
documentation: 'https://www.home-assistant.io/integrations/nanoleaf',
|
|
requirements: ['aionanoleaf2==1.0.2'],
|
|
codeowners: ['@milanmeu', '@joostlek', '@loebi-ch', '@JaspervRijbroek', '@jonathanrobichaud4'],
|
|
zeroconf: ['_nanoleafms._tcp.local.', '_nanoleafapi._tcp.local.'],
|
|
ssdp: ['Nanoleaf_aurora:light', 'nanoleaf:nl29', 'nanoleaf:nl42', 'nanoleaf:nl52', 'nanoleaf:nl69', 'inanoleaf:nl81'],
|
|
homekitModels: ['NL29', 'NL42', 'NL47', 'NL48', 'NL52', 'NL59', 'NL69', 'NL81'],
|
|
};
|
|
|
|
public async setup(configArg: INanoleafConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
void contextArg;
|
|
return new NanoleafRuntime(new NanoleafClient(configArg));
|
|
}
|
|
|
|
public async destroy(): Promise<void> {}
|
|
}
|
|
|
|
export class HomeAssistantNanoleafIntegration extends NanoleafIntegration {}
|
|
|
|
class NanoleafRuntime implements IIntegrationRuntime {
|
|
public domain = 'nanoleaf';
|
|
|
|
constructor(private readonly client: NanoleafClient) {}
|
|
|
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
|
return NanoleafMapper.toDevices(await this.client.getSnapshot());
|
|
}
|
|
|
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
return NanoleafMapper.toEntities(await this.client.getSnapshot());
|
|
}
|
|
|
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
try {
|
|
if (requestArg.domain === 'nanoleaf' && requestArg.service === 'create_auth_token') {
|
|
const result = await this.client.createAuthToken();
|
|
return { success: result.success, error: result.error, data: result };
|
|
}
|
|
|
|
if (requestArg.domain === 'button') {
|
|
if (requestArg.service === 'press') {
|
|
await this.client.identify();
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: `Unsupported Nanoleaf button service: ${requestArg.service}` };
|
|
}
|
|
|
|
if (requestArg.domain === 'select') {
|
|
return this.handleSelectEffect(requestArg);
|
|
}
|
|
|
|
if (requestArg.domain === 'number') {
|
|
return this.handleNumberService(requestArg);
|
|
}
|
|
|
|
if (requestArg.domain !== 'light') {
|
|
return { success: false, error: `Unsupported Nanoleaf service domain: ${requestArg.domain}` };
|
|
}
|
|
|
|
if (requestArg.service === 'turn_on') {
|
|
return this.handleTurnOn(requestArg);
|
|
}
|
|
if (requestArg.service === 'turn_off') {
|
|
await this.client.turnOff(this.numberValue(requestArg.data, 'transition'));
|
|
return { success: true };
|
|
}
|
|
if (requestArg.service === 'set_value') {
|
|
return this.handleSetValue(requestArg);
|
|
}
|
|
if (requestArg.service === 'set_percentage' || requestArg.service === 'set_brightness') {
|
|
return this.handleSetBrightness(requestArg);
|
|
}
|
|
if (requestArg.service === 'select_effect') {
|
|
return this.handleSelectEffect(requestArg);
|
|
}
|
|
|
|
return { success: false, error: `Unsupported Nanoleaf light service: ${requestArg.service}` };
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
public async destroy(): Promise<void> {
|
|
await this.client.destroy();
|
|
}
|
|
|
|
private async handleTurnOn(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
const effect = this.stringValue(requestArg.data, 'effect', 'option');
|
|
if (effect) {
|
|
await this.client.setEffect(effect);
|
|
}
|
|
|
|
const hsColor = requestArg.data?.hs_color;
|
|
if (Array.isArray(hsColor) && typeof hsColor[0] === 'number' && typeof hsColor[1] === 'number') {
|
|
await this.client.setHue(hsColor[0]);
|
|
await this.client.setSaturation(hsColor[1]);
|
|
}
|
|
|
|
const colorTemperature = this.numberValue(requestArg.data, 'color_temp_kelvin', 'kelvin', 'ct');
|
|
if (colorTemperature !== undefined) {
|
|
await this.client.setColorTemperature(colorTemperature);
|
|
}
|
|
|
|
await this.client.turnOn();
|
|
|
|
const brightness = this.brightnessPercent(requestArg.data);
|
|
if (brightness !== undefined) {
|
|
await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition'));
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
private async handleNumberService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
if (requestArg.service !== 'set_value' && requestArg.service !== 'set_percentage' && requestArg.service !== 'set_brightness') {
|
|
return { success: false, error: `Unsupported Nanoleaf number service: ${requestArg.service}` };
|
|
}
|
|
return requestArg.service === 'set_value' ? this.handleSetValue(requestArg) : this.handleSetBrightness(requestArg);
|
|
}
|
|
|
|
private async handleSetValue(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
const value = this.numberValue(requestArg.data, 'value', 'brightness', 'percentage');
|
|
if (value === undefined) {
|
|
return { success: false, error: 'Nanoleaf set_value requires data.value.' };
|
|
}
|
|
|
|
const target = `${requestArg.target.entityId ?? ''} ${this.stringValue(requestArg.data, 'attribute', 'feature') ?? ''}`.toLowerCase();
|
|
if (target.includes('hue')) {
|
|
await this.client.setHue(value);
|
|
return { success: true };
|
|
}
|
|
if (target.includes('saturation') || target.includes('sat')) {
|
|
await this.client.setSaturation(value);
|
|
return { success: true };
|
|
}
|
|
if (target.includes('temperature') || target.includes('color_temp') || target.includes('ct')) {
|
|
await this.client.setColorTemperature(value);
|
|
return { success: true };
|
|
}
|
|
|
|
await this.client.setBrightness(this.brightnessPercent(requestArg.data) ?? value, this.numberValue(requestArg.data, 'transition'));
|
|
return { success: true };
|
|
}
|
|
|
|
private async handleSetBrightness(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
const brightness = this.brightnessPercent(requestArg.data);
|
|
if (brightness === undefined) {
|
|
return { success: false, error: 'Nanoleaf brightness service requires data.percentage, data.brightness, or data.value.' };
|
|
}
|
|
await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition'));
|
|
return { success: true };
|
|
}
|
|
|
|
private async handleSelectEffect(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
const effect = this.stringValue(requestArg.data, 'effect', 'option', 'value');
|
|
if (!effect) {
|
|
return { success: false, error: 'Nanoleaf select_effect requires data.effect or data.option.' };
|
|
}
|
|
await this.client.setEffect(effect);
|
|
return { success: true };
|
|
}
|
|
|
|
private brightnessPercent(dataArg: Record<string, unknown> | undefined): number | undefined {
|
|
const percentage = this.numberValue(dataArg, 'brightness_pct', 'percentage', 'percent', 'value');
|
|
if (percentage !== undefined) {
|
|
return this.clamp(Math.round(percentage), 0, 100);
|
|
}
|
|
const brightness = this.numberValue(dataArg, 'brightness');
|
|
if (brightness === undefined) {
|
|
return undefined;
|
|
}
|
|
return this.clamp(Math.round(brightness > 100 ? brightness / 2.55 : brightness), 0, 100);
|
|
}
|
|
|
|
private numberValue(dataArg: Record<string, unknown> | undefined, ...keysArg: string[]): number | undefined {
|
|
if (!dataArg) {
|
|
return undefined;
|
|
}
|
|
for (const key of keysArg) {
|
|
const value = dataArg[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
|
|
return Number(value);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private stringValue(dataArg: Record<string, unknown> | undefined, ...keysArg: string[]): string | undefined {
|
|
if (!dataArg) {
|
|
return undefined;
|
|
}
|
|
for (const key of keysArg) {
|
|
const value = dataArg[key];
|
|
if (typeof value === 'string' && value) {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
|
return Math.max(minArg, Math.min(maxArg, valueArg));
|
|
}
|
|
}
|