Add native hub protocol integrations
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createDeconzDiscoveryDescriptor } from '../../ts/integrations/deconz/index.js';
|
||||||
|
|
||||||
|
tap.test('matches deCONZ mDNS, SSDP, and manual discovery records', async () => {
|
||||||
|
const descriptor = createDeconzDiscoveryDescriptor();
|
||||||
|
const mdnsMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-mdns-match')!;
|
||||||
|
const ssdpMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-ssdp-match')!;
|
||||||
|
const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'deconz-manual-match')!;
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
|
||||||
|
const mdnsResult = await mdnsMatcher.matches({
|
||||||
|
name: 'deCONZ-GW',
|
||||||
|
type: '_http._tcp.local.',
|
||||||
|
host: 'deconz.local',
|
||||||
|
port: 80,
|
||||||
|
txt: {
|
||||||
|
bridgeid: '00212EFFFF00C5FB',
|
||||||
|
modelid: 'deCONZ',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(mdnsResult.matched).toBeTrue();
|
||||||
|
expect(mdnsResult.normalizedDeviceId).toEqual('00212EFFFF00C5FB');
|
||||||
|
|
||||||
|
const ssdpResult = await ssdpMatcher.matches({
|
||||||
|
ssdpLocation: 'http://192.168.1.55:80/description.xml',
|
||||||
|
upnp: {
|
||||||
|
manufacturer: 'Royal Philips Electronics',
|
||||||
|
manufacturerURL: 'http://www.dresden-elektronik.de',
|
||||||
|
modelName: 'deCONZ',
|
||||||
|
serialNumber: '00:21:2e:ff:ff:00:c5:fb',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(ssdpResult.matched).toBeTrue();
|
||||||
|
expect(ssdpResult.candidate?.host).toEqual('192.168.1.55');
|
||||||
|
expect(ssdpResult.normalizedDeviceId).toEqual('00212EFFFF00C5FB');
|
||||||
|
|
||||||
|
const manualResult = await manualMatcher.matches({
|
||||||
|
host: '192.168.1.56',
|
||||||
|
name: 'Phoscon Gateway',
|
||||||
|
model: 'ConBee II',
|
||||||
|
}, {});
|
||||||
|
expect(manualResult.matched).toBeTrue();
|
||||||
|
expect(manualResult.candidate?.port).toEqual(80);
|
||||||
|
|
||||||
|
const validationResult = await validator.validate(manualResult.candidate!, {});
|
||||||
|
expect(validationResult.matched).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeconzMapper, type IDeconzSnapshot } from '../../ts/integrations/deconz/index.js';
|
||||||
|
|
||||||
|
const snapshot: IDeconzSnapshot = {
|
||||||
|
config: {
|
||||||
|
bridgeid: '00212EFFFF00C5FB',
|
||||||
|
name: 'RaspBee GW',
|
||||||
|
devicename: 'ConBee II',
|
||||||
|
modelid: 'deCONZ',
|
||||||
|
swversion: '2.26.3',
|
||||||
|
rfconnected: true,
|
||||||
|
websocketport: 8088,
|
||||||
|
},
|
||||||
|
lights: {
|
||||||
|
'1': {
|
||||||
|
name: 'Kitchen Ceiling',
|
||||||
|
manufacturername: 'IKEA',
|
||||||
|
modelid: 'TRADFRI bulb E27',
|
||||||
|
type: 'Extended color light',
|
||||||
|
uniqueid: '00:0b:57:ff:fe:9a:46:ab-01',
|
||||||
|
state: {
|
||||||
|
on: true,
|
||||||
|
bri: 204,
|
||||||
|
ct: 370,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
name: 'Counter Plug',
|
||||||
|
manufacturername: 'dresden elektronik',
|
||||||
|
modelid: 'Smart plug',
|
||||||
|
type: 'Smart plug',
|
||||||
|
uniqueid: '00:0b:57:ff:fe:9a:46:ac-01',
|
||||||
|
state: {
|
||||||
|
on: false,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'3': {
|
||||||
|
name: 'Living Blind',
|
||||||
|
manufacturername: 'ubisys',
|
||||||
|
modelid: 'J1',
|
||||||
|
type: 'Window covering device',
|
||||||
|
uniqueid: '00:0b:57:ff:fe:9a:46:ad-01',
|
||||||
|
state: {
|
||||||
|
open: true,
|
||||||
|
lift: 25,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
'4': {
|
||||||
|
name: 'Living Room',
|
||||||
|
lights: ['1', '3'],
|
||||||
|
state: {
|
||||||
|
all_on: false,
|
||||||
|
any_on: true,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
on: true,
|
||||||
|
bri: 128,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sensors: {
|
||||||
|
'5': {
|
||||||
|
name: 'Hall Temperature',
|
||||||
|
manufacturername: 'Xiaomi',
|
||||||
|
modelid: 'lumi.weather',
|
||||||
|
type: 'ZHATemperature',
|
||||||
|
uniqueid: '00:15:8d:00:01:aa:bb:cc-01-0402',
|
||||||
|
config: {
|
||||||
|
battery: 88,
|
||||||
|
on: true,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
temperature: 2150,
|
||||||
|
lastupdated: '2026-05-05T08:00:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'6': {
|
||||||
|
name: 'Hall Motion',
|
||||||
|
manufacturername: 'Philips',
|
||||||
|
modelid: 'SML001',
|
||||||
|
type: 'ZHAPresence',
|
||||||
|
uniqueid: '00:17:88:01:02:03:04:05-02-0406',
|
||||||
|
config: {
|
||||||
|
lowbattery: false,
|
||||||
|
on: true,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
presence: true,
|
||||||
|
lastupdated: '2026-05-05T08:01:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'7': {
|
||||||
|
name: 'Radiator',
|
||||||
|
manufacturername: 'Eurotronic',
|
||||||
|
modelid: 'SPZB0001',
|
||||||
|
type: 'ZHAThermostat',
|
||||||
|
uniqueid: '00:15:8d:00:01:aa:bb:dd-01-0201',
|
||||||
|
config: {
|
||||||
|
heatsetpoint: 2200,
|
||||||
|
locked: false,
|
||||||
|
mode: 'heat',
|
||||||
|
on: true,
|
||||||
|
reachable: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
temperature: 2010,
|
||||||
|
lastupdated: '2026-05-05T08:02:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps deCONZ lights, groups, sensors, covers, and climate entities', async () => {
|
||||||
|
const devices = DeconzMapper.toDevices(snapshot);
|
||||||
|
const entities = DeconzMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'deconz.gateway.00212effff00c5fb')).toBeTrue();
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.features.some((featureArg) => featureArg.capability === 'cover'))).toBeTrue();
|
||||||
|
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'light.kitchen_ceiling')?.state).toEqual('on');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'switch.counter_plug')?.state).toEqual('off');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'cover.living_blind')?.attributes?.position).toEqual(75);
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'light.living_room')?.attributes?.isDeconzGroup).toEqual(true);
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.hall_temperature')?.state).toEqual(21.5);
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.hall_motion')?.state).toEqual('on');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'climate.radiator')?.attributes?.targetTemperature).toEqual(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createEsphomeDiscoveryDescriptor } from '../../ts/integrations/esphome/index.js';
|
||||||
|
|
||||||
|
tap.test('matches ESPHome native API mDNS records', async () => {
|
||||||
|
const descriptor = createEsphomeDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[0];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
type: '_esphomelib._tcp.local.',
|
||||||
|
name: 'kitchen_sensor._esphomelib._tcp.local.',
|
||||||
|
host: 'kitchen-sensor.local',
|
||||||
|
port: 6053,
|
||||||
|
txt: {
|
||||||
|
mac: 'aabbccddeeff',
|
||||||
|
name: 'kitchen_sensor',
|
||||||
|
friendly_name: 'Kitchen Sensor',
|
||||||
|
api_encryption: 'Noise_NNpsk0_25519_ChaChaPoly_SHA256',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||||
|
expect(result.candidate?.host).toEqual('kitchen-sensor.local');
|
||||||
|
expect(result.candidate?.port).toEqual(6053);
|
||||||
|
expect(result.candidate?.metadata?.encryptionRequired).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches new ESPHome mDNS service type and manual entries', async () => {
|
||||||
|
const descriptor = createEsphomeDiscoveryDescriptor();
|
||||||
|
const mdnsMatcher = descriptor.getMatchers()[0];
|
||||||
|
const manualMatcher = descriptor.getMatchers()[1];
|
||||||
|
const mdnsResult = await mdnsMatcher.matches({
|
||||||
|
type: '_esphome._tcp.local.',
|
||||||
|
name: 'garage_door._esphome._tcp.local.',
|
||||||
|
hostname: 'garage-door.local.',
|
||||||
|
port: 6053,
|
||||||
|
properties: { friendly_name: 'Garage Door' },
|
||||||
|
}, {});
|
||||||
|
const manualResult = await manualMatcher.matches({
|
||||||
|
host: 'garage-door.local',
|
||||||
|
name: 'Garage Door',
|
||||||
|
}, {});
|
||||||
|
expect(mdnsResult.matched).toBeTrue();
|
||||||
|
expect(mdnsResult.candidate?.name).toEqual('Garage Door');
|
||||||
|
expect(manualResult.matched).toBeTrue();
|
||||||
|
expect(manualResult.candidate?.port).toEqual(6053);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { EsphomeMapper } from '../../ts/integrations/esphome/index.js';
|
||||||
|
|
||||||
|
const snapshot = EsphomeMapper.toSnapshot({
|
||||||
|
host: 'living-room-node.local',
|
||||||
|
deviceInfo: {
|
||||||
|
name: 'living_room_node',
|
||||||
|
friendlyName: 'Living Room Node',
|
||||||
|
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
manufacturer: 'Espressif',
|
||||||
|
model: 'ESP32',
|
||||||
|
esphomeVersion: '2026.4.4',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ platform: 'light', key: 1, name: 'Lamp', objectId: 'lamp' },
|
||||||
|
{ platform: 'switch', key: 2, name: 'Relay', objectId: 'relay' },
|
||||||
|
{ platform: 'sensor', key: 3, name: 'Temperature', objectId: 'temperature', unitOfMeasurement: 'C', deviceClass: 'temperature' },
|
||||||
|
{ platform: 'binary_sensor', key: 4, name: 'Motion', objectId: 'motion', deviceClass: 'motion' },
|
||||||
|
{ platform: 'fan', key: 5, name: 'Fan', objectId: 'fan' },
|
||||||
|
{ platform: 'cover', key: 6, name: 'Blind', objectId: 'blind', supportsPosition: true },
|
||||||
|
{ platform: 'climate', key: 7, name: 'Thermostat', objectId: 'thermostat' },
|
||||||
|
{ platform: 'button', key: 8, name: 'Restart', objectId: 'restart' },
|
||||||
|
{ platform: 'number', key: 9, name: 'Target humidity', objectId: 'target_humidity', minValue: 0, maxValue: 100, step: 1 },
|
||||||
|
{ platform: 'select', key: 10, name: 'Mode', objectId: 'mode', options: ['Auto', 'Manual'] },
|
||||||
|
],
|
||||||
|
states: [
|
||||||
|
{ platform: 'light', key: 1, state: true, brightness: 0.5 },
|
||||||
|
{ platform: 'switch', key: 2, state: false },
|
||||||
|
{ platform: 'sensor', key: 3, state: 21.25 },
|
||||||
|
{ platform: 'binary_sensor', key: 4, state: true },
|
||||||
|
{ platform: 'fan', key: 5, state: true, percentage: 66 },
|
||||||
|
{ platform: 'cover', key: 6, position: 0.42 },
|
||||||
|
{ platform: 'climate', key: 7, mode: 'heat', current_temperature: 20.5, target_temperature: 22 },
|
||||||
|
{ platform: 'number', key: 9, state: 45 },
|
||||||
|
{ platform: 'select', key: 10, state: 'Auto' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps ESPHome snapshot devices and entities', async () => {
|
||||||
|
const devices = EsphomeMapper.toDevices(snapshot);
|
||||||
|
const entities = EsphomeMapper.toEntities(snapshot);
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'esphome.device.aabbccddeeff')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.state === 'off')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 21.25)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 42)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'select' && entityArg.state === 'Auto')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps entity services to safe ESPHome command shapes', async () => {
|
||||||
|
const deviceId = EsphomeMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'number')?.deviceId;
|
||||||
|
const numberCommand = EsphomeMapper.commandForService(snapshot, {
|
||||||
|
domain: 'number',
|
||||||
|
service: 'set_value',
|
||||||
|
target: { entityId: 'number.living_room_node_target_humidity' },
|
||||||
|
data: { value: 55 },
|
||||||
|
});
|
||||||
|
const coverCommand = EsphomeMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'set_position',
|
||||||
|
target: { entityId: 'cover.living_room_node_blind' },
|
||||||
|
data: { position: 70 },
|
||||||
|
});
|
||||||
|
const selectCommand = EsphomeMapper.commandForService(snapshot, {
|
||||||
|
domain: 'select',
|
||||||
|
service: 'select_option',
|
||||||
|
target: { entityId: 'select.living_room_node_mode' },
|
||||||
|
data: { option: 'Manual' },
|
||||||
|
});
|
||||||
|
expect(numberCommand?.payload.value).toEqual(55);
|
||||||
|
expect(coverCommand?.payload.position).toEqual(0.7);
|
||||||
|
expect(selectCommand?.payload.option).toEqual('Manual');
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error('Expected mapped number entity device id');
|
||||||
|
}
|
||||||
|
const deviceTargetNumberCommand = EsphomeMapper.commandForService(snapshot, {
|
||||||
|
domain: 'number',
|
||||||
|
service: 'set_value',
|
||||||
|
target: { deviceId },
|
||||||
|
data: { value: 60 },
|
||||||
|
});
|
||||||
|
expect(deviceTargetNumberCommand?.platform).toEqual('number');
|
||||||
|
expect(deviceTargetNumberCommand?.payload.value).toEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('uses manual entry data for snapshots', async () => {
|
||||||
|
const manualSnapshot = EsphomeMapper.toSnapshot({
|
||||||
|
manualEntries: [{
|
||||||
|
host: 'manual-node.local',
|
||||||
|
port: 6053,
|
||||||
|
name: 'Manual Node',
|
||||||
|
deviceName: 'manual_node',
|
||||||
|
manufacturer: 'ESPHome',
|
||||||
|
model: 'ESP32-S3',
|
||||||
|
encryptionKey: 'base64-key',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
expect(manualSnapshot.host).toEqual('manual-node.local');
|
||||||
|
expect(manualSnapshot.deviceInfo.name).toEqual('manual_node');
|
||||||
|
expect(manualSnapshot.deviceInfo.friendlyName).toEqual('Manual Node');
|
||||||
|
expect(manualSnapshot.deviceInfo.apiEncryptionSupported).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomekitControllerConfigFlow, createHomekitControllerDiscoveryDescriptor } from '../../ts/integrations/homekit_controller/index.js';
|
||||||
|
|
||||||
|
tap.test('matches HomeKit mDNS records', async () => {
|
||||||
|
const descriptor = createHomekitControllerDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[0];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
type: '_hap._tcp.local.',
|
||||||
|
name: 'Desk Lamp._hap._tcp.local.',
|
||||||
|
host: 'desk-lamp.local',
|
||||||
|
port: 51826,
|
||||||
|
txt: {
|
||||||
|
id: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
md: 'Lamp 1',
|
||||||
|
mf: 'Example Lighting',
|
||||||
|
ci: '5',
|
||||||
|
sf: '1',
|
||||||
|
'c#': '2',
|
||||||
|
's#': '1',
|
||||||
|
pv: '1.1',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||||
|
expect(result.candidate?.host).toEqual('desk-lamp.local');
|
||||||
|
expect(result.candidate?.metadata?.categoryName).toEqual('Lightbulb');
|
||||||
|
expect(result.candidate?.metadata?.paired).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('validates HomeKit candidates by metadata', async () => {
|
||||||
|
const descriptor = createHomekitControllerDiscoveryDescriptor();
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
const result = await validator.validate({
|
||||||
|
source: 'manual',
|
||||||
|
id: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
host: 'desk-lamp.local',
|
||||||
|
metadata: { homekit: true, category: 5 },
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.confidence).toEqual('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('preserves manual HomeKit setup metadata for config flow', async () => {
|
||||||
|
const descriptor = createHomekitControllerDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[1];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
source: 'manual',
|
||||||
|
id: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
name: 'Manual Lamp',
|
||||||
|
host: 'manual-lamp.local',
|
||||||
|
setupCode: '23456789',
|
||||||
|
category: 5,
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.metadata?.setupCode).toEqual('23456789');
|
||||||
|
|
||||||
|
const flow = new HomekitControllerConfigFlow();
|
||||||
|
const step = await flow.start(result.candidate!, {});
|
||||||
|
const done = await step.submit?.({});
|
||||||
|
|
||||||
|
expect(done?.kind).toEqual('done');
|
||||||
|
expect(done?.config?.setupCode).toEqual('234-56-789');
|
||||||
|
expect(done?.config?.host).toEqual('manual-lamp.local');
|
||||||
|
expect(done?.config?.id).toEqual('aa:bb:cc:dd:ee:ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects generic candidates with non-HomeKit category metadata', async () => {
|
||||||
|
const descriptor = createHomekitControllerDiscoveryDescriptor();
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
const result = await validator.validate({
|
||||||
|
source: 'manual',
|
||||||
|
id: 'generic-device',
|
||||||
|
host: 'generic.local',
|
||||||
|
metadata: { category: 5 },
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(result.matched).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomekitControllerIntegration, HomekitControllerMapper } from '../../ts/integrations/homekit_controller/index.js';
|
||||||
|
|
||||||
|
const snapshot = HomekitControllerMapper.toSnapshot({
|
||||||
|
id: 'aa:bb:cc:dd:ee:ff',
|
||||||
|
connected: true,
|
||||||
|
accessories: [{
|
||||||
|
aid: 1,
|
||||||
|
services: [{
|
||||||
|
iid: 1,
|
||||||
|
type: '0000003E-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 2, type: '00000023-0000-1000-8000-0026BB765291', value: 'Living Room Bridge' },
|
||||||
|
{ iid: 3, type: '00000020-0000-1000-8000-0026BB765291', value: 'Example' },
|
||||||
|
{ iid: 4, type: '00000021-0000-1000-8000-0026BB765291', value: 'HK-Bridge' },
|
||||||
|
{ iid: 5, type: '00000030-0000-1000-8000-0026BB765291', value: 'SERIAL1' },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 10,
|
||||||
|
type: '00000043-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 11, type: '00000023-0000-1000-8000-0026BB765291', value: 'Lamp' },
|
||||||
|
{ iid: 12, type: '00000025-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 13, type: '00000008-0000-1000-8000-0026BB765291', value: 42, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 20,
|
||||||
|
type: '00000045-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 21, type: '0000001D-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'ev'] },
|
||||||
|
{ iid: 22, type: '0000001E-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 30,
|
||||||
|
type: '0000008C-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 31, type: '0000006D-0000-1000-8000-0026BB765291', value: 50, perms: ['pr', 'ev'] },
|
||||||
|
{ iid: 32, type: '0000007C-0000-1000-8000-0026BB765291', value: 50, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 33, type: '00000072-0000-1000-8000-0026BB765291', value: 2, perms: ['pr', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 40,
|
||||||
|
type: '00000110-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 41, type: '000000B0-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 50,
|
||||||
|
type: '00000049-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 51, type: '00000023-0000-1000-8000-0026BB765291', value: 'Wall Switch' },
|
||||||
|
{ iid: 52, type: '00000025-0000-1000-8000-0026BB765291', value: false, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 60,
|
||||||
|
type: '00000047-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 61, type: '00000023-0000-1000-8000-0026BB765291', value: 'Outlet' },
|
||||||
|
{ iid: 62, type: '00000025-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 63, type: '00000026-0000-1000-8000-0026BB765291', value: true, perms: ['pr', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 70,
|
||||||
|
type: '0000008A-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 71, type: '00000011-0000-1000-8000-0026BB765291', value: 21.5, perms: ['pr', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 80,
|
||||||
|
type: '0000004A-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 81, type: '00000011-0000-1000-8000-0026BB765291', value: 20, perms: ['pr', 'ev'] },
|
||||||
|
{ iid: 82, type: '00000035-0000-1000-8000-0026BB765291', value: 23, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 83, type: '00000033-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 90,
|
||||||
|
type: '00000041-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 91, type: '0000000E-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'ev'] },
|
||||||
|
{ iid: 92, type: '00000032-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 93, type: '00000024-0000-1000-8000-0026BB765291', value: false, perms: ['pr', 'ev'] },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
iid: 100,
|
||||||
|
type: '000000B7-0000-1000-8000-0026BB765291',
|
||||||
|
characteristics: [
|
||||||
|
{ iid: 101, type: '000000B0-0000-1000-8000-0026BB765291', value: 1, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
{ iid: 102, type: '00000029-0000-1000-8000-0026BB765291', value: 55, perms: ['pr', 'pw', 'ev'] },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HomeKit accessories to devices and entities', async () => {
|
||||||
|
const devices = HomekitControllerMapper.toDevices(snapshot);
|
||||||
|
const entities = HomekitControllerMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(devices[0].protocol).toEqual('homekit');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Example');
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on' && entityArg.attributes?.brightness === 42)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => String(entityArg.platform) === 'lock' && entityArg.state === 'locked')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 'open')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => String(entityArg.platform) === 'camera' && entityArg.state === 'available')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.attributes?.serviceType === 'outlet' && entityArg.attributes?.outletInUse === true)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 21.5)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'heat' && entityArg.attributes?.targetTemperature === 23)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 'closed' && entityArg.attributes?.serviceType === 'garage_door_opener')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'fan' && entityArg.state === 'on' && entityArg.attributes?.percentage === 55)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps common entity services to HomeKit writes', async () => {
|
||||||
|
const light = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'light');
|
||||||
|
const command = HomekitControllerMapper.commandForService(snapshot, {
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_off',
|
||||||
|
target: { entityId: light?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command?.command).toEqual('write_characteristics');
|
||||||
|
expect(command?.writes?.[0]?.aid).toEqual(1);
|
||||||
|
expect(command?.writes?.[0]?.iid).toEqual(12);
|
||||||
|
expect(command?.writes?.[0]?.value).toEqual(false);
|
||||||
|
|
||||||
|
const lightOnCommand = HomekitControllerMapper.commandForService(snapshot, {
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
target: { entityId: light?.id },
|
||||||
|
data: { brightness: 128 },
|
||||||
|
});
|
||||||
|
expect(lightOnCommand?.writes?.some((writeArg) => writeArg.iid === 13 && writeArg.value === 50)).toBeTrue();
|
||||||
|
|
||||||
|
const fan = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'fan');
|
||||||
|
const fanCommand = HomekitControllerMapper.commandForService(snapshot, {
|
||||||
|
domain: 'fan',
|
||||||
|
service: 'set_percentage',
|
||||||
|
target: { entityId: fan?.id },
|
||||||
|
data: { percentage: 75 },
|
||||||
|
});
|
||||||
|
expect(fanCommand?.writes?.some((writeArg) => writeArg.iid === 102 && writeArg.value === 75)).toBeTrue();
|
||||||
|
|
||||||
|
const climate = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'climate');
|
||||||
|
const climateCommand = HomekitControllerMapper.commandForService(snapshot, {
|
||||||
|
domain: 'climate',
|
||||||
|
service: 'set_hvac_mode',
|
||||||
|
target: { entityId: climate?.id },
|
||||||
|
data: { hvac_mode: 'heat_cool' },
|
||||||
|
});
|
||||||
|
expect(climateCommand?.writes?.some((writeArg) => writeArg.iid === 83 && writeArg.value === 3)).toBeTrue();
|
||||||
|
|
||||||
|
const camera = HomekitControllerMapper.toEntities(snapshot).find((entityArg) => String(entityArg.platform) === 'camera');
|
||||||
|
const cameraCommand = HomekitControllerMapper.commandForService(snapshot, {
|
||||||
|
domain: 'camera',
|
||||||
|
service: 'snapshot',
|
||||||
|
target: { entityId: camera?.id },
|
||||||
|
data: { width: 320, height: 240 },
|
||||||
|
});
|
||||||
|
expect(cameraCommand?.command).toEqual('camera_snapshot');
|
||||||
|
expect(cameraCommand?.aid).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('returns explicit unsupported errors for native HAP security operations', async () => {
|
||||||
|
const integration = new HomekitControllerIntegration();
|
||||||
|
const runtime = await integration.setup({ host: 'desk-lamp.local', setupCode: '234-56-789' }, {});
|
||||||
|
const result = await runtime.callService?.({
|
||||||
|
domain: 'homekit_controller',
|
||||||
|
service: 'pair_setup',
|
||||||
|
target: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.success).toEqual(false);
|
||||||
|
expect(String(result?.error).includes('pair setup is not implemented')).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { MatterConfigFlow, createMatterDiscoveryDescriptor } from '../../ts/integrations/matter/index.js';
|
||||||
|
|
||||||
|
tap.test('matches Matter zeroconf records', async () => {
|
||||||
|
const descriptor = createMatterDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[0];
|
||||||
|
|
||||||
|
const operational = await matcher.matches({
|
||||||
|
type: '_matter._tcp.local.',
|
||||||
|
name: 'Kitchen Bulb._matter._tcp.local.',
|
||||||
|
host: 'kitchen-bulb.local',
|
||||||
|
port: 5540,
|
||||||
|
txt: { dn: 'Kitchen Bulb' },
|
||||||
|
}, {});
|
||||||
|
const commissionable = await matcher.matches({
|
||||||
|
type: '_matterc._udp.local.',
|
||||||
|
name: 'Commissionable._matterc._udp.local.',
|
||||||
|
host: 'commissionable.local',
|
||||||
|
port: 5540,
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(operational.matched).toBeTrue();
|
||||||
|
expect(operational.candidate?.name).toEqual('Kitchen Bulb');
|
||||||
|
expect(commissionable.matched).toBeTrue();
|
||||||
|
expect(commissionable.candidate?.model).toEqual('Commissionable Matter device');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('manual Matter setup defaults to local server URL', async () => {
|
||||||
|
const descriptor = createMatterDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[1];
|
||||||
|
const result = await matcher.matches({}, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.metadata?.url).toEqual('ws://localhost:5580/ws');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Matter config flow uses candidate URL default', async () => {
|
||||||
|
const flow = new MatterConfigFlow();
|
||||||
|
const step = await flow.start({
|
||||||
|
source: 'manual',
|
||||||
|
metadata: { url: 'ws://matter.local:5580/ws' },
|
||||||
|
}, {});
|
||||||
|
const done = await step.submit?.({});
|
||||||
|
|
||||||
|
expect(done?.config?.url).toEqual('ws://matter.local:5580/ws');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { MatterMapper } from '../../ts/integrations/matter/index.js';
|
||||||
|
import type { IMatterSnapshot } from '../../ts/integrations/matter/index.js';
|
||||||
|
|
||||||
|
const snapshot: IMatterSnapshot = {
|
||||||
|
connected: true,
|
||||||
|
events: [],
|
||||||
|
serverInfo: {
|
||||||
|
fabric_id: 1,
|
||||||
|
compressed_fabric_id: 2,
|
||||||
|
schema_version: 11,
|
||||||
|
min_supported_schema_version: 6,
|
||||||
|
sdk_version: '1.4.2',
|
||||||
|
wifi_credentials_set: true,
|
||||||
|
thread_credentials_set: false,
|
||||||
|
bluetooth_enabled: false,
|
||||||
|
},
|
||||||
|
nodes: [{
|
||||||
|
node_id: 1234,
|
||||||
|
available: true,
|
||||||
|
attributes: {
|
||||||
|
'0/29/0': [{ deviceType: 0x0016, revision: 1 }],
|
||||||
|
'0/29/1': [29, 40],
|
||||||
|
'0/40/1': 'Acme',
|
||||||
|
'0/40/2': 123,
|
||||||
|
'0/40/3': 'Matter Test Device',
|
||||||
|
'0/40/4': 456,
|
||||||
|
'0/40/5': 'Test Node',
|
||||||
|
'0/40/15': 'serial-1',
|
||||||
|
'1/29/0': [{ deviceType: 0x0101, revision: 2 }],
|
||||||
|
'1/29/1': [29, 6, 8],
|
||||||
|
'1/6/0': true,
|
||||||
|
'1/8/0': 128,
|
||||||
|
'1/8/2': 1,
|
||||||
|
'1/8/3': 254,
|
||||||
|
'2/29/0': [{ deviceType: 0x0015, revision: 1 }],
|
||||||
|
'2/29/1': [29, 69],
|
||||||
|
'2/69/0': false,
|
||||||
|
'3/29/0': [{ deviceType: 0x0202, revision: 1 }],
|
||||||
|
'3/29/1': [29, 258],
|
||||||
|
'3/258/10': 0,
|
||||||
|
'3/258/14': 2500,
|
||||||
|
'4/29/0': [{ deviceType: 0x000a, revision: 1 }],
|
||||||
|
'4/29/1': [29, 257],
|
||||||
|
'4/257/0': 1,
|
||||||
|
'5/29/0': [{ deviceType: 0x0301, revision: 1 }],
|
||||||
|
'5/29/1': [29, 513],
|
||||||
|
'5/513/0': 2150,
|
||||||
|
'5/513/18': 2000,
|
||||||
|
'5/513/28': 4,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps Matter nodes to canonical devices and entities', async () => {
|
||||||
|
const devices = MatterMapper.toDevices(snapshot);
|
||||||
|
const entities = MatterMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'matter.node.1234.endpoint.1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.attributes?.deviceClass === 'door')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.attributes?.position === 75)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => String(entityArg.platform) === 'lock' && entityArg.state === 'locked')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'heat')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps entity services to Matter server commands', async () => {
|
||||||
|
const light = MatterMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'light');
|
||||||
|
const cover = MatterMapper.toEntities(snapshot).find((entityArg) => entityArg.platform === 'cover');
|
||||||
|
|
||||||
|
const turnOff = MatterMapper.commandForService(snapshot, {
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_off',
|
||||||
|
target: { entityId: light?.id },
|
||||||
|
});
|
||||||
|
const setPosition = MatterMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'set_position',
|
||||||
|
target: { entityId: cover?.id },
|
||||||
|
data: { position: 42 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(turnOff?.command).toEqual('device_command');
|
||||||
|
expect(turnOff?.args?.cluster_id).toEqual(6);
|
||||||
|
expect(turnOff?.args?.command_name).toEqual('Off');
|
||||||
|
expect(setPosition?.args?.cluster_id).toEqual(258);
|
||||||
|
expect(setPosition?.args?.payload).toEqual({ liftPercent100thsValue: 5800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createNanoleafDiscoveryDescriptor } from '../../ts/integrations/nanoleaf/index.js';
|
||||||
|
|
||||||
|
tap.test('matches Nanoleaf mDNS zeroconf records', async () => {
|
||||||
|
const descriptor = createNanoleafDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-mdns-match');
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
name: 'Nanoleaf Shapes ABCD',
|
||||||
|
type: '_nanoleafapi._tcp.local.',
|
||||||
|
host: 'nanoleaf-shapes.local',
|
||||||
|
port: 16021,
|
||||||
|
txt: {
|
||||||
|
id: 'NL123ABC',
|
||||||
|
md: 'NL42',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.normalizedDeviceId).toEqual('NL123ABC');
|
||||||
|
expect(result.candidate?.port).toEqual(16021);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches Nanoleaf SSDP records', async () => {
|
||||||
|
const descriptor = createNanoleafDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-ssdp-match');
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
st: 'nanoleaf:nl42',
|
||||||
|
usn: 'uuid:nanoleaf-nl42',
|
||||||
|
headers: {
|
||||||
|
'_host': '192.168.1.55:16021',
|
||||||
|
'nl-devicename': 'Nanoleaf Shapes ABCD',
|
||||||
|
'nl-deviceid': 'NL123ABC',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.host).toEqual('192.168.1.55');
|
||||||
|
expect(result.candidate?.model).toEqual('NL42');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('validates manual Nanoleaf candidates', async () => {
|
||||||
|
const descriptor = createNanoleafDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'nanoleaf-manual-match');
|
||||||
|
const manualResult = await matcher!.matches({
|
||||||
|
host: '192.168.1.56',
|
||||||
|
port: 16021,
|
||||||
|
metadata: { nanoleaf: true },
|
||||||
|
}, {});
|
||||||
|
expect(manualResult.matched).toBeTrue();
|
||||||
|
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
const validation = await validator.validate(manualResult.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { NanoleafMapper } from '../../ts/integrations/nanoleaf/index.js';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
controllerInfo: {
|
||||||
|
name: 'Living Room Shapes',
|
||||||
|
serialNo: 'NL123ABC',
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: 'NL42',
|
||||||
|
firmwareVersion: '9.6.1',
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
on: { value: true },
|
||||||
|
brightness: { value: 80, min: 0, max: 100 },
|
||||||
|
hue: { value: 220, min: 0, max: 360 },
|
||||||
|
sat: { value: 65, min: 0, max: 100 },
|
||||||
|
ct: { value: 4000, min: 1200, max: 6500 },
|
||||||
|
colorMode: 'effect',
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
select: 'Northern Lights',
|
||||||
|
effectsList: ['Northern Lights', '*Solid*'],
|
||||||
|
},
|
||||||
|
panelLayout: {
|
||||||
|
layout: {
|
||||||
|
numPanels: 2,
|
||||||
|
sideLength: 150,
|
||||||
|
positionData: [
|
||||||
|
{ panelId: 101, x: 0, y: 0, o: 0, shapeType: 7 },
|
||||||
|
{ panelId: 102, x: 150, y: 0, o: 60, shapeType: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rhythm: {
|
||||||
|
rhythmConnected: true,
|
||||||
|
rhythmActive: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps Nanoleaf controller and panels to canonical devices', async () => {
|
||||||
|
const devices = NanoleafMapper.toDevices(snapshot);
|
||||||
|
expect(devices[0].id).toEqual('nanoleaf.controller.nl123abc');
|
||||||
|
expect(devices[0].features.some((featureArg) => featureArg.id === 'panel_count')).toBeTrue();
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'nanoleaf.panel.nl123abc.101')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Nanoleaf light state, sensors, select, and button entities', async () => {
|
||||||
|
const entities = NanoleafMapper.toEntities(snapshot);
|
||||||
|
const light = entities.find((entityArg) => entityArg.id === 'light.living_room_shapes');
|
||||||
|
const effect = entities.find((entityArg) => entityArg.id === 'select.living_room_shapes_effect');
|
||||||
|
expect(light?.state).toEqual('on');
|
||||||
|
expect(light?.attributes?.brightness).toEqual(80);
|
||||||
|
expect(effect?.state).toEqual('Northern Lights');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'button.living_room_shapes_identify')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.living_room_shapes_panel_count')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.living_room_shapes_panel_101')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createTradfriDiscoveryDescriptor } from '../../ts/integrations/tradfri/index.js';
|
||||||
|
|
||||||
|
tap.test('matches IKEA TRADFRI HomeKit mDNS records', async () => {
|
||||||
|
const descriptor = createTradfriDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[0];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
type: '_hap._tcp.local.',
|
||||||
|
name: 'TRADFRI-Gateway._hap._tcp.local.',
|
||||||
|
host: 'tradfri-gateway.local',
|
||||||
|
port: 8080,
|
||||||
|
txt: {
|
||||||
|
md: 'TRADFRI',
|
||||||
|
id: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:FF');
|
||||||
|
expect(result.candidate?.host).toEqual('tradfri-gateway.local');
|
||||||
|
expect(result.candidate?.port).toEqual(5684);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches and validates manual host/security-code entries', async () => {
|
||||||
|
const descriptor = createTradfriDiscoveryDescriptor();
|
||||||
|
const manualMatcher = descriptor.getMatchers()[1];
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
const result = await manualMatcher.matches({
|
||||||
|
host: '192.168.1.23',
|
||||||
|
securityCode: 'abc123',
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.metadata?.securityCode).toEqual('abc123');
|
||||||
|
const validation = await validator.validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { TradfriMapper } from '../../ts/integrations/tradfri/index.js';
|
||||||
|
import type { ITradfriSnapshot } from '../../ts/integrations/tradfri/index.js';
|
||||||
|
|
||||||
|
const snapshot: ITradfriSnapshot = {
|
||||||
|
host: 'tradfri-gateway.local',
|
||||||
|
port: 5684,
|
||||||
|
connected: true,
|
||||||
|
gateway: {
|
||||||
|
id: 'gw-001',
|
||||||
|
name: 'Tradfri Gateway',
|
||||||
|
firmwareVersion: '1.19.32',
|
||||||
|
},
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 65537,
|
||||||
|
name: 'Kitchen Bulb',
|
||||||
|
reachable: 1,
|
||||||
|
deviceInfo: {
|
||||||
|
manufacturer: 'IKEA of Sweden',
|
||||||
|
modelNumber: 'TRADFRI bulb E27 WS opal 980lm',
|
||||||
|
firmwareVersion: '2.3.087',
|
||||||
|
},
|
||||||
|
lightControl: [{ state: 1, dimmer: 127, colorTemp: 370 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 65538,
|
||||||
|
name: 'Coffee Outlet',
|
||||||
|
reachable: true,
|
||||||
|
deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'TRADFRI control outlet' },
|
||||||
|
socketControl: [{ state: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 65539,
|
||||||
|
name: 'Bedroom Blind',
|
||||||
|
reachable: true,
|
||||||
|
deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'FYRTUR block-out roller blind', batteryLevel: 88 },
|
||||||
|
blindControl: [{ currentCoverPosition: 40 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 65540,
|
||||||
|
name: 'Hall Motion',
|
||||||
|
reachable: true,
|
||||||
|
deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'TRADFRI motion sensor', batteryLevel: 76 },
|
||||||
|
sensors: [{ name: 'Motion', deviceClass: 'motion', value: true, binary: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 65541,
|
||||||
|
name: 'Air Purifier',
|
||||||
|
reachable: true,
|
||||||
|
deviceInfo: { manufacturer: 'IKEA of Sweden', modelNumber: 'STARKVIND air purifier' },
|
||||||
|
airPurifierControl: [{ mode: 1, fanSpeed: 20, airQuality: 12, filterLifetimeRemaining: 1200 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 131073,
|
||||||
|
name: 'Kitchen Group',
|
||||||
|
state: 1,
|
||||||
|
dimmer: 200,
|
||||||
|
memberIds: [65537, 65538],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps Tradfri snapshot to canonical devices and entities', async () => {
|
||||||
|
const devices = TradfriMapper.toDevices(snapshot);
|
||||||
|
const entities = TradfriMapper.toEntities(snapshot);
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'tradfri.gateway.gw_001')).toBeTrue();
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'tradfri.device.gw_001.65539')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_bulb' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.coffee_outlet' && entityArg.state === 'off')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'cover.bedroom_blind' && entityArg.state === 60)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_blind_battery' && entityArg.state === 88)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.hall_motion_motion' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'fan.air_purifier' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_group')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps service calls to Tradfri command payloads', async () => {
|
||||||
|
const lightCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
target: { entityId: 'light.kitchen_bulb' },
|
||||||
|
data: { brightness: 200 },
|
||||||
|
});
|
||||||
|
const coverCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'set_position',
|
||||||
|
target: { entityId: 'cover.bedroom_blind' },
|
||||||
|
data: { position: 70 },
|
||||||
|
});
|
||||||
|
const setValueCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'set_value',
|
||||||
|
target: { entityId: 'cover.bedroom_blind' },
|
||||||
|
data: { value: 25 },
|
||||||
|
});
|
||||||
|
const fanCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'fan',
|
||||||
|
service: 'set_percentage',
|
||||||
|
target: { entityId: 'fan.air_purifier' },
|
||||||
|
data: { percentage: 50 },
|
||||||
|
});
|
||||||
|
const openCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'open_cover',
|
||||||
|
target: { entityId: 'cover.bedroom_blind' },
|
||||||
|
});
|
||||||
|
const closeCommand = TradfriMapper.commandForService(snapshot, {
|
||||||
|
domain: 'cover',
|
||||||
|
service: 'close_cover',
|
||||||
|
target: { entityId: 'cover.bedroom_blind' },
|
||||||
|
});
|
||||||
|
expect(lightCommand?.coap.payload?.['3311']).toEqual([{ '5850': 1, '5851': 200 }]);
|
||||||
|
expect(coverCommand?.payload.rawPosition).toEqual(30);
|
||||||
|
expect(setValueCommand?.payload.position).toEqual(25);
|
||||||
|
expect(fanCommand?.payload.mode).toEqual(26);
|
||||||
|
expect(openCommand?.payload.rawPosition).toEqual(0);
|
||||||
|
expect(closeCommand?.payload.rawPosition).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createWizDiscoveryDescriptor } from '../../ts/integrations/wiz/index.js';
|
||||||
|
|
||||||
|
tap.test('matches WiZ mDNS, UDP, and manual discovery records', async () => {
|
||||||
|
const descriptor = createWizDiscoveryDescriptor();
|
||||||
|
const mdnsMatcher = descriptor.getMatchers()[0];
|
||||||
|
const udpMatcher = descriptor.getMatchers()[1];
|
||||||
|
const manualMatcher = descriptor.getMatchers()[2];
|
||||||
|
|
||||||
|
const mdnsResult = await mdnsMatcher.matches({
|
||||||
|
type: '_http._tcp.local.',
|
||||||
|
name: 'wiz_a8bb50a4f94d._http._tcp.local.',
|
||||||
|
host: 'wiz-a4f94d.local',
|
||||||
|
txt: {
|
||||||
|
mac: 'a8bb50a4f94d',
|
||||||
|
name: 'Desk Lamp',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
const udpResult = await udpMatcher.matches({
|
||||||
|
host: '192.168.1.51',
|
||||||
|
response: {
|
||||||
|
method: 'registration',
|
||||||
|
result: {
|
||||||
|
mac: 'a8bb50a4f94d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
const manualResult = await manualMatcher.matches({
|
||||||
|
host: '192.168.1.52',
|
||||||
|
name: 'Counter Plug',
|
||||||
|
deviceInfo: {
|
||||||
|
model: 'WiZ Smart Plug',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
expect(mdnsResult.matched).toBeTrue();
|
||||||
|
expect(mdnsResult.normalizedDeviceId).toEqual('a8:bb:50:a4:f9:4d');
|
||||||
|
expect(udpResult.matched).toBeTrue();
|
||||||
|
expect(udpResult.candidate?.metadata?.discoveryProtocol).toEqual('udp');
|
||||||
|
expect(manualResult.matched).toBeTrue();
|
||||||
|
expect(manualResult.candidate?.port).toEqual(38899);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('validates WiZ candidates from DHCP-style metadata', async () => {
|
||||||
|
const validator = createWizDiscoveryDescriptor().getValidators()[0];
|
||||||
|
const result = await validator.validate({
|
||||||
|
source: 'dhcp',
|
||||||
|
host: 'wiz_a4f94d',
|
||||||
|
macAddress: 'A8:BB:50:A4:F9:4D',
|
||||||
|
port: 38899,
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.confidence).toEqual('certain');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { WizMapper, type IWizSnapshot } from '../../ts/integrations/wiz/index.js';
|
||||||
|
|
||||||
|
const snapshot: IWizSnapshot = {
|
||||||
|
connected: true,
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
host: '192.168.1.51',
|
||||||
|
port: 38899,
|
||||||
|
mac: 'a8bb50a4f94d',
|
||||||
|
name: 'Desk Lamp',
|
||||||
|
available: true,
|
||||||
|
deviceInfo: {
|
||||||
|
manufacturer: 'WiZ',
|
||||||
|
model: 'RGBW Tunable',
|
||||||
|
moduleName: 'ESP03_SHRGB1C_31',
|
||||||
|
fwVersion: '1.33.0',
|
||||||
|
features: {
|
||||||
|
effect: true,
|
||||||
|
power: true,
|
||||||
|
occupancy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pilot: {
|
||||||
|
mac: 'a8bb50a4f94d',
|
||||||
|
rssi: -57,
|
||||||
|
state: true,
|
||||||
|
sceneId: 4,
|
||||||
|
temp: 3000,
|
||||||
|
dimming: 80,
|
||||||
|
r: 255,
|
||||||
|
g: 100,
|
||||||
|
b: 0,
|
||||||
|
pc: 12345,
|
||||||
|
speed: 100,
|
||||||
|
src: 'wfa1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps WiZ lights, sensors, buttons, numbers, and selects', async () => {
|
||||||
|
const devices = WizMapper.toDevices(snapshot);
|
||||||
|
const entities = WizMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(devices[0].id).toEqual('wiz.device.a8_bb_50_a4_f9_4d');
|
||||||
|
expect(devices[0].features.some((featureArg) => featureArg.capability === 'light')).toBeTrue();
|
||||||
|
expect(devices[0].features.some((featureArg) => featureArg.id === 'power')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'light.desk_lamp')?.state).toEqual('on');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.desk_lamp_power')?.state).toEqual(12.345);
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'button.desk_lamp_button_on')?.state).toEqual('wfa1');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'select.desk_lamp_effect')?.state).toEqual('Party');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'number.desk_lamp_effect_speed')?.state).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps canonical services to WiZ setPilot payloads', async () => {
|
||||||
|
const turnOnCommand = WizMapper.commandForService(snapshot, {
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
target: { entityId: 'light.desk_lamp' },
|
||||||
|
data: { brightness_pct: 50, effect: 'Ocean' },
|
||||||
|
});
|
||||||
|
const selectCommand = WizMapper.commandForService(snapshot, {
|
||||||
|
domain: 'select',
|
||||||
|
service: 'select_option',
|
||||||
|
target: { entityId: 'select.desk_lamp_effect' },
|
||||||
|
data: { option: 'Party' },
|
||||||
|
});
|
||||||
|
const numberCommand = WizMapper.commandForService(snapshot, {
|
||||||
|
domain: 'number',
|
||||||
|
service: 'set_value',
|
||||||
|
target: { entityId: 'number.desk_lamp_effect_speed' },
|
||||||
|
data: { value: 120 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(turnOnCommand?.payload.dimming).toEqual(50);
|
||||||
|
expect(turnOnCommand?.payload.sceneId).toEqual(1);
|
||||||
|
expect(selectCommand?.payload.sceneId).toEqual(4);
|
||||||
|
expect(numberCommand?.payload.speed).toEqual(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createXiaomiMiioDiscoveryDescriptor } from '../../ts/integrations/xiaomi_miio/index.js';
|
||||||
|
|
||||||
|
tap.test('matches Xiaomi Miio mDNS records', async () => {
|
||||||
|
const descriptor = createXiaomiMiioDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-mdns-match');
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
type: '_miio._udp.local.',
|
||||||
|
name: 'rockrobo-vacuum-v1_miio._udp.local.',
|
||||||
|
host: '192.168.1.50',
|
||||||
|
port: 54321,
|
||||||
|
txt: { poch: 'mac=286c0789abcd', did: '123456789' },
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('xiaomi_miio');
|
||||||
|
expect(result.candidate?.model).toEqual('rockrobo.vacuum.v1');
|
||||||
|
expect(result.candidate?.macAddress).toEqual('28:6c:07:89:ab:cd');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches manual host token entries and validates candidates', async () => {
|
||||||
|
const descriptor = createXiaomiMiioDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-manual-match');
|
||||||
|
const validator = descriptor.getValidators()[0];
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
host: '192.168.1.51',
|
||||||
|
token: '00112233445566778899aabbccddeeff',
|
||||||
|
model: 'zhimi.airpurifier.v2',
|
||||||
|
name: 'Bedroom purifier',
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.metadata?.tokenConfigured).toBeTrue();
|
||||||
|
const validation = await validator.validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
expect(validation.confidence).toEqual('certain');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches Xiaomi Miio DHCP records', async () => {
|
||||||
|
const descriptor = createXiaomiMiioDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'xiaomi-miio-dhcp-match');
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
ipAddress: '192.168.1.52',
|
||||||
|
hostname: 'roborock-vacuum',
|
||||||
|
manufacturer: 'Xiaomi',
|
||||||
|
macAddress: '28:6C:07:89:AB:CE',
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.host).toEqual('192.168.1.52');
|
||||||
|
expect(result.candidate?.macAddress).toEqual('28:6c:07:89:ab:ce');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { XiaomiMiioMapper } from '../../ts/integrations/xiaomi_miio/index.js';
|
||||||
|
|
||||||
|
const snapshot = XiaomiMiioMapper.toSnapshot({
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 'vacuum-1',
|
||||||
|
name: 'Roborock S5',
|
||||||
|
model: 'rockrobo.vacuum.v1',
|
||||||
|
state: { state_code: 5, battery: 82, clean_area: 22, clean_time: 1800, fan_speed: 'Balanced' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fan-1',
|
||||||
|
name: 'Bedroom purifier',
|
||||||
|
model: 'zhimi.airpurifier.v2',
|
||||||
|
state: { is_on: true, mode: 'auto', fan_level: 2, temperature: 22.4, humidity: 44, pm25: 6 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light-1',
|
||||||
|
name: 'Desk lamp',
|
||||||
|
model: 'philips.light.bulb',
|
||||||
|
state: { is_on: true, brightness: 80, color_temperature: 45 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plug-1',
|
||||||
|
name: 'Coffee plug',
|
||||||
|
model: 'chuangmi.plug.v3',
|
||||||
|
state: { is_on: false, temperature: 30, load_power: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cover-1',
|
||||||
|
name: 'Living room curtain',
|
||||||
|
kind: 'cover',
|
||||||
|
state: { position: 55 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'humidifier-1',
|
||||||
|
name: 'Nursery humidifier',
|
||||||
|
model: 'zhimi.humidifier.ca4',
|
||||||
|
state: { is_on: true, humidity: 41, target_humidity: 50, mode: 'Auto' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Xiaomi Miio device states to canonical devices and entities', async () => {
|
||||||
|
const devices = XiaomiMiioMapper.toDevices(snapshot);
|
||||||
|
const entities = XiaomiMiioMapper.toEntities(snapshot);
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'xiaomi_miio.device.vacuum_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'vacuum.roborock_s5' && entityArg.state === 'cleaning')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'fan.bedroom_purifier' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.desk_lamp' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.coffee_plug' && entityArg.state === 'off')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'cover.living_room_curtain' && entityArg.state === 'open')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'humidifier.nursery_humidifier' && entityArg.platform === 'climate')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_purifier_pm25' && entityArg.state === 6)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps vacuum and generic set_value service calls to client commands', async () => {
|
||||||
|
const vacuumCommand = XiaomiMiioMapper.commandForService(snapshot, {
|
||||||
|
domain: 'vacuum',
|
||||||
|
service: 'return_home',
|
||||||
|
target: { entityId: 'vacuum.roborock_s5' },
|
||||||
|
});
|
||||||
|
expect(vacuumCommand?.method).toEqual('return_home');
|
||||||
|
expect(vacuumCommand?.kind).toEqual('vacuum');
|
||||||
|
|
||||||
|
const valueCommand = XiaomiMiioMapper.commandForService(snapshot, {
|
||||||
|
domain: 'number',
|
||||||
|
service: 'set_value',
|
||||||
|
target: { entityId: 'number.bedroom_purifier_fan_level' },
|
||||||
|
data: { value: 3 },
|
||||||
|
});
|
||||||
|
expect(valueCommand?.method).toEqual('set_value');
|
||||||
|
expect(valueCommand?.payload.value).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createYeelightDiscoveryDescriptor } from '../../ts/integrations/yeelight/index.js';
|
||||||
|
|
||||||
|
tap.test('matches Yeelight SSDP wifi_bulb responses', async () => {
|
||||||
|
const descriptor = createYeelightDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[0];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
location: 'yeelight://192.168.1.25:55443',
|
||||||
|
id: '0x0000000002eb9f61',
|
||||||
|
model: 'color',
|
||||||
|
support: 'get_prop set_power set_bright set_rgb set_ct_abx',
|
||||||
|
st: 'wifi_bulb',
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.normalizedDeviceId).toEqual('0x0000000002eb9f61');
|
||||||
|
expect(result.candidate?.host).toEqual('192.168.1.25');
|
||||||
|
expect(result.candidate?.port).toEqual(55443);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches Yeelight mDNS records', async () => {
|
||||||
|
const descriptor = createYeelightDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers()[1];
|
||||||
|
const result = await matcher.matches({
|
||||||
|
name: 'yeelink-light-color1_mibt1234',
|
||||||
|
type: '_miio._udp.local.',
|
||||||
|
host: 'yeelight-kitchen.local',
|
||||||
|
txt: {
|
||||||
|
id: '0x0000000002eb9f62',
|
||||||
|
model: 'YLDP02YL',
|
||||||
|
},
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.manufacturer).toEqual('Yeelight');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { YeelightMapper } from '../../ts/integrations/yeelight/index.js';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
connected: true,
|
||||||
|
bulbs: [
|
||||||
|
{
|
||||||
|
id: '0x0000000002eb9f61',
|
||||||
|
host: '192.168.1.25',
|
||||||
|
port: 55443,
|
||||||
|
name: 'Kitchen Bulb',
|
||||||
|
model: 'color',
|
||||||
|
support: ['get_prop', 'set_power', 'set_bright', 'set_rgb', 'set_ct_abx'],
|
||||||
|
available: true,
|
||||||
|
properties: {
|
||||||
|
power: 'on',
|
||||||
|
bright: '80',
|
||||||
|
ct: '3700',
|
||||||
|
rgb: '16711680',
|
||||||
|
hue: '0',
|
||||||
|
sat: '100',
|
||||||
|
color_mode: '1',
|
||||||
|
flowing: '0',
|
||||||
|
nl_br: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('maps Yeelight bulbs to canonical devices and light/sensor entities', async () => {
|
||||||
|
const devices = YeelightMapper.toDevices(snapshot);
|
||||||
|
const entities = YeelightMapper.toEntities(snapshot);
|
||||||
|
expect(devices[0].id).toEqual('yeelight.bulb.0x0000000002eb9f61');
|
||||||
|
expect(devices[0].features.some((featureArg) => featureArg.id === 'color_temperature')).toBeTrue();
|
||||||
|
expect(entities[0].id).toEqual('light.kitchen_bulb');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.kitchen_bulb_color_temperature')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { createZhaDiscoveryDescriptor } from '../../ts/integrations/zha/index.js';
|
||||||
|
|
||||||
|
tap.test('matches known ZHA USB coordinator records', async () => {
|
||||||
|
const descriptor = createZhaDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'zha-usb-match');
|
||||||
|
const result = await matcher!.matches({
|
||||||
|
vid: '10C4',
|
||||||
|
pid: 'EA60',
|
||||||
|
manufacturer: 'SONOFF',
|
||||||
|
description: 'SONOFF Zigbee 3.0 USB Dongle Plus',
|
||||||
|
path: '/dev/ttyUSB0',
|
||||||
|
}, {});
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('zha');
|
||||||
|
expect(result.candidate?.metadata?.radioPath).toEqual('/dev/ttyUSB0');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ZhaMapper } from '../../ts/integrations/zha/index.js';
|
||||||
|
|
||||||
|
const snapshot = ZhaMapper.toSnapshot({
|
||||||
|
radio: { path: '/dev/ttyUSB0', radioType: 'znp' },
|
||||||
|
coordinator: { ieee: '00:11:22:33:44:55:66:77', model: 'CC2652' },
|
||||||
|
devices: [{
|
||||||
|
ieee: 'aa:bb:cc:dd:ee:ff:00:11',
|
||||||
|
name: 'Kitchen light',
|
||||||
|
manufacturer: 'IKEA',
|
||||||
|
model: 'TRADFRI bulb',
|
||||||
|
available: true,
|
||||||
|
entities: [{
|
||||||
|
platform: 'light',
|
||||||
|
entityId: 'light.kitchen_light',
|
||||||
|
uniqueId: 'zha_kitchen_light',
|
||||||
|
name: 'Kitchen light',
|
||||||
|
isOn: true,
|
||||||
|
endpointId: 1,
|
||||||
|
clusterId: 6,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps ZHA devices and entities', async () => {
|
||||||
|
const devices = ZhaMapper.toDevices(snapshot);
|
||||||
|
const entities = ZhaMapper.toEntities(snapshot);
|
||||||
|
expect(devices.some((deviceArg) => deviceArg.id === 'zha.device.aa_bb_cc_dd_ee_ff_00_11')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_light' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
+20
@@ -4,23 +4,43 @@ export * from './integrations/index.js';
|
|||||||
|
|
||||||
import { HueIntegration } from './integrations/hue/index.js';
|
import { HueIntegration } from './integrations/hue/index.js';
|
||||||
import { CastIntegration } from './integrations/cast/index.js';
|
import { CastIntegration } from './integrations/cast/index.js';
|
||||||
|
import { DeconzIntegration } from './integrations/deconz/index.js';
|
||||||
|
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||||
|
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||||
|
import { MatterIntegration } from './integrations/matter/index.js';
|
||||||
import { MqttIntegration } from './integrations/mqtt/index.js';
|
import { MqttIntegration } from './integrations/mqtt/index.js';
|
||||||
|
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||||
import { RokuIntegration } from './integrations/roku/index.js';
|
import { RokuIntegration } from './integrations/roku/index.js';
|
||||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||||
|
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
||||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||||
|
import { WizIntegration } from './integrations/wiz/index.js';
|
||||||
|
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js';
|
||||||
|
import { YeelightIntegration } from './integrations/yeelight/index.js';
|
||||||
|
import { ZhaIntegration } from './integrations/zha/index.js';
|
||||||
import { ZwaveJsIntegration } from './integrations/zwave_js/index.js';
|
import { ZwaveJsIntegration } from './integrations/zwave_js/index.js';
|
||||||
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||||
import { IntegrationRegistry } from './core/index.js';
|
import { IntegrationRegistry } from './core/index.js';
|
||||||
|
|
||||||
export const integrations = [
|
export const integrations = [
|
||||||
new CastIntegration(),
|
new CastIntegration(),
|
||||||
|
new DeconzIntegration(),
|
||||||
|
new EsphomeIntegration(),
|
||||||
|
new HomekitControllerIntegration(),
|
||||||
new HueIntegration(),
|
new HueIntegration(),
|
||||||
|
new MatterIntegration(),
|
||||||
new MqttIntegration(),
|
new MqttIntegration(),
|
||||||
|
new NanoleafIntegration(),
|
||||||
new RokuIntegration(),
|
new RokuIntegration(),
|
||||||
new ShellyIntegration(),
|
new ShellyIntegration(),
|
||||||
new SonosIntegration(),
|
new SonosIntegration(),
|
||||||
|
new TradfriIntegration(),
|
||||||
new WolfSmartsetIntegration(),
|
new WolfSmartsetIntegration(),
|
||||||
|
new WizIntegration(),
|
||||||
|
new XiaomiMiioIntegration(),
|
||||||
|
new YeelightIntegration(),
|
||||||
|
new ZhaIntegration(),
|
||||||
new ZwaveJsIntegration(),
|
new ZwaveJsIntegration(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,238 @@
|
|||||||
|
import type {
|
||||||
|
IDeconzConfig,
|
||||||
|
IDeconzGatewayConfig,
|
||||||
|
IDeconzGroupActionPatch,
|
||||||
|
IDeconzLightStatePatch,
|
||||||
|
IDeconzSensorConfigPatch,
|
||||||
|
IDeconzSensorStatePatch,
|
||||||
|
IDeconzSnapshot,
|
||||||
|
IDeconzWebsocketEvent,
|
||||||
|
} from './deconz.types.js';
|
||||||
|
|
||||||
|
type TWebSocketMessage = { data: unknown };
|
||||||
|
|
||||||
|
type TWebSocketLike = {
|
||||||
|
close(): void;
|
||||||
|
addEventListener?: (eventArg: 'message', handlerArg: (messageArg: TWebSocketMessage) => void) => void;
|
||||||
|
onmessage?: (messageArg: TWebSocketMessage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TWebSocketConstructor = new (urlArg: string) => TWebSocketLike;
|
||||||
|
|
||||||
|
export class DeconzClient {
|
||||||
|
constructor(private readonly config: IDeconzConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IDeconzSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
return this.config.snapshot;
|
||||||
|
}
|
||||||
|
return this.requestJson<IDeconzSnapshot>('GET', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getGatewayConfig(): Promise<IDeconzGatewayConfig> {
|
||||||
|
if (this.config.snapshot?.config) {
|
||||||
|
return this.config.snapshot.config;
|
||||||
|
}
|
||||||
|
return this.requestJson<IDeconzGatewayConfig>('GET', '/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setLightState(lightIdArg: string, stateArg: IDeconzLightStatePatch): Promise<void> {
|
||||||
|
if (this.canUseHttp()) {
|
||||||
|
await this.put(`/lights/${encodeURIComponent(lightIdArg)}/state`, stateArg);
|
||||||
|
}
|
||||||
|
this.applyLightState(lightIdArg, stateArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setGroupState(groupIdArg: string, actionArg: IDeconzGroupActionPatch): Promise<void> {
|
||||||
|
if (this.canUseHttp()) {
|
||||||
|
await this.put(`/groups/${encodeURIComponent(groupIdArg)}/action`, actionArg);
|
||||||
|
}
|
||||||
|
this.applyGroupState(groupIdArg, actionArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSensorConfig(sensorIdArg: string, configArg: IDeconzSensorConfigPatch): Promise<void> {
|
||||||
|
if (this.canUseHttp()) {
|
||||||
|
await this.put(`/sensors/${encodeURIComponent(sensorIdArg)}/config`, configArg);
|
||||||
|
}
|
||||||
|
this.applySensorConfig(sensorIdArg, configArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSensorState(sensorIdArg: string, stateArg: IDeconzSensorStatePatch): Promise<void> {
|
||||||
|
if (this.canUseHttp()) {
|
||||||
|
await this.put(`/sensors/${encodeURIComponent(sensorIdArg)}/state`, stateArg);
|
||||||
|
}
|
||||||
|
this.applySensorState(sensorIdArg, stateArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async recallScene(groupIdArg: string, sceneIdArg: string): Promise<void> {
|
||||||
|
if (this.canUseHttp()) {
|
||||||
|
await this.put(`/groups/${encodeURIComponent(groupIdArg)}/scenes/${encodeURIComponent(sceneIdArg)}/recall`, {});
|
||||||
|
}
|
||||||
|
const group = this.config.snapshot?.groups[groupIdArg];
|
||||||
|
if (group) {
|
||||||
|
group.action = { ...group.action, scene: sceneIdArg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async put<TResult = unknown>(pathArg: string, dataArg: Record<string, unknown>): Promise<TResult> {
|
||||||
|
return this.requestJson<TResult>('PUT', pathArg, dataArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async post<TResult = unknown>(pathArg: string, dataArg: Record<string, unknown>): Promise<TResult> {
|
||||||
|
return this.requestJson<TResult>('POST', pathArg, dataArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribeToEvents(handlerArg: (eventArg: IDeconzWebsocketEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
if (this.config.enableWebSocket === false || !this.config.host) {
|
||||||
|
return async () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: TWebSocketConstructor }).WebSocket;
|
||||||
|
if (!WebSocketCtor) {
|
||||||
|
return async () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocketPort = await this.resolveWebSocketPort();
|
||||||
|
if (!websocketPort) {
|
||||||
|
return async () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocketCtor(`ws://${this.config.host}:${websocketPort}`);
|
||||||
|
const listener = (messageArg: TWebSocketMessage) => {
|
||||||
|
const event = this.parseWebSocketMessage(messageArg.data);
|
||||||
|
if (event) {
|
||||||
|
handlerArg(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (socket.addEventListener) {
|
||||||
|
socket.addEventListener('message', listener);
|
||||||
|
} else {
|
||||||
|
socket.onmessage = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async requestJson<TResult>(methodArg: string, pathArg: string, dataArg?: Record<string, unknown>): Promise<TResult> {
|
||||||
|
if (!this.canUseHttp()) {
|
||||||
|
throw new Error('deCONZ host and apiKey are required when snapshot data is not provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await globalThis.fetch(`${this.baseUrl()}${this.apiPath(pathArg)}`, {
|
||||||
|
method: methodArg,
|
||||||
|
headers: dataArg ? { 'content-type': 'application/json' } : undefined,
|
||||||
|
body: dataArg ? JSON.stringify(dataArg) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const parsed = text ? JSON.parse(text) as unknown : undefined;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`deCONZ request ${methodArg} ${pathArg || '/'} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = this.findApiError(parsed);
|
||||||
|
if (apiError) {
|
||||||
|
throw new Error(`deCONZ request ${methodArg} ${pathArg || '/'} failed: ${apiError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl(): string {
|
||||||
|
const protocol = this.config.protocol || 'http';
|
||||||
|
const port = this.config.port ?? (protocol === 'https' ? 443 : 80);
|
||||||
|
const defaultPort = protocol === 'https' ? 443 : 80;
|
||||||
|
return `${protocol}://${this.config.host}${port === defaultPort ? '' : `:${port}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private apiPath(pathArg: string): string {
|
||||||
|
const suffix = pathArg.startsWith('/') ? pathArg : pathArg ? `/${pathArg}` : '';
|
||||||
|
return `/api/${encodeURIComponent(String(this.config.apiKey))}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canUseHttp(): boolean {
|
||||||
|
return Boolean(this.config.host && this.config.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveWebSocketPort(): Promise<number | undefined> {
|
||||||
|
if (this.config.websocketPort) {
|
||||||
|
return this.config.websocketPort;
|
||||||
|
}
|
||||||
|
if (this.config.snapshot?.config?.websocketport) {
|
||||||
|
return this.config.snapshot.config.websocketport;
|
||||||
|
}
|
||||||
|
if (!this.canUseHttp()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (await this.getGatewayConfig()).websocketport;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWebSocketMessage(dataArg: unknown): IDeconzWebsocketEvent | undefined {
|
||||||
|
if (typeof dataArg === 'string') {
|
||||||
|
return JSON.parse(dataArg) as IDeconzWebsocketEvent;
|
||||||
|
}
|
||||||
|
if (this.isRecord(dataArg)) {
|
||||||
|
return dataArg as IDeconzWebsocketEvent;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLightState(lightIdArg: string, stateArg: IDeconzLightStatePatch): void {
|
||||||
|
const light = this.config.snapshot?.lights[lightIdArg];
|
||||||
|
if (light) {
|
||||||
|
light.state = { ...light.state, ...stateArg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyGroupState(groupIdArg: string, actionArg: IDeconzGroupActionPatch): void {
|
||||||
|
const group = this.config.snapshot?.groups[groupIdArg];
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
group.action = { ...group.action, ...actionArg };
|
||||||
|
if (typeof actionArg.on === 'boolean') {
|
||||||
|
group.state = {
|
||||||
|
...group.state,
|
||||||
|
any_on: actionArg.on,
|
||||||
|
all_on: actionArg.on,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySensorConfig(sensorIdArg: string, configArg: IDeconzSensorConfigPatch): void {
|
||||||
|
const sensor = this.config.snapshot?.sensors[sensorIdArg];
|
||||||
|
if (sensor) {
|
||||||
|
sensor.config = { ...sensor.config, ...configArg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySensorState(sensorIdArg: string, stateArg: IDeconzSensorStatePatch): void {
|
||||||
|
const sensor = this.config.snapshot?.sensors[sensorIdArg];
|
||||||
|
if (sensor) {
|
||||||
|
sensor.state = { ...sensor.state, ...stateArg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findApiError(valueArg: unknown): string | undefined {
|
||||||
|
if (!Array.isArray(valueArg)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const item of valueArg) {
|
||||||
|
if (!this.isRecord(item) || !this.isRecord(item.error)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const description = item.error.description;
|
||||||
|
return typeof description === 'string' ? description : JSON.stringify(item.error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IDeconzConfig } from './deconz.types.js';
|
||||||
|
|
||||||
|
export class DeconzConfigFlow implements IConfigFlow<IDeconzConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDeconzConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect deCONZ Gateway',
|
||||||
|
description: 'Configure the local deCONZ REST API endpoint. Leave API key empty only if setup will use a snapshot fixture.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number' },
|
||||||
|
{ name: 'apiKey', label: 'API key', type: 'password' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = String(valuesArg.host || candidateArg.host || '');
|
||||||
|
if (!host) {
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
title: 'deCONZ configuration incomplete',
|
||||||
|
error: 'Host is required.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'deCONZ configured',
|
||||||
|
config: {
|
||||||
|
bridgeId: candidateArg.id,
|
||||||
|
host,
|
||||||
|
port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || 80,
|
||||||
|
apiKey: valuesArg.apiKey ? String(valuesArg.apiKey) : undefined,
|
||||||
|
protocol: 'http',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,275 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
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 { DeconzClient } from './deconz.classes.client.js';
|
||||||
|
import { DeconzConfigFlow } from './deconz.classes.configflow.js';
|
||||||
|
import { createDeconzDiscoveryDescriptor } from './deconz.discovery.js';
|
||||||
|
import { DeconzMapper } from './deconz.mapper.js';
|
||||||
|
import type { IDeconzConfig, IDeconzGroupActionPatch, IDeconzLightStatePatch, IDeconzSensorConfigPatch, IDeconzSensorStatePatch } from './deconz.types.js';
|
||||||
|
|
||||||
export class HomeAssistantDeconzIntegration extends DescriptorOnlyIntegration {
|
interface IDeconzServiceTarget {
|
||||||
constructor() {
|
entity: IIntegrationEntity;
|
||||||
super({
|
resource: string;
|
||||||
domain: "deconz",
|
resourceId: string;
|
||||||
displayName: "deCONZ",
|
stateKey?: string;
|
||||||
status: 'descriptor-only',
|
configKey?: string;
|
||||||
metadata: {
|
}
|
||||||
"source": "home-assistant/core",
|
|
||||||
"upstreamPath": "homeassistant/components/deconz",
|
export class DeconzIntegration extends BaseIntegration<IDeconzConfig> {
|
||||||
"upstreamDomain": "deconz",
|
public readonly domain = 'deconz';
|
||||||
"integrationType": "hub",
|
public readonly displayName = 'deCONZ';
|
||||||
"iotClass": "local_push",
|
public readonly status = 'control-runtime' as const;
|
||||||
"requirements": [
|
public readonly discoveryDescriptor = createDeconzDiscoveryDescriptor();
|
||||||
"pydeconz==120"
|
public readonly configFlow = new DeconzConfigFlow();
|
||||||
],
|
public readonly metadata = {
|
||||||
"dependencies": [],
|
source: 'home-assistant/core',
|
||||||
"afterDependencies": [],
|
upstreamPath: 'homeassistant/components/deconz',
|
||||||
"codeowners": [
|
upstreamDomain: 'deconz',
|
||||||
"@Kane610"
|
integrationType: 'hub',
|
||||||
]
|
iotClass: 'local_push',
|
||||||
},
|
requirements: ['pydeconz==120'],
|
||||||
});
|
codeowners: ['@Kane610'],
|
||||||
|
documentation: 'https://www.home-assistant.io/integrations/deconz',
|
||||||
|
restDocumentation: 'https://dresden-elektronik.github.io/deconz-rest-doc/',
|
||||||
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: IDeconzConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new DeconzRuntime(new DeconzClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantDeconzIntegration extends DeconzIntegration {}
|
||||||
|
|
||||||
|
class DeconzRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'deconz';
|
||||||
|
|
||||||
|
constructor(private readonly client: DeconzClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return DeconzMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return DeconzMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: Parameters<NonNullable<IIntegrationRuntime['subscribe']>>[0]): Promise<() => Promise<void>> {
|
||||||
|
return this.client.subscribeToEvents((eventArg) => handlerArg(DeconzMapper.toIntegrationEvent(eventArg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, error: 'deCONZ service calls require a known target entityId or deviceId.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'light') {
|
||||||
|
return this.callLightService(target, requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'switch') {
|
||||||
|
return this.callSwitchService(target, requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'cover') {
|
||||||
|
return this.callCoverService(target, requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'sensor' || requestArg.domain === 'binary_sensor' || requestArg.domain === 'climate') {
|
||||||
|
return this.callSensorService(target, requestArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported deCONZ service domain: ${requestArg.domain}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callLightService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (targetArg.resource !== 'lights' && targetArg.resource !== 'groups') {
|
||||||
|
return { success: false, error: 'deCONZ light services require a light or group target.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.lightPayload(requestArg);
|
||||||
|
if (!payload) {
|
||||||
|
return { success: false, error: `Unsupported deCONZ light service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetArg.resource === 'groups') {
|
||||||
|
await this.client.setGroupState(targetArg.resourceId, payload);
|
||||||
|
} else {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, payload);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSwitchService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (targetArg.resource !== 'lights') {
|
||||||
|
return { success: false, error: 'deCONZ switch services require a light-backed switch target.' };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { on: true });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { on: false });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported deCONZ switch service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callCoverService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (targetArg.resource !== 'lights') {
|
||||||
|
return { success: false, error: 'deCONZ cover services require a light-backed cover target.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'open_cover') {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { open: true });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'close_cover') {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { open: false });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop_cover') {
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { lift: 'stop' });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_position' || requestArg.service === 'set_percentage') {
|
||||||
|
const position = this.numberFromData(requestArg.data, requestArg.service === 'set_position' ? ['position'] : ['percentage']);
|
||||||
|
if (position === undefined) {
|
||||||
|
return { success: false, error: `deCONZ ${requestArg.service} requires a numeric position or percentage.` };
|
||||||
|
}
|
||||||
|
await this.client.setLightState(targetArg.resourceId, { lift: this.clamp(100 - position, 0, 100) });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported deCONZ cover service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSensorService(targetArg: IDeconzServiceTarget, requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (targetArg.resource !== 'sensors') {
|
||||||
|
return { success: false, error: 'deCONZ sensor services require a sensor-backed target.' };
|
||||||
|
}
|
||||||
|
if (requestArg.service !== 'set_value') {
|
||||||
|
return { success: false, error: `Unsupported deCONZ ${requestArg.domain} service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = this.stringFromData(requestArg.data, ['field', 'attribute']) || targetArg.configKey || targetArg.stateKey;
|
||||||
|
if (!field) {
|
||||||
|
return { success: false, error: 'deCONZ set_value requires a field, attribute, or mapped sensor key.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.valueFromData(requestArg.data, ['value', 'temperature', 'target_temperature']);
|
||||||
|
if (value === undefined) {
|
||||||
|
return { success: false, error: 'deCONZ set_value requires a value.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetArg.configKey || this.isSensorConfigField(field)) {
|
||||||
|
await this.client.setSensorConfig(targetArg.resourceId, { [field]: this.normalizeSensorConfigValue(field, value) } as IDeconzSensorConfigPatch);
|
||||||
|
} else {
|
||||||
|
await this.client.setSensorState(targetArg.resourceId, { [field]: value } as IDeconzSensorStatePatch);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private lightPayload(requestArg: IServiceCallRequest): IDeconzLightStatePatch | IDeconzGroupActionPatch | undefined {
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
return { on: false };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
const payload: IDeconzLightStatePatch = { on: true };
|
||||||
|
const brightness = this.numberFromData(requestArg.data, ['brightness']);
|
||||||
|
const brightnessPct = this.numberFromData(requestArg.data, ['brightness_pct', 'percentage']);
|
||||||
|
const transition = this.numberFromData(requestArg.data, ['transition']);
|
||||||
|
const colorTemp = this.numberFromData(requestArg.data, ['color_temp', 'color_temp_mired']);
|
||||||
|
if (brightness !== undefined) {
|
||||||
|
payload.bri = this.clamp(Math.round(brightness), 0, 255);
|
||||||
|
} else if (brightnessPct !== undefined) {
|
||||||
|
payload.bri = this.percentToBri(brightnessPct);
|
||||||
|
}
|
||||||
|
if (transition !== undefined) {
|
||||||
|
payload.transitiontime = Math.round(transition * 10);
|
||||||
|
}
|
||||||
|
if (colorTemp !== undefined) {
|
||||||
|
payload.ct = Math.round(colorTemp);
|
||||||
|
}
|
||||||
|
if (Array.isArray(requestArg.data?.xy_color)) {
|
||||||
|
payload.xy = requestArg.data.xy_color as number[];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_percentage') {
|
||||||
|
const percentage = this.numberFromData(requestArg.data, ['percentage']);
|
||||||
|
if (percentage === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
on: percentage > 0,
|
||||||
|
bri: this.percentToBri(percentage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTarget(requestArg: IServiceCallRequest): Promise<IDeconzServiceTarget | undefined> {
|
||||||
|
const entities = DeconzMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId)
|
||||||
|
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain);
|
||||||
|
if (!entity) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = entity.attributes?.deconzResource;
|
||||||
|
const resourceId = entity.attributes?.deconzId;
|
||||||
|
if (typeof resource !== 'string' || typeof resourceId !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entity,
|
||||||
|
resource,
|
||||||
|
resourceId,
|
||||||
|
stateKey: typeof entity.attributes?.deconzStateKey === 'string' ? entity.attributes.deconzStateKey : undefined,
|
||||||
|
configKey: typeof entity.attributes?.deconzConfigKey === 'string' ? entity.attributes.deconzConfigKey : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): number | undefined {
|
||||||
|
const value = this.valueFromData(dataArg, keysArg);
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): string | undefined {
|
||||||
|
const value = this.valueFromData(dataArg, keysArg);
|
||||||
|
return typeof value === 'string' && value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): unknown {
|
||||||
|
for (const key of keysArg) {
|
||||||
|
if (dataArg && key in dataArg) {
|
||||||
|
return dataArg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSensorConfigField(fieldArg: string): boolean {
|
||||||
|
return new Set(['battery', 'coolsetpoint', 'fanmode', 'heatsetpoint', 'locked', 'mode', 'offset', 'on', 'preset', 'reachable', 'setvalve']).has(fieldArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSensorConfigValue(fieldArg: string, valueArg: unknown): unknown {
|
||||||
|
if ((fieldArg === 'heatsetpoint' || fieldArg === 'coolsetpoint') && typeof valueArg === 'number' && Math.abs(valueArg) < 100) {
|
||||||
|
return Math.round(valueArg * 100);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private percentToBri(valueArg: number): number {
|
||||||
|
return this.clamp(Math.round(valueArg / 100 * 255), 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.min(maxArg, Math.max(minArg, valueArg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IDeconzDiscoveryRecord, IDeconzManualEntry, IDeconzMdnsRecord, IDeconzSsdpRecord } from './deconz.types.js';
|
||||||
|
|
||||||
|
const DECONZ_MANUFACTURER = 'dresden elektronik';
|
||||||
|
const DECONZ_MANUFACTURER_URL = 'http://www.dresden-elektronik.de';
|
||||||
|
|
||||||
|
export class DeconzMdnsMatcher implements IDiscoveryMatcher<IDeconzMdnsRecord> {
|
||||||
|
public id = 'deconz-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize deCONZ and Phoscon mDNS records.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IDeconzMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
const txt = recordArg.txt || {};
|
||||||
|
const id = normalizeBridgeId(txt.bridgeid || txt.id || txt.serial || txt.serialnumber);
|
||||||
|
const metadata = [recordArg.name, recordArg.type, txt.modelid, txt.model, txt.manufacturer, txt.manufacturername].join(' ').toLowerCase();
|
||||||
|
const matched = recordArg.type?.toLowerCase() === '_deconz._tcp.local.' || hasDeconzText(metadata);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'mDNS record does not contain deCONZ or Phoscon metadata.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'mDNS record contains deCONZ or Phoscon metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
id,
|
||||||
|
host: recordArg.host,
|
||||||
|
port: recordArg.port || 80,
|
||||||
|
manufacturer: 'dresden elektronik',
|
||||||
|
model: txt.modelid || txt.model || 'deCONZ',
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeconzSsdpMatcher implements IDiscoveryMatcher<IDeconzSsdpRecord> {
|
||||||
|
public id = 'deconz-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize deCONZ SSDP records by dresden elektronik manufacturer metadata.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IDeconzSsdpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
const upnp = recordArg.upnp || {};
|
||||||
|
const manufacturerUrl = recordArg.manufacturerURL || upnp.manufacturerURL || upnp.manufacturerUrl || '';
|
||||||
|
const manufacturer = recordArg.manufacturer || upnp.manufacturer || '';
|
||||||
|
const model = recordArg.modelName || recordArg.modelNumber || upnp.modelName || upnp.modelNumber || '';
|
||||||
|
const friendlyName = recordArg.friendlyName || upnp.friendlyName || '';
|
||||||
|
const serial = recordArg.serialNumber || upnp.serialNumber || upnp.serial || upnp.UDN;
|
||||||
|
const id = normalizeBridgeId(serial);
|
||||||
|
const metadata = [manufacturerUrl, manufacturer, model, friendlyName].join(' ').toLowerCase();
|
||||||
|
const matched = manufacturerUrl.toLowerCase() === DECONZ_MANUFACTURER_URL || hasDeconzText(metadata);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'SSDP record does not contain deCONZ manufacturer metadata.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = parseEndpoint(recordArg.ssdpLocation || recordArg.location);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP record matches deCONZ manufacturer metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
id,
|
||||||
|
host: endpoint.host,
|
||||||
|
port: endpoint.port || 80,
|
||||||
|
manufacturer: DECONZ_MANUFACTURER,
|
||||||
|
model: model || 'deCONZ',
|
||||||
|
name: friendlyName || undefined,
|
||||||
|
metadata: {
|
||||||
|
manufacturer,
|
||||||
|
manufacturerUrl,
|
||||||
|
ssdpLocation: recordArg.ssdpLocation || recordArg.location,
|
||||||
|
upnp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeconzManualMatcher implements IDiscoveryMatcher<IDeconzManualEntry> {
|
||||||
|
public id = 'deconz-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual deCONZ setup entries by host and deCONZ metadata.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IDeconzManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
const metadata = [
|
||||||
|
inputArg.name,
|
||||||
|
inputArg.manufacturer,
|
||||||
|
inputArg.model,
|
||||||
|
inputArg.metadata ? JSON.stringify(inputArg.metadata) : '',
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host && (hasDeconzText(metadata) || inputArg.metadata?.deconz || inputArg.metadata?.phoscon));
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'Manual entry does not contain deCONZ setup hints.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizeBridgeId(inputArg.id);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'Manual entry can start deCONZ setup.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || 80,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || DECONZ_MANUFACTURER,
|
||||||
|
model: inputArg.model || 'deCONZ',
|
||||||
|
metadata: inputArg.metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeconzPhosconDiscoveryMatcher implements IDiscoveryMatcher<IDeconzDiscoveryRecord> {
|
||||||
|
public id = 'deconz-phoscon-discovery-match';
|
||||||
|
public source = 'broker' as const;
|
||||||
|
public description = 'Recognize Phoscon discovery broker records.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IDeconzDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
const host = recordArg.internalipaddress;
|
||||||
|
if (!host) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'Phoscon discovery record does not include an internal IP address.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizeBridgeId(recordArg.id || recordArg.macaddress);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'Phoscon discovery record contains a local deCONZ gateway endpoint.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'broker',
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port: Number(recordArg.internalport || 80),
|
||||||
|
name: recordArg.name,
|
||||||
|
manufacturer: DECONZ_MANUFACTURER,
|
||||||
|
model: 'deCONZ',
|
||||||
|
macAddress: recordArg.macaddress,
|
||||||
|
metadata: { discovery: recordArg },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeconzCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'deconz-candidate-validator';
|
||||||
|
public description = 'Validate deCONZ candidates before starting local setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
const metadata = [
|
||||||
|
candidateArg.integrationDomain,
|
||||||
|
candidateArg.manufacturer,
|
||||||
|
candidateArg.model,
|
||||||
|
candidateArg.name,
|
||||||
|
candidateArg.metadata ? JSON.stringify(candidateArg.metadata) : '',
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
const matched = candidateArg.integrationDomain === 'deconz' || hasDeconzText(metadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has deCONZ metadata.' : 'Candidate is not deCONZ.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: normalizeBridgeId(candidateArg.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDeconzDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
displayName: 'deCONZ',
|
||||||
|
})
|
||||||
|
.addMatcher(new DeconzMdnsMatcher())
|
||||||
|
.addMatcher(new DeconzSsdpMatcher())
|
||||||
|
.addMatcher(new DeconzManualMatcher())
|
||||||
|
.addMatcher(new DeconzPhosconDiscoveryMatcher())
|
||||||
|
.addValidator(new DeconzCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeBridgeId = (valueArg?: string): string | undefined => {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const cleaned = valueArg.replace(/[^a-fA-F0-9]/g, '').toUpperCase();
|
||||||
|
return cleaned.length >= 8 ? cleaned : valueArg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDeconzText = (valueArg: string): boolean => {
|
||||||
|
return valueArg.includes('deconz')
|
||||||
|
|| valueArg.includes('phoscon')
|
||||||
|
|| valueArg.includes('dresden')
|
||||||
|
|| valueArg.includes('conbee')
|
||||||
|
|| valueArg.includes('raspbee');
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEndpoint = (urlArg?: string): { host?: string; port?: number } => {
|
||||||
|
if (!urlArg) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(urlArg);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? Number(parsed.port) : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js';
|
||||||
|
import type { IDeconzGroup, IDeconzLight, IDeconzSensor, IDeconzSnapshot, IDeconzWebsocketEvent } from './deconz.types.js';
|
||||||
|
|
||||||
|
interface IDeconzSensorEntityDescription {
|
||||||
|
key: string;
|
||||||
|
platform: 'binary_sensor' | 'sensor';
|
||||||
|
stateKey?: string;
|
||||||
|
configKey?: string;
|
||||||
|
nameSuffix?: string;
|
||||||
|
unit?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
value: (sensorArg: IDeconzSensor) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POWER_PLUG_TYPES = new Set([
|
||||||
|
'on/off light',
|
||||||
|
'on/off output',
|
||||||
|
'on/off plug-in unit',
|
||||||
|
'smart plug',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const COVER_TYPES = new Set([
|
||||||
|
'level controllable output',
|
||||||
|
'window covering controller',
|
||||||
|
'window covering device',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BINARY_SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [
|
||||||
|
{ key: 'alarm', platform: 'binary_sensor', stateKey: 'alarm', deviceClass: 'safety', value: (sensorArg) => sensorArg.state?.alarm },
|
||||||
|
{ key: 'carbon_monoxide', platform: 'binary_sensor', stateKey: 'carbonmonoxide', deviceClass: 'carbon_monoxide', value: (sensorArg) => sensorArg.state?.carbonmonoxide },
|
||||||
|
{ key: 'fire', platform: 'binary_sensor', stateKey: 'fire', deviceClass: 'smoke', value: (sensorArg) => sensorArg.state?.fire },
|
||||||
|
{ key: 'flag', platform: 'binary_sensor', stateKey: 'flag', value: (sensorArg) => sensorArg.state?.flag },
|
||||||
|
{ key: 'open', platform: 'binary_sensor', stateKey: 'open', deviceClass: 'opening', value: (sensorArg) => sensorArg.state?.open },
|
||||||
|
{ key: 'presence', platform: 'binary_sensor', stateKey: 'presence', deviceClass: 'motion', value: (sensorArg) => sensorArg.state?.presence },
|
||||||
|
{ key: 'vibration', platform: 'binary_sensor', stateKey: 'vibration', deviceClass: 'vibration', value: (sensorArg) => sensorArg.state?.vibration },
|
||||||
|
{ key: 'water', platform: 'binary_sensor', stateKey: 'water', deviceClass: 'moisture', value: (sensorArg) => sensorArg.state?.water },
|
||||||
|
{ key: 'tampered', platform: 'binary_sensor', configKey: 'tampered', nameSuffix: 'Tampered', deviceClass: 'tamper', value: (sensorArg) => sensorArg.config?.tampered },
|
||||||
|
{ key: 'low_battery', platform: 'binary_sensor', configKey: 'lowbattery', nameSuffix: 'Low Battery', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.lowbattery },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [
|
||||||
|
{ key: 'air_quality', platform: 'sensor', stateKey: 'airquality', value: (sensorArg) => sensorArg.state?.airquality },
|
||||||
|
{ key: 'air_quality_ppb', platform: 'sensor', stateKey: 'airqualityppb', nameSuffix: 'PPB', unit: 'ppb', value: (sensorArg) => sensorArg.state?.airqualityppb },
|
||||||
|
{ key: 'battery', platform: 'sensor', configKey: 'battery', nameSuffix: 'Battery', unit: '%', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.battery ?? sensorArg.state?.battery },
|
||||||
|
{ key: 'button_event', platform: 'sensor', stateKey: 'buttonevent', nameSuffix: 'Button Event', value: (sensorArg) => sensorArg.state?.buttonevent },
|
||||||
|
{ key: 'consumption', platform: 'sensor', stateKey: 'consumption', unit: 'kWh', deviceClass: 'energy', value: (sensorArg) => DeconzMapper.scaleConsumption(sensorArg.state?.consumption) },
|
||||||
|
{ key: 'current', platform: 'sensor', stateKey: 'current', unit: 'A', value: (sensorArg) => sensorArg.state?.current },
|
||||||
|
{ key: 'humidity', platform: 'sensor', stateKey: 'humidity', unit: '%', deviceClass: 'humidity', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.humidity) },
|
||||||
|
{ key: 'light_level', platform: 'sensor', stateKey: 'lightlevel', unit: 'lx', deviceClass: 'illuminance', value: (sensorArg) => DeconzMapper.lightLevel(sensorArg) },
|
||||||
|
{ key: 'power', platform: 'sensor', stateKey: 'power', unit: 'W', deviceClass: 'power', value: (sensorArg) => sensorArg.state?.power },
|
||||||
|
{ key: 'pressure', platform: 'sensor', stateKey: 'pressure', unit: 'hPa', deviceClass: 'pressure', value: (sensorArg) => sensorArg.state?.pressure },
|
||||||
|
{ key: 'status', platform: 'sensor', stateKey: 'status', value: (sensorArg) => sensorArg.state?.status },
|
||||||
|
{ key: 'temperature', platform: 'sensor', stateKey: 'temperature', unit: 'C', deviceClass: 'temperature', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.temperature) },
|
||||||
|
{ key: 'voltage', platform: 'sensor', stateKey: 'voltage', unit: 'V', deviceClass: 'voltage', value: (sensorArg) => sensorArg.state?.voltage },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class DeconzMapper {
|
||||||
|
public static toDevices(snapshotArg: IDeconzSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
|
||||||
|
const bridgeId = this.bridgeId(snapshotArg);
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: this.gatewayDeviceId(snapshotArg),
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
name: snapshotArg.config?.name || snapshotArg.config?.devicename || 'deCONZ Gateway',
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: 'dresden elektronik',
|
||||||
|
model: snapshotArg.config?.devicename || snapshotArg.config?.modelid || 'deCONZ',
|
||||||
|
online: snapshotArg.config?.rfconnected !== false,
|
||||||
|
features: [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'connectivity', value: snapshotArg.config?.rfconnected === false ? 'offline' : 'online', updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
bridgeId,
|
||||||
|
apiVersion: snapshotArg.config?.apiversion,
|
||||||
|
softwareVersion: snapshotArg.config?.swversion,
|
||||||
|
websocketPort: snapshotArg.config?.websocketport,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [lightId, light] of Object.entries(snapshotArg.lights)) {
|
||||||
|
devices.push(this.lightToDevice(lightId, light, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [groupId, group] of Object.entries(snapshotArg.groups)) {
|
||||||
|
if (!group.lights?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
devices.push(this.groupToDevice(bridgeId, groupId, group, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) {
|
||||||
|
devices.push(this.sensorToDevice(sensorId, sensor, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IDeconzSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const usedIds = new Map<string, number>();
|
||||||
|
const bridgeId = this.bridgeId(snapshotArg);
|
||||||
|
|
||||||
|
for (const [lightId, light] of Object.entries(snapshotArg.lights)) {
|
||||||
|
entities.push(this.lightToEntity(lightId, light, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [groupId, group] of Object.entries(snapshotArg.groups)) {
|
||||||
|
if (!group.lights?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entities.push(this.groupToEntity(bridgeId, groupId, group, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) {
|
||||||
|
if (this.isThermostat(sensor)) {
|
||||||
|
entities.push(this.climateToEntity(sensorId, sensor, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) {
|
||||||
|
if (this.isThermostat(sensor) && description.key === 'temperature') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = description.value(sensor);
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entities.push(this.sensorToEntity(sensorId, sensor, description, value, usedIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IDeconzWebsocketEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: eventArg.e === 'added' ? 'device_added' : eventArg.e === 'deleted' ? 'device_removed' : 'state_changed',
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static scaleHundred(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.abs(valueArg) > 200 ? valueArg / 100 : valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static scaleConsumption(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.abs(valueArg) >= 1000 ? valueArg / 1000 : valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static lightLevel(sensorArg: IDeconzSensor): number | undefined {
|
||||||
|
if (typeof sensorArg.state?.lux === 'number') {
|
||||||
|
return sensorArg.state.lux;
|
||||||
|
}
|
||||||
|
if (typeof sensorArg.state?.lightlevel !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.round(Math.pow(10, (sensorArg.state.lightlevel - 1) / 10000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightToDevice(lightIdArg: string, lightArg: IDeconzLight, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: this.isLightAvailable(lightArg) ? 'online' : 'offline', updatedAt: updatedAtArg },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.isCoverLight(lightArg)) {
|
||||||
|
features.push({ id: 'position', capability: 'cover', name: 'Position', readable: true, writable: true, unit: '%' });
|
||||||
|
this.pushDeviceState(state, 'position', this.coverPosition(lightArg), updatedAtArg);
|
||||||
|
} else if (this.isSwitchLight(lightArg)) {
|
||||||
|
features.push({ id: 'on', capability: 'switch', name: 'Power', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg);
|
||||||
|
} else {
|
||||||
|
features.push({ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg);
|
||||||
|
if (typeof lightArg.state?.bri === 'number') {
|
||||||
|
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' });
|
||||||
|
this.pushDeviceState(state, 'brightness', Math.round(lightArg.state.bri / 255 * 100), updatedAtArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.lightDeviceId(lightIdArg, lightArg),
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
name: lightArg.name || `deCONZ Light ${lightIdArg}`,
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: lightArg.manufacturername || 'Unknown',
|
||||||
|
model: lightArg.modelid || lightArg.type,
|
||||||
|
online: this.isLightAvailable(lightArg),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
deconzId: lightIdArg,
|
||||||
|
uniqueId: lightArg.uniqueid,
|
||||||
|
type: lightArg.type,
|
||||||
|
softwareVersion: lightArg.swversion,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupToDevice(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: 'online', updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'on', value: groupArg.state?.any_on ?? groupArg.action?.on ?? false, updatedAt: updatedAtArg },
|
||||||
|
];
|
||||||
|
if (typeof groupArg.action?.bri === 'number') {
|
||||||
|
state.push({ featureId: 'brightness', value: Math.round(groupArg.action.bri / 255 * 100), updatedAt: updatedAtArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.groupDeviceId(bridgeIdArg, groupIdArg),
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
name: groupArg.name || `deCONZ Group ${groupIdArg}`,
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: 'dresden elektronik',
|
||||||
|
model: 'deCONZ group',
|
||||||
|
online: true,
|
||||||
|
features: [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
{ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||||
|
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' },
|
||||||
|
],
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
bridgeId: bridgeIdArg,
|
||||||
|
deconzId: groupIdArg,
|
||||||
|
lights: groupArg.lights,
|
||||||
|
type: groupArg.type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorToDevice(sensorIdArg: string, sensorArg: IDeconzSensor, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: this.isSensorAvailable(sensorArg) ? 'online' : 'offline', updatedAt: updatedAtArg },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.isThermostat(sensorArg)) {
|
||||||
|
features.push({ id: 'target_temperature', capability: 'climate', name: 'Target Temperature', readable: true, writable: true, unit: 'C' });
|
||||||
|
features.push({ id: 'current_temperature', capability: 'climate', name: 'Current Temperature', readable: true, writable: false, unit: 'C' });
|
||||||
|
this.pushDeviceState(state, 'target_temperature', this.targetTemperature(sensorArg), updatedAtArg);
|
||||||
|
this.pushDeviceState(state, 'current_temperature', this.scaleHundred(sensorArg.state?.temperature), updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) {
|
||||||
|
if (this.isThermostat(sensorArg) && description.key === 'temperature') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = description.value(sensorArg);
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
features.push({
|
||||||
|
id: description.key,
|
||||||
|
capability: 'sensor',
|
||||||
|
name: this.entityName(sensorArg, description),
|
||||||
|
readable: true,
|
||||||
|
writable: Boolean(sensorArg.type?.startsWith('CLIP') && description.stateKey),
|
||||||
|
unit: description.unit,
|
||||||
|
});
|
||||||
|
this.pushDeviceState(state, description.key, description.platform === 'binary_sensor' ? Boolean(value) : value, updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.sensorDeviceId(sensorIdArg, sensorArg),
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
name: sensorArg.name || `deCONZ Sensor ${sensorIdArg}`,
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: sensorArg.manufacturername || 'Unknown',
|
||||||
|
model: sensorArg.modelid || sensorArg.type,
|
||||||
|
online: this.isSensorAvailable(sensorArg),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
deconzId: sensorIdArg,
|
||||||
|
uniqueId: sensorArg.uniqueid,
|
||||||
|
type: sensorArg.type,
|
||||||
|
softwareVersion: sensorArg.swversion,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightToEntity(lightIdArg: string, lightArg: IDeconzLight, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
const platform = this.isCoverLight(lightArg) ? 'cover' : this.isSwitchLight(lightArg) ? 'switch' : 'light';
|
||||||
|
const resourcePath = `/lights/${lightIdArg}/state`;
|
||||||
|
return {
|
||||||
|
id: this.entityId(platform, lightArg.name || `deCONZ Light ${lightIdArg}`, usedIdsArg),
|
||||||
|
uniqueId: `deconz_light_${this.slug(lightArg.uniqueid || lightIdArg)}`,
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
deviceId: this.lightDeviceId(lightIdArg, lightArg),
|
||||||
|
platform,
|
||||||
|
name: lightArg.name || `deCONZ Light ${lightIdArg}`,
|
||||||
|
state: platform === 'cover' ? this.coverState(lightArg) : lightArg.state?.on ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
deconzResource: 'lights',
|
||||||
|
deconzId: lightIdArg,
|
||||||
|
deconzPath: resourcePath,
|
||||||
|
uniqueId: lightArg.uniqueid,
|
||||||
|
type: lightArg.type,
|
||||||
|
brightness: lightArg.state?.bri,
|
||||||
|
colorMode: lightArg.state?.colormode,
|
||||||
|
colorTemperature: lightArg.state?.ct,
|
||||||
|
position: platform === 'cover' ? this.coverPosition(lightArg) : undefined,
|
||||||
|
reachable: lightArg.state?.reachable,
|
||||||
|
},
|
||||||
|
available: this.isLightAvailable(lightArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupToEntity(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
return {
|
||||||
|
id: this.entityId('light', groupArg.name || `deCONZ Group ${groupIdArg}`, usedIdsArg),
|
||||||
|
uniqueId: `deconz_group_${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`,
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
deviceId: this.groupDeviceId(bridgeIdArg, groupIdArg),
|
||||||
|
platform: 'light',
|
||||||
|
name: groupArg.name || `deCONZ Group ${groupIdArg}`,
|
||||||
|
state: groupArg.state?.any_on || groupArg.action?.on ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
deconzResource: 'groups',
|
||||||
|
deconzId: groupIdArg,
|
||||||
|
deconzPath: `/groups/${groupIdArg}/action`,
|
||||||
|
isDeconzGroup: true,
|
||||||
|
allOn: groupArg.state?.all_on,
|
||||||
|
anyOn: groupArg.state?.any_on,
|
||||||
|
brightness: groupArg.action?.bri,
|
||||||
|
lights: groupArg.lights,
|
||||||
|
},
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorToEntity(
|
||||||
|
sensorIdArg: string,
|
||||||
|
sensorArg: IDeconzSensor,
|
||||||
|
descriptionArg: IDeconzSensorEntityDescription,
|
||||||
|
valueArg: unknown,
|
||||||
|
usedIdsArg: Map<string, number>
|
||||||
|
): IIntegrationEntity {
|
||||||
|
const name = this.entityName(sensorArg, descriptionArg);
|
||||||
|
return {
|
||||||
|
id: this.entityId(descriptionArg.platform, name, usedIdsArg),
|
||||||
|
uniqueId: `deconz_sensor_${this.slug(sensorArg.uniqueid || sensorIdArg)}_${descriptionArg.key}`,
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
deviceId: this.sensorDeviceId(sensorIdArg, sensorArg),
|
||||||
|
platform: descriptionArg.platform,
|
||||||
|
name,
|
||||||
|
state: descriptionArg.platform === 'binary_sensor' ? (valueArg ? 'on' : 'off') : valueArg,
|
||||||
|
attributes: {
|
||||||
|
deconzResource: 'sensors',
|
||||||
|
deconzId: sensorIdArg,
|
||||||
|
deconzPath: descriptionArg.configKey ? `/sensors/${sensorIdArg}/config` : `/sensors/${sensorIdArg}/state`,
|
||||||
|
deconzStateKey: descriptionArg.stateKey,
|
||||||
|
deconzConfigKey: descriptionArg.configKey,
|
||||||
|
deviceClass: descriptionArg.deviceClass,
|
||||||
|
unit: descriptionArg.unit,
|
||||||
|
type: sensorArg.type,
|
||||||
|
uniqueId: sensorArg.uniqueid,
|
||||||
|
lastUpdated: sensorArg.state?.lastupdated,
|
||||||
|
},
|
||||||
|
available: this.isSensorAvailable(sensorArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static climateToEntity(sensorIdArg: string, sensorArg: IDeconzSensor, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
return {
|
||||||
|
id: this.entityId('climate', sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`, usedIdsArg),
|
||||||
|
uniqueId: `deconz_climate_${this.slug(sensorArg.uniqueid || sensorIdArg)}`,
|
||||||
|
integrationDomain: 'deconz',
|
||||||
|
deviceId: this.sensorDeviceId(sensorIdArg, sensorArg),
|
||||||
|
platform: 'climate',
|
||||||
|
name: sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`,
|
||||||
|
state: this.climateMode(sensorArg),
|
||||||
|
attributes: {
|
||||||
|
deconzResource: 'sensors',
|
||||||
|
deconzId: sensorIdArg,
|
||||||
|
deconzPath: `/sensors/${sensorIdArg}/config`,
|
||||||
|
currentTemperature: this.scaleHundred(sensorArg.state?.temperature),
|
||||||
|
targetTemperature: this.targetTemperature(sensorArg),
|
||||||
|
fanMode: sensorArg.config?.fanmode,
|
||||||
|
preset: sensorArg.config?.preset,
|
||||||
|
locked: sensorArg.config?.locked,
|
||||||
|
valve: sensorArg.config?.valve,
|
||||||
|
mode: sensorArg.config?.mode,
|
||||||
|
},
|
||||||
|
available: this.isSensorAvailable(sensorArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushDeviceState(
|
||||||
|
stateArg: plugins.shxInterfaces.data.IDeviceState[],
|
||||||
|
featureIdArg: string,
|
||||||
|
valueArg: unknown,
|
||||||
|
updatedAtArg: string
|
||||||
|
): void {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateArg.push({ featureId: featureIdArg, value: valueArg as plugins.shxInterfaces.data.TDeviceStateValue, updatedAt: updatedAtArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityName(sensorArg: IDeconzSensor, descriptionArg: IDeconzSensorEntityDescription): string {
|
||||||
|
const baseName = sensorArg.name || 'deCONZ Sensor';
|
||||||
|
return descriptionArg.nameSuffix ? `${baseName} ${descriptionArg.nameSuffix}` : baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isLightAvailable(lightArg: IDeconzLight): boolean {
|
||||||
|
return lightArg.state?.reachable !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isSensorAvailable(sensorArg: IDeconzSensor): boolean {
|
||||||
|
return sensorArg.config?.reachable !== false && sensorArg.config?.on !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isSwitchLight(lightArg: IDeconzLight): boolean {
|
||||||
|
const type = lightArg.type?.toLowerCase() || '';
|
||||||
|
return POWER_PLUG_TYPES.has(type) || type.includes('plug') || type.includes('outlet');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isCoverLight(lightArg: IDeconzLight): boolean {
|
||||||
|
const type = lightArg.type?.toLowerCase() || '';
|
||||||
|
return COVER_TYPES.has(type) || lightArg.state?.lift !== undefined || lightArg.state?.tilt !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isThermostat(sensorArg: IDeconzSensor): boolean {
|
||||||
|
const type = sensorArg.type?.toLowerCase() || '';
|
||||||
|
return type.includes('thermostat')
|
||||||
|
|| sensorArg.config?.heatsetpoint !== undefined
|
||||||
|
|| sensorArg.config?.coolsetpoint !== undefined
|
||||||
|
|| sensorArg.config?.mode !== undefined && sensorArg.state?.temperature !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coverState(lightArg: IDeconzLight): string {
|
||||||
|
if (lightArg.state?.open === false || lightArg.state?.lift === 100) {
|
||||||
|
return 'closed';
|
||||||
|
}
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coverPosition(lightArg: IDeconzLight): number | undefined {
|
||||||
|
if (typeof lightArg.state?.lift === 'number') {
|
||||||
|
return this.clamp(100 - lightArg.state.lift, 0, 100);
|
||||||
|
}
|
||||||
|
if (typeof lightArg.state?.open === 'boolean') {
|
||||||
|
return lightArg.state.open ? 100 : 0;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static climateMode(sensorArg: IDeconzSensor): string {
|
||||||
|
if (sensorArg.config?.mode) {
|
||||||
|
return sensorArg.config.mode;
|
||||||
|
}
|
||||||
|
return sensorArg.config?.on === false ? 'off' : 'heat';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static targetTemperature(sensorArg: IDeconzSensor): number | undefined {
|
||||||
|
if (sensorArg.config?.mode === 'cool' && typeof sensorArg.config.coolsetpoint === 'number') {
|
||||||
|
return this.scaleHundred(sensorArg.config.coolsetpoint);
|
||||||
|
}
|
||||||
|
if (typeof sensorArg.config?.heatsetpoint === 'number') {
|
||||||
|
return this.scaleHundred(sensorArg.config.heatsetpoint);
|
||||||
|
}
|
||||||
|
if (typeof sensorArg.config?.coolsetpoint === 'number') {
|
||||||
|
return this.scaleHundred(sensorArg.config.coolsetpoint);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static gatewayDeviceId(snapshotArg: IDeconzSnapshot): string {
|
||||||
|
return `deconz.gateway.${this.slug(this.bridgeId(snapshotArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightDeviceId(lightIdArg: string, lightArg: IDeconzLight): string {
|
||||||
|
return `deconz.light.${this.slug(this.serialFromUniqueId(lightArg.uniqueid) || lightArg.uniqueid || lightIdArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupDeviceId(bridgeIdArg: string, groupIdArg: string): string {
|
||||||
|
return `deconz.group.${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorDeviceId(sensorIdArg: string, sensorArg: IDeconzSensor): string {
|
||||||
|
return `deconz.sensor.${this.slug(this.serialFromUniqueId(sensorArg.uniqueid) || sensorArg.uniqueid || sensorIdArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bridgeId(snapshotArg: IDeconzSnapshot): string {
|
||||||
|
return snapshotArg.config?.bridgeid || snapshotArg.config?.uuid || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serialFromUniqueId(uniqueIdArg?: string): string | undefined {
|
||||||
|
return uniqueIdArg?.split('-')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityId(platformArg: string, nameArg: string, usedIdsArg: Map<string, number>): string {
|
||||||
|
const base = `${platformArg}.${this.slug(nameArg)}`;
|
||||||
|
const count = usedIdsArg.get(base) || 0;
|
||||||
|
usedIdsArg.set(base, count + 1);
|
||||||
|
return count === 0 ? base : `${base}_${count + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'deconz';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.min(maxArg, Math.max(minArg, valueArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,271 @@
|
|||||||
export interface IHomeAssistantDeconzConfig {
|
export type TDeconzProtocol = 'http' | 'https';
|
||||||
// TODO: replace with the TypeScript-native config for deconz.
|
|
||||||
|
export type TDeconzResource = 'config' | 'groups' | 'lights' | 'scenes' | 'sensors';
|
||||||
|
|
||||||
|
export type TDeconzWebsocketEventName = 'added' | 'changed' | 'deleted' | 'scene-called';
|
||||||
|
|
||||||
|
export type TDeconzCommandMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
|
||||||
|
export type TDeconzStateValue = string | number | boolean | null | undefined | number[] | string[] | Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface IDeconzConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
protocol?: TDeconzProtocol;
|
||||||
|
bridgeId?: string;
|
||||||
|
websocketPort?: number;
|
||||||
|
enableWebSocket?: boolean;
|
||||||
|
snapshot?: IDeconzSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzGatewayConfig {
|
||||||
|
apiversion?: string;
|
||||||
|
bridgeid?: string;
|
||||||
|
devicename?: string;
|
||||||
|
fwversion?: string;
|
||||||
|
ipaddress?: string;
|
||||||
|
mac?: string;
|
||||||
|
modelid?: string;
|
||||||
|
name?: string;
|
||||||
|
swversion?: string;
|
||||||
|
uuid?: string;
|
||||||
|
websocketnotifyall?: boolean;
|
||||||
|
websocketport?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IDeconzLightState {
|
||||||
|
alert?: string;
|
||||||
|
bri?: number;
|
||||||
|
colormode?: string;
|
||||||
|
ct?: number;
|
||||||
|
effect?: string;
|
||||||
|
hue?: number;
|
||||||
|
lift?: number | 'stop';
|
||||||
|
on?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
reachable?: boolean;
|
||||||
|
sat?: number;
|
||||||
|
speed?: number;
|
||||||
|
stop?: boolean;
|
||||||
|
tilt?: number;
|
||||||
|
xy?: [number, number] | number[];
|
||||||
|
[key: string]: TDeconzStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzLight {
|
||||||
|
id?: string;
|
||||||
|
colorcapabilities?: number;
|
||||||
|
ctmax?: number;
|
||||||
|
ctmin?: number;
|
||||||
|
etag?: string;
|
||||||
|
hascolor?: boolean;
|
||||||
|
lastannounced?: string;
|
||||||
|
lastseen?: string;
|
||||||
|
manufacturername?: string;
|
||||||
|
modelid?: string;
|
||||||
|
name?: string;
|
||||||
|
state?: IDeconzLightState;
|
||||||
|
swversion?: string;
|
||||||
|
type?: string;
|
||||||
|
uniqueid?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzGroupState {
|
||||||
|
all_on?: boolean;
|
||||||
|
any_on?: boolean;
|
||||||
|
[key: string]: TDeconzStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSceneReference {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzGroup {
|
||||||
|
id?: string;
|
||||||
|
action?: IDeconzLightState;
|
||||||
|
devicemembership?: string[];
|
||||||
|
etag?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
lights?: string[];
|
||||||
|
lightsequence?: string[];
|
||||||
|
multideviceids?: string[];
|
||||||
|
name?: string;
|
||||||
|
scenes?: IDeconzSceneReference[] | Record<string, IDeconzSceneReference>;
|
||||||
|
state?: IDeconzGroupState;
|
||||||
|
type?: string;
|
||||||
|
class?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSensorState {
|
||||||
|
airquality?: string;
|
||||||
|
airqualityppb?: number;
|
||||||
|
alarm?: boolean;
|
||||||
|
battery?: number;
|
||||||
|
buttonevent?: number | null;
|
||||||
|
carbonmonoxide?: boolean;
|
||||||
|
consumption?: number;
|
||||||
|
current?: number;
|
||||||
|
dark?: boolean;
|
||||||
|
daylight?: boolean;
|
||||||
|
fire?: boolean;
|
||||||
|
flag?: boolean;
|
||||||
|
gesture?: number;
|
||||||
|
humidity?: number;
|
||||||
|
lastupdated?: string;
|
||||||
|
lightlevel?: number;
|
||||||
|
lux?: number;
|
||||||
|
open?: boolean;
|
||||||
|
power?: number;
|
||||||
|
presence?: boolean;
|
||||||
|
pressure?: number;
|
||||||
|
status?: number;
|
||||||
|
temperature?: number;
|
||||||
|
vibration?: boolean;
|
||||||
|
voltage?: number;
|
||||||
|
water?: boolean;
|
||||||
|
[key: string]: TDeconzStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSensorConfig {
|
||||||
|
battery?: number | null;
|
||||||
|
coolsetpoint?: number;
|
||||||
|
fanmode?: string;
|
||||||
|
heatsetpoint?: number;
|
||||||
|
locked?: boolean;
|
||||||
|
lowbattery?: boolean;
|
||||||
|
mode?: string;
|
||||||
|
offset?: number;
|
||||||
|
on?: boolean;
|
||||||
|
preset?: string;
|
||||||
|
reachable?: boolean;
|
||||||
|
tampered?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
valve?: number;
|
||||||
|
[key: string]: TDeconzStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSensor {
|
||||||
|
id?: string;
|
||||||
|
config?: IDeconzSensorConfig;
|
||||||
|
ep?: number;
|
||||||
|
etag?: string;
|
||||||
|
lastseen?: string;
|
||||||
|
manufacturername?: string;
|
||||||
|
mode?: number;
|
||||||
|
modelid?: string;
|
||||||
|
name?: string;
|
||||||
|
state?: IDeconzSensorState;
|
||||||
|
swversion?: string;
|
||||||
|
type?: string;
|
||||||
|
uniqueid?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSceneLightState {
|
||||||
|
id?: string;
|
||||||
|
bri?: number;
|
||||||
|
ct?: number;
|
||||||
|
hue?: number;
|
||||||
|
on?: boolean;
|
||||||
|
sat?: number;
|
||||||
|
transitiontime?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
xy?: [number, number] | number[];
|
||||||
|
[key: string]: TDeconzStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzScene {
|
||||||
|
id?: string;
|
||||||
|
groupId?: string;
|
||||||
|
lights?: string[] | IDeconzSceneLightState[];
|
||||||
|
name?: string;
|
||||||
|
state?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSnapshot {
|
||||||
|
config?: IDeconzGatewayConfig;
|
||||||
|
groups: Record<string, IDeconzGroup>;
|
||||||
|
lights: Record<string, IDeconzLight>;
|
||||||
|
sensors: Record<string, IDeconzSensor>;
|
||||||
|
scenes?: Record<string, IDeconzScene>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzLightStatePatch extends Partial<IDeconzLightState> {}
|
||||||
|
|
||||||
|
export interface IDeconzGroupActionPatch extends Partial<IDeconzLightState> {}
|
||||||
|
|
||||||
|
export interface IDeconzSensorConfigPatch extends Partial<IDeconzSensorConfig> {}
|
||||||
|
|
||||||
|
export interface IDeconzSensorStatePatch extends Partial<IDeconzSensorState> {}
|
||||||
|
|
||||||
|
export interface IDeconzCommand {
|
||||||
|
method: TDeconzCommandMethod;
|
||||||
|
path: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
resource?: TDeconzResource;
|
||||||
|
resourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzWebsocketEvent {
|
||||||
|
t?: 'event';
|
||||||
|
e: TDeconzWebsocketEventName;
|
||||||
|
r: 'groups' | 'lights' | 'scenes' | 'sensors';
|
||||||
|
id?: string;
|
||||||
|
gid?: string;
|
||||||
|
scid?: string;
|
||||||
|
uniqueid?: string;
|
||||||
|
name?: string;
|
||||||
|
config?: IDeconzSensorConfig;
|
||||||
|
state?: IDeconzGroupState | IDeconzLightState | IDeconzSensorState;
|
||||||
|
group?: IDeconzGroup;
|
||||||
|
light?: IDeconzLight;
|
||||||
|
sensor?: IDeconzSensor;
|
||||||
|
scene?: IDeconzScene;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzMdnsRecord {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzSsdpRecord {
|
||||||
|
ssdpLocation?: string;
|
||||||
|
location?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
manufacturerURL?: string;
|
||||||
|
modelName?: string;
|
||||||
|
modelNumber?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
upnp?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDeconzDiscoveryRecord {
|
||||||
|
id?: string;
|
||||||
|
internalipaddress?: string;
|
||||||
|
internalport?: string | number;
|
||||||
|
macaddress?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './deconz.classes.client.js';
|
||||||
|
export * from './deconz.classes.configflow.js';
|
||||||
export * from './deconz.classes.integration.js';
|
export * from './deconz.classes.integration.js';
|
||||||
|
export * from './deconz.discovery.js';
|
||||||
|
export * from './deconz.mapper.js';
|
||||||
export * from './deconz.types.js';
|
export * from './deconz.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,116 @@
|
|||||||
|
import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEvent, IEsphomeSnapshot } from './esphome.types.js';
|
||||||
|
import { EsphomeMapper } from './esphome.mapper.js';
|
||||||
|
|
||||||
|
type TEsphomeEventHandler = (eventArg: IEsphomeEvent) => void;
|
||||||
|
|
||||||
|
export class EsphomeClient {
|
||||||
|
private readonly events: IEsphomeEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TEsphomeEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IEsphomeConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IEsphomeSnapshot> {
|
||||||
|
return EsphomeMapper.toSnapshot(this.config, undefined, this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TEsphomeEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IEsphomeClientCommand): Promise<IEsphomeCommandResult> {
|
||||||
|
let result: IEsphomeCommandResult;
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: this.unsupportedLiveControlMessage(),
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.emit({
|
||||||
|
type: result.success ? 'command_mapped' : 'command_failed',
|
||||||
|
command: commandArg,
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
deviceId: this.stringValue(commandArg.deviceId),
|
||||||
|
entityId: commandArg.entityId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectLive(): Promise<void> {
|
||||||
|
await new EsphomeNativeApiConnection(this.config).connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IEsphomeEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: IEsphomeClientCommand): IEsphomeCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IEsphomeCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedLiveControlMessage(): string {
|
||||||
|
if (this.hasEncryptionKey()) {
|
||||||
|
return 'ESPHome live native API writes require protobuf framing plus Noise encryption support, which is not implemented. The mapped command was not sent.';
|
||||||
|
}
|
||||||
|
if (this.hasPassword()) {
|
||||||
|
return 'ESPHome live native API writes require protobuf framing plus legacy password login support, which is not implemented. The mapped command was not sent.';
|
||||||
|
}
|
||||||
|
return 'ESPHome live native API writes require protobuf framing, which is not implemented. The mapped command was not sent.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasEncryptionKey(): boolean {
|
||||||
|
return Boolean(this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasPassword(): boolean {
|
||||||
|
return Boolean(this.config.password || this.config.manualEntries?.some((entryArg) => entryArg.password));
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EsphomeNativeApiConnection {
|
||||||
|
constructor(private readonly config: IEsphomeConfig) {}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
throw new Error(this.unsupportedMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IEsphomeClientCommand): Promise<void> {
|
||||||
|
void commandArg;
|
||||||
|
throw new Error(this.unsupportedMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedMessage(): string {
|
||||||
|
if (this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk)) {
|
||||||
|
return 'Encrypted ESPHome native API uses Noise plus protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.';
|
||||||
|
}
|
||||||
|
return 'ESPHome native API uses protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IEsphomeConfig } from './esphome.types.js';
|
||||||
|
import { esphomeDefaultNativeApiPort } from './esphome.types.js';
|
||||||
|
|
||||||
|
export class EsphomeConfigFlow implements IConfigFlow<IEsphomeConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IEsphomeConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect ESPHome',
|
||||||
|
description: 'Configure an ESPHome native API device. Snapshot/manual data is supported; live protobuf control is reported explicitly if unavailable.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Native API port', type: 'number' },
|
||||||
|
{ name: 'encryptionKey', label: 'Encryption key', type: 'password' },
|
||||||
|
{ name: 'password', label: 'Legacy API password', type: 'password' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => ({
|
||||||
|
kind: 'done',
|
||||||
|
title: 'ESPHome configured',
|
||||||
|
config: {
|
||||||
|
host: this.stringValue(valuesArg.host) || candidateArg.host || '',
|
||||||
|
port: this.numberValue(valuesArg.port) || candidateArg.port || esphomeDefaultNativeApiPort,
|
||||||
|
name: candidateArg.name,
|
||||||
|
deviceName: this.stringValue(candidateArg.metadata?.deviceName),
|
||||||
|
encryptionKey: this.stringValue(valuesArg.encryptionKey) || this.stringValue(candidateArg.metadata?.encryptionKey),
|
||||||
|
noisePsk: this.stringValue(candidateArg.metadata?.noisePsk),
|
||||||
|
password: this.stringValue(valuesArg.password) || this.stringValue(candidateArg.metadata?.password),
|
||||||
|
deviceInfo: {
|
||||||
|
name: this.stringValue(candidateArg.metadata?.deviceName) || candidateArg.name,
|
||||||
|
friendlyName: candidateArg.name,
|
||||||
|
macAddress: candidateArg.macAddress,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
model: candidateArg.model,
|
||||||
|
host: candidateArg.host,
|
||||||
|
port: candidateArg.port || esphomeDefaultNativeApiPort,
|
||||||
|
apiEncryptionSupported: Boolean(candidateArg.metadata?.encryptionRequired),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,117 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { EsphomeClient } from './esphome.classes.client.js';
|
||||||
|
import { EsphomeConfigFlow } from './esphome.classes.configflow.js';
|
||||||
|
import { createEsphomeDiscoveryDescriptor } from './esphome.discovery.js';
|
||||||
|
import { EsphomeMapper } from './esphome.mapper.js';
|
||||||
|
import type { IEsphomeClientCommand, IEsphomeConfig } from './esphome.types.js';
|
||||||
|
|
||||||
export class HomeAssistantEsphomeIntegration extends DescriptorOnlyIntegration {
|
export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
|
||||||
constructor() {
|
public readonly domain = 'esphome';
|
||||||
super({
|
public readonly displayName = 'ESPHome';
|
||||||
domain: "esphome",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "ESPHome",
|
public readonly discoveryDescriptor = createEsphomeDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new EsphomeConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/esphome",
|
upstreamPath: 'homeassistant/components/esphome',
|
||||||
"upstreamDomain": "esphome",
|
upstreamDomain: 'esphome',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"qualityScale": "platinum",
|
qualityScale: 'platinum',
|
||||||
"requirements": [
|
requirements: [
|
||||||
"aioesphomeapi==44.21.0",
|
'aioesphomeapi==44.21.0',
|
||||||
"esphome-dashboard-api==1.3.0",
|
'esphome-dashboard-api==1.3.0',
|
||||||
"bleak-esphome==3.7.3"
|
'bleak-esphome==3.7.3',
|
||||||
],
|
],
|
||||||
"dependencies": [
|
dependencies: ['assist_pipeline', 'bluetooth', 'intent', 'ffmpeg', 'http'],
|
||||||
"assist_pipeline",
|
afterDependencies: ['hassio', 'tag', 'usb', 'zeroconf'],
|
||||||
"bluetooth",
|
codeowners: ['@jesserockz', '@kbx81', '@bdraco'],
|
||||||
"intent",
|
documentation: 'https://www.home-assistant.io/integrations/esphome',
|
||||||
"ffmpeg",
|
zeroconf: ['_esphomelib._tcp.local.'],
|
||||||
"http"
|
mqtt: ['esphome/discover/#'],
|
||||||
],
|
};
|
||||||
"afterDependencies": [
|
|
||||||
"hassio",
|
public async setup(configArg: IEsphomeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"tag",
|
void contextArg;
|
||||||
"usb",
|
return new EsphomeRuntime(new EsphomeClient(configArg));
|
||||||
"zeroconf"
|
}
|
||||||
],
|
|
||||||
"codeowners": [
|
public async destroy(): Promise<void> {}
|
||||||
"@jesserockz",
|
}
|
||||||
"@kbx81",
|
|
||||||
"@bdraco"
|
export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {}
|
||||||
]
|
|
||||||
},
|
class EsphomeRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'esphome';
|
||||||
|
|
||||||
|
constructor(private readonly client: EsphomeClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return EsphomeMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return EsphomeMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: eventArg.type === 'command_mapped' ? 'state_changed' : 'error',
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = requestArg.domain === 'esphome'
|
||||||
|
? this.esphomeCommandFromService(requestArg)
|
||||||
|
: EsphomeMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `ESPHome service ${requestArg.domain}.${requestArg.service} has no safe native command mapping.` };
|
||||||
|
}
|
||||||
|
const result = await this.client.sendCommand(command);
|
||||||
|
return { success: result.success, error: result.error, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private esphomeCommandFromService(requestArg: IServiceCallRequest): IEsphomeClientCommand | undefined {
|
||||||
|
if (requestArg.service === 'send_command' && this.isRecord(requestArg.data?.command)) {
|
||||||
|
return requestArg.data.command as unknown as IEsphomeClientCommand;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'execute_service' || requestArg.service === 'execute_action') {
|
||||||
|
const name = requestArg.data?.name;
|
||||||
|
if (typeof name !== 'string' || !name) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'execute_service',
|
||||||
|
service: name,
|
||||||
|
payload: this.isRecord(requestArg.data?.data) ? requestArg.data.data : {},
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'execute_service',
|
||||||
|
service: requestArg.service,
|
||||||
|
payload: requestArg.data || {},
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IEsphomeManualEntry, IEsphomeMdnsRecord } from './esphome.types.js';
|
||||||
|
import { esphomeDefaultNativeApiPort } from './esphome.types.js';
|
||||||
|
|
||||||
|
const esphomeMdnsTypes = new Set(['_esphomelib._tcp.local', '_esphome._tcp.local']);
|
||||||
|
|
||||||
|
export class EsphomeMdnsMatcher implements IDiscoveryMatcher<IEsphomeMdnsRecord> {
|
||||||
|
public id = 'esphome-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize ESPHome native API mDNS advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IEsphomeMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const mac = this.normalizeMac(this.txt(txt, 'mac'));
|
||||||
|
const deviceName = this.deviceName(recordArg, txt);
|
||||||
|
const matched = esphomeMdnsTypes.has(type) || Boolean(mac || this.txt(txt, 'api_encryption') || this.txt(txt, 'friendly_name'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not an ESPHome native API advertisement.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: mac ? 'certain' : esphomeMdnsTypes.has(type) ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches ESPHome native API metadata.',
|
||||||
|
normalizedDeviceId: mac || deviceName,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
id: mac || recordArg.name || recordArg.host,
|
||||||
|
host: recordArg.host || recordArg.hostname || recordArg.addresses?.[0],
|
||||||
|
port: recordArg.port || esphomeDefaultNativeApiPort,
|
||||||
|
name: this.txt(txt, 'friendly_name') || deviceName || 'ESPHome',
|
||||||
|
manufacturer: 'ESPHome',
|
||||||
|
model: 'Native API device',
|
||||||
|
macAddress: mac,
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
deviceName,
|
||||||
|
encryptionRequired: Boolean(this.txt(txt, 'api_encryption')),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
||||||
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceName(recordArg: IEsphomeMdnsRecord, txtArg: Record<string, string | undefined>): string | undefined {
|
||||||
|
const txtName = this.txt(txtArg, 'name');
|
||||||
|
if (txtName) {
|
||||||
|
return txtName;
|
||||||
|
}
|
||||||
|
const hostname = recordArg.hostname || recordArg.name;
|
||||||
|
return hostname?.replace(/\._?esphome(?:lib)?\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EsphomeManualMatcher implements IDiscoveryMatcher<IEsphomeManualEntry> {
|
||||||
|
public id = 'esphome-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual ESPHome native API setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IEsphomeManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.esphome || inputArg.metadata?.api_encryption || model.includes('esphome') || manufacturer.includes('esphome'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ESPHome setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start ESPHome native API setup.',
|
||||||
|
normalizedDeviceId: inputArg.id || inputArg.host,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
id: inputArg.id || inputArg.host,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || esphomeDefaultNativeApiPort,
|
||||||
|
name: inputArg.name || inputArg.deviceName || 'ESPHome',
|
||||||
|
manufacturer: inputArg.manufacturer || 'ESPHome',
|
||||||
|
model: inputArg.model || 'Native API device',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
deviceName: inputArg.deviceName,
|
||||||
|
password: inputArg.password,
|
||||||
|
encryptionKey: inputArg.encryptionKey,
|
||||||
|
noisePsk: inputArg.noisePsk,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EsphomeCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'esphome-candidate-validator';
|
||||||
|
public description = 'Validate ESPHome native API candidates.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const mdnsType = typeof candidateArg.metadata?.mdnsType === 'string' ? candidateArg.metadata.mdnsType.toLowerCase().replace(/\.$/, '') : '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'esphome' || candidateArg.port === esphomeDefaultNativeApiPort || esphomeMdnsTypes.has(mdnsType) || manufacturer.includes('esphome') || model.includes('esphome') || Boolean(candidateArg.metadata?.esphome || candidateArg.metadata?.api_encryption);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has ESPHome native API metadata.' : 'Candidate is not ESPHome.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEsphomeDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'esphome', displayName: 'ESPHome' })
|
||||||
|
.addMatcher(new EsphomeMdnsMatcher())
|
||||||
|
.addMatcher(new EsphomeManualMatcher())
|
||||||
|
.addValidator(new EsphomeCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type { IEsphomeAreaInfo, IEsphomeClientCommand, IEsphomeConfig, IEsphomeDeviceInfo, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeManualEntry, IEsphomeSnapshot } from './esphome.types.js';
|
||||||
|
import { esphomeDefaultNativeApiPort } from './esphome.types.js';
|
||||||
|
|
||||||
|
export class EsphomeMapper {
|
||||||
|
public static toSnapshot(configArg: IEsphomeConfig, connectedArg?: boolean, eventsArg: IEsphomeSnapshot['events'] = []): IEsphomeSnapshot {
|
||||||
|
const source = configArg.snapshot;
|
||||||
|
const manualEntry = configArg.manualEntries?.[0];
|
||||||
|
const deviceInfo = this.deviceInfoFromConfig(configArg, source?.deviceInfo, manualEntry);
|
||||||
|
return {
|
||||||
|
host: configArg.host || source?.host || manualEntry?.host || deviceInfo.host,
|
||||||
|
port: configArg.port || source?.port || manualEntry?.port || deviceInfo.port || esphomeDefaultNativeApiPort,
|
||||||
|
connected: connectedArg ?? source?.connected ?? false,
|
||||||
|
deviceInfo,
|
||||||
|
apiVersion: source?.apiVersion,
|
||||||
|
entities: [...(source?.entities || []), ...(configArg.entities || [])],
|
||||||
|
states: [
|
||||||
|
...this.normalizeStates(source?.states),
|
||||||
|
...this.normalizeStates(configArg.states),
|
||||||
|
],
|
||||||
|
services: [...(source?.services || []), ...(configArg.services || [])],
|
||||||
|
events: [...(source?.events || []), ...eventsArg],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDevices(snapshotArg: IEsphomeSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const deviceInfo = snapshotArg.deviceInfo;
|
||||||
|
const devices = new Map<string, plugins.shxInterfaces.data.IDeviceDefinition>();
|
||||||
|
const mainDeviceId = this.mainDeviceId(snapshotArg);
|
||||||
|
devices.set(mainDeviceId, {
|
||||||
|
id: mainDeviceId,
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
name: this.deviceName(deviceInfo, snapshotArg.host),
|
||||||
|
room: this.areaName(deviceInfo.area) || deviceInfo.suggestedArea,
|
||||||
|
protocol: 'esphome',
|
||||||
|
manufacturer: this.manufacturer(deviceInfo),
|
||||||
|
model: this.model(deviceInfo),
|
||||||
|
online: snapshotArg.connected || Boolean(snapshotArg.entities.length || snapshotArg.states.length),
|
||||||
|
features: [
|
||||||
|
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
|
||||||
|
{ id: 'entity_count', capability: 'sensor', name: 'Entity count', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt },
|
||||||
|
{ featureId: 'entity_count', value: snapshotArg.entities.length, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
macAddress: this.normalizeMac(deviceInfo.macAddress),
|
||||||
|
bluetoothMacAddress: this.normalizeMac(deviceInfo.bluetoothMacAddress),
|
||||||
|
host: snapshotArg.host,
|
||||||
|
port: snapshotArg.port,
|
||||||
|
esphomeVersion: deviceInfo.esphomeVersion,
|
||||||
|
compilationTime: deviceInfo.compilationTime,
|
||||||
|
projectName: deviceInfo.projectName,
|
||||||
|
projectVersion: deviceInfo.projectVersion,
|
||||||
|
apiEncryptionSupported: deviceInfo.apiEncryptionSupported,
|
||||||
|
usesPassword: deviceInfo.usesPassword,
|
||||||
|
webserverPort: deviceInfo.webserverPort,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subDevice of deviceInfo.devices || []) {
|
||||||
|
const subDeviceId = this.subDeviceIdValue(subDevice);
|
||||||
|
if (subDeviceId === undefined || subDeviceId === null || String(subDeviceId) === '0') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
devices.set(this.subDeviceId(snapshotArg, subDeviceId), {
|
||||||
|
id: this.subDeviceId(snapshotArg, subDeviceId),
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
name: subDevice.name || this.deviceName(deviceInfo, snapshotArg.host),
|
||||||
|
room: this.areaForSubDevice(deviceInfo, subDevice.areaId ?? subDevice.area_id),
|
||||||
|
protocol: 'esphome',
|
||||||
|
manufacturer: this.manufacturer(deviceInfo),
|
||||||
|
model: this.model(deviceInfo),
|
||||||
|
online: snapshotArg.connected || Boolean(snapshotArg.entities.length || snapshotArg.states.length),
|
||||||
|
features: [],
|
||||||
|
state: [],
|
||||||
|
metadata: {
|
||||||
|
parentDeviceId: mainDeviceId,
|
||||||
|
esphomeDeviceId: subDeviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of snapshotArg.entities) {
|
||||||
|
const deviceId = this.deviceIdForEntity(snapshotArg, entity);
|
||||||
|
const device = devices.get(deviceId) || devices.get(mainDeviceId);
|
||||||
|
if (!device) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const feature = this.featureForEntity(entity);
|
||||||
|
device.features.push(feature);
|
||||||
|
device.state.push({ featureId: feature.id, value: this.deviceStateValue(this.entityState(entity, this.stateForEntity(snapshotArg, entity))), updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...devices.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IEsphomeSnapshot): IIntegrationEntity[] {
|
||||||
|
return snapshotArg.entities.map((entityArg) => {
|
||||||
|
const state = this.stateForEntity(snapshotArg, entityArg);
|
||||||
|
const platform = this.corePlatform(entityArg.platform);
|
||||||
|
const name = this.entityName(entityArg);
|
||||||
|
return {
|
||||||
|
id: entityArg.entityId || entityArg.entity_id || `${platform}.${this.slug(`${this.deviceName(snapshotArg.deviceInfo, snapshotArg.host)} ${name}`)}`,
|
||||||
|
uniqueId: this.uniqueIdForEntity(snapshotArg, entityArg),
|
||||||
|
integrationDomain: 'esphome',
|
||||||
|
deviceId: this.deviceIdForEntity(snapshotArg, entityArg),
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
state: this.entityState(entityArg, state),
|
||||||
|
attributes: {
|
||||||
|
key: entityArg.key,
|
||||||
|
deviceId: entityArg.deviceId ?? entityArg.device_id,
|
||||||
|
esphomePlatform: entityArg.platform,
|
||||||
|
deviceClass: entityArg.deviceClass || entityArg.device_class,
|
||||||
|
unit: entityArg.unitOfMeasurement || entityArg.unit_of_measurement,
|
||||||
|
accuracyDecimals: entityArg.accuracyDecimals ?? entityArg.accuracy_decimals,
|
||||||
|
stateClass: entityArg.stateClass ?? entityArg.state_class,
|
||||||
|
entityCategory: entityArg.entityCategory || entityArg.entity_category,
|
||||||
|
icon: entityArg.icon,
|
||||||
|
options: entityArg.options,
|
||||||
|
effects: entityArg.effects,
|
||||||
|
rawState: state,
|
||||||
|
rawInfo: entityArg.raw,
|
||||||
|
},
|
||||||
|
available: !this.missingState(state),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IEsphomeSnapshot, requestArg: IServiceCallRequest): IEsphomeClientCommand | undefined {
|
||||||
|
const target = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
if (!target) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const platform = this.corePlatform(target.platform);
|
||||||
|
const payload = this.payloadForService(target, requestArg);
|
||||||
|
if (!payload) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: `${platform}.command`,
|
||||||
|
service: requestArg.service,
|
||||||
|
platform: target.platform,
|
||||||
|
key: target.key,
|
||||||
|
deviceId: target.deviceId ?? target.device_id,
|
||||||
|
entityId: target.entityId || target.entity_id,
|
||||||
|
uniqueId: this.uniqueIdForEntity(snapshotArg, target),
|
||||||
|
payload,
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static corePlatform(platformArg: string): TEntityPlatform {
|
||||||
|
const platform = platformArg.toLowerCase();
|
||||||
|
if (platform === 'text_sensor') {
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update'];
|
||||||
|
return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static uniqueIdForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): string {
|
||||||
|
const explicit = entityArg.uniqueId || entityArg.unique_id;
|
||||||
|
if (explicit) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const deviceIdentifier = this.compactDeviceIdentifier(snapshotArg.deviceInfo) || this.slug(snapshotArg.host || 'configured');
|
||||||
|
const objectId = entityArg.objectId || entityArg.object_id || this.slug(this.entityName(entityArg));
|
||||||
|
const subDevice = entityArg.deviceId ?? entityArg.device_id;
|
||||||
|
return `esphome_${this.slug(`${deviceIdentifier}_${entityArg.platform}_${objectId}_${entityArg.key}_${subDevice ?? 0}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceInfoFromConfig(configArg: IEsphomeConfig, sourceArg?: IEsphomeDeviceInfo, manualEntryArg?: IEsphomeManualEntry): IEsphomeDeviceInfo {
|
||||||
|
return {
|
||||||
|
...sourceArg,
|
||||||
|
...configArg.deviceInfo,
|
||||||
|
name: configArg.deviceInfo?.name || sourceArg?.name || configArg.deviceName || manualEntryArg?.deviceName || manualEntryArg?.name || configArg.name || configArg.host || manualEntryArg?.host || 'ESPHome',
|
||||||
|
friendlyName: configArg.deviceInfo?.friendlyName || sourceArg?.friendlyName || configArg.name || manualEntryArg?.name,
|
||||||
|
manufacturer: configArg.deviceInfo?.manufacturer || sourceArg?.manufacturer || manualEntryArg?.manufacturer,
|
||||||
|
model: configArg.deviceInfo?.model || sourceArg?.model || manualEntryArg?.model,
|
||||||
|
host: configArg.host || sourceArg?.host || manualEntryArg?.host || configArg.deviceInfo?.host,
|
||||||
|
port: configArg.port || sourceArg?.port || manualEntryArg?.port || configArg.deviceInfo?.port || esphomeDefaultNativeApiPort,
|
||||||
|
usesPassword: configArg.deviceInfo?.usesPassword ?? sourceArg?.usesPassword ?? Boolean(configArg.password || manualEntryArg?.password),
|
||||||
|
apiEncryptionSupported: configArg.deviceInfo?.apiEncryptionSupported ?? sourceArg?.apiEncryptionSupported ?? Boolean(configArg.encryptionKey || configArg.noisePsk || manualEntryArg?.encryptionKey || manualEntryArg?.noisePsk),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeStates(statesArg: IEsphomeConfig['states'] | IEsphomeSnapshot['states'] | undefined): IEsphomeEntityState[] {
|
||||||
|
if (!statesArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(statesArg)) {
|
||||||
|
return statesArg.map((stateArg) => ({ ...stateArg }));
|
||||||
|
}
|
||||||
|
return Object.entries(statesArg).map(([key, value]) => {
|
||||||
|
if (this.isRecord(value)) {
|
||||||
|
return { key, ...value } as IEsphomeEntityState;
|
||||||
|
}
|
||||||
|
return { key, state: value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stateForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): IEsphomeEntityState | undefined {
|
||||||
|
const uniqueId = this.uniqueIdForEntity(snapshotArg, entityArg);
|
||||||
|
const entityId = entityArg.entityId || entityArg.entity_id;
|
||||||
|
return snapshotArg.states.find((stateArg) => {
|
||||||
|
if ((stateArg.uniqueId || stateArg.unique_id) && (stateArg.uniqueId || stateArg.unique_id) === uniqueId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entityId && (stateArg.entityId || stateArg.entity_id) === entityId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const stateKey = stateArg.key;
|
||||||
|
if (stateKey === undefined || String(stateKey) !== String(entityArg.key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const statePlatform = stateArg.platform;
|
||||||
|
if (statePlatform && this.corePlatform(String(statePlatform)) !== this.corePlatform(entityArg.platform)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entityDeviceId = entityArg.deviceId ?? entityArg.device_id;
|
||||||
|
const stateDeviceId = stateArg.deviceId ?? stateArg.device_id;
|
||||||
|
return stateDeviceId === undefined || entityDeviceId === undefined || String(stateDeviceId) === String(entityDeviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityState(entityArg: IEsphomeEntityDescriptor, stateArg?: IEsphomeEntityState): unknown {
|
||||||
|
if (this.missingState(stateArg)) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
const rawValue = this.rawStateValue(stateArg);
|
||||||
|
const platform = this.corePlatform(entityArg.platform);
|
||||||
|
if (platform === 'light' || platform === 'switch' || platform === 'fan') {
|
||||||
|
if (typeof rawValue === 'boolean') {
|
||||||
|
return rawValue ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
if (this.isRecord(stateArg) && typeof stateArg.state === 'boolean') {
|
||||||
|
return stateArg.state ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'cover') {
|
||||||
|
const position = this.numberValue(stateArg?.position);
|
||||||
|
if (position !== undefined) {
|
||||||
|
return Math.round(position * 100);
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'boolean') {
|
||||||
|
return rawValue ? 'open' : 'closed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'climate') {
|
||||||
|
return stateArg?.mode ?? stateArg?.hvacMode ?? stateArg?.hvac_mode ?? rawValue ?? 'unknown';
|
||||||
|
}
|
||||||
|
if (platform === 'button') {
|
||||||
|
return rawValue ?? 'idle';
|
||||||
|
}
|
||||||
|
return rawValue ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static payloadForService(entityArg: IEsphomeEntityDescriptor, requestArg: IServiceCallRequest): Record<string, unknown> | undefined {
|
||||||
|
const platform = this.corePlatform(entityArg.platform);
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
if (!['light', 'switch', 'fan', 'climate'].includes(platform)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: true,
|
||||||
|
...this.lightPayload(requestArg),
|
||||||
|
...this.fanPayload(requestArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
if (!['light', 'switch', 'fan', 'climate'].includes(platform)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { state: false };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
if (!['number', 'text'].includes(platform) || requestArg.data?.value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { value: requestArg.data.value };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'press') {
|
||||||
|
return platform === 'button' ? { press: true } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_position') {
|
||||||
|
const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.positionPercentage ?? requestArg.data?.position_percentage);
|
||||||
|
return platform === 'cover' && position !== undefined ? { position: this.percentToFraction(position), positionPercentage: this.clampPercent(position) } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_percentage') {
|
||||||
|
const percentage = this.numberValue(requestArg.data?.percentage);
|
||||||
|
return platform === 'fan' && percentage !== undefined ? { state: percentage > 0, percentage: this.clampPercent(percentage) } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'select_option') {
|
||||||
|
const option = requestArg.data?.option;
|
||||||
|
return platform === 'select' && typeof option === 'string' ? { option } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'open_cover') {
|
||||||
|
return platform === 'cover' ? { position: 1, positionPercentage: 100 } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'close_cover') {
|
||||||
|
return platform === 'cover' ? { position: 0, positionPercentage: 0 } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop_cover') {
|
||||||
|
return platform === 'cover' ? { stop: true } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_temperature') {
|
||||||
|
const temperature = this.numberValue(requestArg.data?.temperature);
|
||||||
|
return platform === 'climate' && temperature !== undefined ? { targetTemperature: temperature } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetEntity(snapshotArg: IEsphomeSnapshot, requestArg: IServiceCallRequest): IEsphomeEntityDescriptor | undefined {
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||||
|
if (!entity) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const target = snapshotArg.entities.find((entityArg) => this.uniqueIdForEntity(snapshotArg, entityArg) === entity.uniqueId);
|
||||||
|
return target && this.serviceMatchesEntity(target, requestArg) ? target : undefined;
|
||||||
|
}
|
||||||
|
const candidates = requestArg.target.deviceId
|
||||||
|
? snapshotArg.entities.filter((entityArg) => this.deviceIdForEntity(snapshotArg, entityArg) === requestArg.target.deviceId)
|
||||||
|
: snapshotArg.entities;
|
||||||
|
return candidates.find((entityArg) => this.serviceMatchesEntity(entityArg, requestArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serviceMatchesEntity(entityArg: IEsphomeEntityDescriptor, requestArg: IServiceCallRequest): boolean {
|
||||||
|
if (!this.entityWritable(entityArg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const domainPlatform = this.domainPlatform(requestArg.domain);
|
||||||
|
if (domainPlatform && this.corePlatform(entityArg.platform) !== domainPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(this.payloadForService(entityArg, requestArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static domainPlatform(domainArg: string): TEntityPlatform | undefined {
|
||||||
|
if (domainArg === 'esphome') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update'];
|
||||||
|
return allowed.includes(domainArg as TEntityPlatform) ? domainArg as TEntityPlatform : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityWritable(entityArg: IEsphomeEntityDescriptor): boolean {
|
||||||
|
if (typeof entityArg.writable === 'boolean') {
|
||||||
|
return entityArg.writable;
|
||||||
|
}
|
||||||
|
return ['light', 'switch', 'fan', 'cover', 'climate', 'button', 'number', 'select', 'text'].includes(this.corePlatform(entityArg.platform));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static featureForEntity(entityArg: IEsphomeEntityDescriptor): plugins.shxInterfaces.data.IDeviceFeature {
|
||||||
|
const platform = this.corePlatform(entityArg.platform);
|
||||||
|
return {
|
||||||
|
id: this.slug(`${entityArg.platform}_${entityArg.objectId || entityArg.object_id || entityArg.key}`),
|
||||||
|
capability: this.capabilityForPlatform(platform),
|
||||||
|
name: this.entityName(entityArg),
|
||||||
|
readable: true,
|
||||||
|
writable: this.entityWritable(entityArg),
|
||||||
|
unit: entityArg.unitOfMeasurement || entityArg.unit_of_measurement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static capabilityForPlatform(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability {
|
||||||
|
if (platformArg === 'light') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
if (platformArg === 'cover') {
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
if (platformArg === 'climate') {
|
||||||
|
return 'climate';
|
||||||
|
}
|
||||||
|
if (platformArg === 'fan') {
|
||||||
|
return 'fan';
|
||||||
|
}
|
||||||
|
if (platformArg === 'switch' || platformArg === 'button' || platformArg === 'number' || platformArg === 'select' || platformArg === 'text') {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceIdForEntity(snapshotArg: IEsphomeSnapshot, entityArg: IEsphomeEntityDescriptor): string {
|
||||||
|
const deviceId = entityArg.deviceId ?? entityArg.device_id;
|
||||||
|
if (deviceId === undefined || deviceId === null || String(deviceId) === '0') {
|
||||||
|
return this.mainDeviceId(snapshotArg);
|
||||||
|
}
|
||||||
|
return this.subDeviceId(snapshotArg, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mainDeviceId(snapshotArg: IEsphomeSnapshot): string {
|
||||||
|
return `esphome.device.${this.slug(this.compactDeviceIdentifier(snapshotArg.deviceInfo) || snapshotArg.deviceInfo.name || snapshotArg.host || 'configured')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static subDeviceId(snapshotArg: IEsphomeSnapshot, subDeviceIdArg: string | number): string {
|
||||||
|
return `${this.mainDeviceId(snapshotArg)}.${this.slug(String(subDeviceIdArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static subDeviceIdValue(valueArg: { deviceId?: number | string; device_id?: number | string }): number | string | undefined {
|
||||||
|
return valueArg.deviceId ?? valueArg.device_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityName(entityArg: IEsphomeEntityDescriptor): string {
|
||||||
|
return entityArg.name || entityArg.objectId || entityArg.object_id || `${entityArg.platform} ${entityArg.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(deviceInfoArg: IEsphomeDeviceInfo, hostArg?: string): string {
|
||||||
|
return deviceInfoArg.friendlyName || deviceInfoArg.name || hostArg || 'ESPHome';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static manufacturer(deviceInfoArg: IEsphomeDeviceInfo): string {
|
||||||
|
if (deviceInfoArg.manufacturer) {
|
||||||
|
return deviceInfoArg.manufacturer;
|
||||||
|
}
|
||||||
|
if (deviceInfoArg.projectName) {
|
||||||
|
return deviceInfoArg.projectName.split('.')[0] || 'ESPHome';
|
||||||
|
}
|
||||||
|
return 'ESPHome';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static model(deviceInfoArg: IEsphomeDeviceInfo): string | undefined {
|
||||||
|
if (deviceInfoArg.model) {
|
||||||
|
return deviceInfoArg.model;
|
||||||
|
}
|
||||||
|
if (deviceInfoArg.projectName) {
|
||||||
|
return deviceInfoArg.projectName.split('.').slice(1).join('.') || deviceInfoArg.projectName;
|
||||||
|
}
|
||||||
|
return 'ESPHome node';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static areaForSubDevice(deviceInfoArg: IEsphomeDeviceInfo, areaIdArg: unknown): string | undefined {
|
||||||
|
const area = (deviceInfoArg.areas || []).find((areaArg) => String(areaArg.areaId ?? areaArg.area_id) === String(areaIdArg));
|
||||||
|
return this.areaName(area);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static areaName(areaArg?: IEsphomeAreaInfo): string | undefined {
|
||||||
|
return areaArg?.name || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rawStateValue(stateArg?: IEsphomeEntityState): unknown {
|
||||||
|
if (!stateArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if ('state' in stateArg) {
|
||||||
|
return stateArg.state;
|
||||||
|
}
|
||||||
|
if ('value' in stateArg) {
|
||||||
|
return stateArg.value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static missingState(stateArg?: IEsphomeEntityState): boolean {
|
||||||
|
return stateArg?.missingState === true || stateArg?.missing_state === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightPayload(requestArg: IServiceCallRequest): Record<string, unknown> {
|
||||||
|
const brightness = this.numberValue(requestArg.data?.brightness);
|
||||||
|
const transition = this.numberValue(requestArg.data?.transition);
|
||||||
|
const effect = requestArg.data?.effect;
|
||||||
|
return {
|
||||||
|
...(brightness !== undefined ? { brightness: this.clampPercent(brightness / 255 * 100) / 100 } : {}),
|
||||||
|
...(transition !== undefined ? { transitionLength: transition } : {}),
|
||||||
|
...(typeof effect === 'string' ? { effect } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fanPayload(requestArg: IServiceCallRequest): Record<string, unknown> {
|
||||||
|
const percentage = this.numberValue(requestArg.data?.percentage);
|
||||||
|
const presetMode = requestArg.data?.presetMode ?? requestArg.data?.preset_mode;
|
||||||
|
return {
|
||||||
|
...(percentage !== undefined ? { percentage: this.clampPercent(percentage) } : {}),
|
||||||
|
...(typeof presetMode === 'string' ? { presetMode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentToFraction(valueArg: number): number {
|
||||||
|
return this.clampPercent(valueArg) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clampPercent(valueArg: number): number {
|
||||||
|
return Math.max(0, Math.min(100, valueArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static compactDeviceIdentifier(deviceInfoArg: IEsphomeDeviceInfo): string | undefined {
|
||||||
|
const mac = this.normalizeMac(deviceInfoArg.macAddress);
|
||||||
|
return mac ? mac.replace(/:/g, '') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeMac(valueArg: unknown): string | undefined {
|
||||||
|
if (typeof valueArg !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return JSON.stringify(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? null : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'esphome';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,250 @@
|
|||||||
export interface IHomeAssistantEsphomeConfig {
|
import type { TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for esphome.
|
|
||||||
|
export const esphomeDefaultNativeApiPort = 6053;
|
||||||
|
|
||||||
|
export type TEsphomeEntityPlatform =
|
||||||
|
| TEntityPlatform
|
||||||
|
| 'text_sensor'
|
||||||
|
| 'alarm_control_panel'
|
||||||
|
| 'camera'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
|
| 'event'
|
||||||
|
| 'lock'
|
||||||
|
| 'time'
|
||||||
|
| 'valve'
|
||||||
|
| 'water_heater';
|
||||||
|
|
||||||
|
export interface IEsphomeConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
password?: string;
|
||||||
|
encryptionKey?: string;
|
||||||
|
noisePsk?: string;
|
||||||
|
snapshot?: IEsphomeSnapshot;
|
||||||
|
deviceInfo?: IEsphomeDeviceInfo;
|
||||||
|
entities?: IEsphomeEntityDescriptor[];
|
||||||
|
states?: IEsphomeEntityState[] | Record<string, unknown>;
|
||||||
|
services?: IEsphomeUserService[];
|
||||||
|
manualEntries?: IEsphomeManualEntry[];
|
||||||
|
commandExecutor?: TEsphomeCommandExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantEsphomeConfig extends IEsphomeConfig {}
|
||||||
|
|
||||||
|
export interface IEsphomeDeviceInfo {
|
||||||
|
name?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
bluetoothMacAddress?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
esphomeVersion?: string;
|
||||||
|
compilationTime?: string;
|
||||||
|
projectName?: string;
|
||||||
|
projectVersion?: string;
|
||||||
|
suggestedArea?: string;
|
||||||
|
area?: IEsphomeAreaInfo;
|
||||||
|
areas?: IEsphomeAreaInfo[];
|
||||||
|
devices?: IEsphomeSubDeviceInfo[];
|
||||||
|
webserverPort?: number;
|
||||||
|
usesPassword?: boolean;
|
||||||
|
apiEncryptionSupported?: boolean;
|
||||||
|
hasDeepSleep?: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeAreaInfo {
|
||||||
|
areaId?: number | string;
|
||||||
|
area_id?: number | string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeSubDeviceInfo {
|
||||||
|
deviceId?: number | string;
|
||||||
|
device_id?: number | string;
|
||||||
|
name?: string;
|
||||||
|
areaId?: number | string;
|
||||||
|
area_id?: number | string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeEntityDescriptor {
|
||||||
|
platform: TEsphomeEntityPlatform | string;
|
||||||
|
key: number | string;
|
||||||
|
deviceId?: number | string;
|
||||||
|
device_id?: number | string;
|
||||||
|
name?: string;
|
||||||
|
objectId?: string;
|
||||||
|
object_id?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
unique_id?: string;
|
||||||
|
entityId?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
disabledByDefault?: boolean;
|
||||||
|
disabled_by_default?: boolean;
|
||||||
|
deviceClass?: string;
|
||||||
|
device_class?: string;
|
||||||
|
icon?: string;
|
||||||
|
entityCategory?: string;
|
||||||
|
entity_category?: string;
|
||||||
|
unitOfMeasurement?: string;
|
||||||
|
unit_of_measurement?: string;
|
||||||
|
accuracyDecimals?: number;
|
||||||
|
accuracy_decimals?: number;
|
||||||
|
stateClass?: string | number;
|
||||||
|
state_class?: string | number;
|
||||||
|
assumedState?: boolean;
|
||||||
|
assumed_state?: boolean;
|
||||||
|
writable?: boolean;
|
||||||
|
minValue?: number;
|
||||||
|
min_value?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
max_value?: number;
|
||||||
|
step?: number;
|
||||||
|
mode?: string | number;
|
||||||
|
options?: string[];
|
||||||
|
effects?: string[];
|
||||||
|
supportsPosition?: boolean;
|
||||||
|
supports_position?: boolean;
|
||||||
|
supportsTilt?: boolean;
|
||||||
|
supports_tilt?: boolean;
|
||||||
|
supportsStop?: boolean;
|
||||||
|
supports_stop?: boolean;
|
||||||
|
supportsSpeed?: boolean;
|
||||||
|
supports_speed?: boolean;
|
||||||
|
supportedSpeedCount?: number;
|
||||||
|
supported_speed_count?: number;
|
||||||
|
supportedModes?: Array<string | number>;
|
||||||
|
supported_modes?: Array<string | number>;
|
||||||
|
supportedFanModes?: Array<string | number>;
|
||||||
|
supported_fan_modes?: Array<string | number>;
|
||||||
|
supportedPresetModes?: string[];
|
||||||
|
supported_preset_modes?: string[];
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeEntityState {
|
||||||
|
platform?: TEsphomeEntityPlatform | string;
|
||||||
|
key?: number | string;
|
||||||
|
deviceId?: number | string;
|
||||||
|
device_id?: number | string;
|
||||||
|
uniqueId?: string;
|
||||||
|
unique_id?: string;
|
||||||
|
entityId?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
state?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
missingState?: boolean;
|
||||||
|
missing_state?: boolean;
|
||||||
|
updatedAt?: number | string;
|
||||||
|
updated_at?: number | string;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeSnapshot {
|
||||||
|
host?: string;
|
||||||
|
port: number;
|
||||||
|
connected: boolean;
|
||||||
|
deviceInfo: IEsphomeDeviceInfo;
|
||||||
|
apiVersion?: IEsphomeApiVersion;
|
||||||
|
entities: IEsphomeEntityDescriptor[];
|
||||||
|
states: IEsphomeEntityState[];
|
||||||
|
services?: IEsphomeUserService[];
|
||||||
|
events: IEsphomeEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeApiVersion {
|
||||||
|
major?: number;
|
||||||
|
minor?: number;
|
||||||
|
patch?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
command?: IEsphomeClientCommand;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeUserService {
|
||||||
|
key?: number | string;
|
||||||
|
name: string;
|
||||||
|
args?: IEsphomeUserServiceArg[];
|
||||||
|
supportsResponse?: 'none' | 'status' | 'optional' | 'only';
|
||||||
|
supports_response?: 'none' | 'status' | 'optional' | 'only';
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeUserServiceArg {
|
||||||
|
name: string;
|
||||||
|
type: 'bool' | 'int' | 'float' | 'string' | 'bool_array' | 'int_array' | 'float_array' | 'string_array' | string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeClientCommand {
|
||||||
|
type: string;
|
||||||
|
service: string;
|
||||||
|
platform?: TEsphomeEntityPlatform | string;
|
||||||
|
key?: number | string;
|
||||||
|
deviceId?: number | string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
target?: {
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TEsphomeCommandExecutor = (
|
||||||
|
commandArg: IEsphomeClientCommand
|
||||||
|
) => Promise<IEsphomeCommandResult | unknown> | IEsphomeCommandResult | unknown;
|
||||||
|
|
||||||
|
export interface IEsphomeMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
password?: string;
|
||||||
|
encryptionKey?: string;
|
||||||
|
noisePsk?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEsphomeDiscoveryRecord {
|
||||||
|
source?: 'mdns' | 'manual' | 'mqtt' | string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './esphome.classes.client.js';
|
||||||
|
export * from './esphome.classes.configflow.js';
|
||||||
export * from './esphome.classes.integration.js';
|
export * from './esphome.classes.integration.js';
|
||||||
|
export * from './esphome.discovery.js';
|
||||||
|
export * from './esphome.mapper.js';
|
||||||
export * from './esphome.types.js';
|
export * from './esphome.types.js';
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ import { HomeAssistantDatetimeIntegration } from '../datetime/index.js';
|
|||||||
import { HomeAssistantDdwrtIntegration } from '../ddwrt/index.js';
|
import { HomeAssistantDdwrtIntegration } from '../ddwrt/index.js';
|
||||||
import { HomeAssistantDeakoIntegration } from '../deako/index.js';
|
import { HomeAssistantDeakoIntegration } from '../deako/index.js';
|
||||||
import { HomeAssistantDebugpyIntegration } from '../debugpy/index.js';
|
import { HomeAssistantDebugpyIntegration } from '../debugpy/index.js';
|
||||||
import { HomeAssistantDeconzIntegration } from '../deconz/index.js';
|
|
||||||
import { HomeAssistantDecoraWifiIntegration } from '../decora_wifi/index.js';
|
import { HomeAssistantDecoraWifiIntegration } from '../decora_wifi/index.js';
|
||||||
import { HomeAssistantDecorquipIntegration } from '../decorquip/index.js';
|
import { HomeAssistantDecorquipIntegration } from '../decorquip/index.js';
|
||||||
import { HomeAssistantDefaultConfigIntegration } from '../default_config/index.js';
|
import { HomeAssistantDefaultConfigIntegration } from '../default_config/index.js';
|
||||||
@@ -340,7 +339,6 @@ import { HomeAssistantEpsonIntegration } from '../epson/index.js';
|
|||||||
import { HomeAssistantEq3btsmartIntegration } from '../eq3btsmart/index.js';
|
import { HomeAssistantEq3btsmartIntegration } from '../eq3btsmart/index.js';
|
||||||
import { HomeAssistantEsceaIntegration } from '../escea/index.js';
|
import { HomeAssistantEsceaIntegration } from '../escea/index.js';
|
||||||
import { HomeAssistantEseraOnewireIntegration } from '../esera_onewire/index.js';
|
import { HomeAssistantEseraOnewireIntegration } from '../esera_onewire/index.js';
|
||||||
import { HomeAssistantEsphomeIntegration } from '../esphome/index.js';
|
|
||||||
import { HomeAssistantEssentIntegration } from '../essent/index.js';
|
import { HomeAssistantEssentIntegration } from '../essent/index.js';
|
||||||
import { HomeAssistantEtherscanIntegration } from '../etherscan/index.js';
|
import { HomeAssistantEtherscanIntegration } from '../etherscan/index.js';
|
||||||
import { HomeAssistantEufyIntegration } from '../eufy/index.js';
|
import { HomeAssistantEufyIntegration } from '../eufy/index.js';
|
||||||
@@ -521,7 +519,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan
|
|||||||
import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js';
|
import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js';
|
||||||
import { HomeAssistantHomeeIntegration } from '../homee/index.js';
|
import { HomeAssistantHomeeIntegration } from '../homee/index.js';
|
||||||
import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
|
import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
|
||||||
import { HomeAssistantHomekitControllerIntegration } from '../homekit_controller/index.js';
|
|
||||||
import { HomeAssistantHomematicIntegration } from '../homematic/index.js';
|
import { HomeAssistantHomematicIntegration } from '../homematic/index.js';
|
||||||
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js';
|
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js';
|
||||||
import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js';
|
import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js';
|
||||||
@@ -724,7 +721,6 @@ import { HomeAssistantMartecIntegration } from '../martec/index.js';
|
|||||||
import { HomeAssistantMaryttsIntegration } from '../marytts/index.js';
|
import { HomeAssistantMaryttsIntegration } from '../marytts/index.js';
|
||||||
import { HomeAssistantMastodonIntegration } from '../mastodon/index.js';
|
import { HomeAssistantMastodonIntegration } from '../mastodon/index.js';
|
||||||
import { HomeAssistantMatrixIntegration } from '../matrix/index.js';
|
import { HomeAssistantMatrixIntegration } from '../matrix/index.js';
|
||||||
import { HomeAssistantMatterIntegration } from '../matter/index.js';
|
|
||||||
import { HomeAssistantMaxcubeIntegration } from '../maxcube/index.js';
|
import { HomeAssistantMaxcubeIntegration } from '../maxcube/index.js';
|
||||||
import { HomeAssistantMaytagIntegration } from '../maytag/index.js';
|
import { HomeAssistantMaytagIntegration } from '../maytag/index.js';
|
||||||
import { HomeAssistantMazdaIntegration } from '../mazda/index.js';
|
import { HomeAssistantMazdaIntegration } from '../mazda/index.js';
|
||||||
@@ -806,7 +802,6 @@ import { HomeAssistantMyuplinkIntegration } from '../myuplink/index.js';
|
|||||||
import { HomeAssistantNadIntegration } from '../nad/index.js';
|
import { HomeAssistantNadIntegration } from '../nad/index.js';
|
||||||
import { HomeAssistantNamIntegration } from '../nam/index.js';
|
import { HomeAssistantNamIntegration } from '../nam/index.js';
|
||||||
import { HomeAssistantNamecheapdnsIntegration } from '../namecheapdns/index.js';
|
import { HomeAssistantNamecheapdnsIntegration } from '../namecheapdns/index.js';
|
||||||
import { HomeAssistantNanoleafIntegration } from '../nanoleaf/index.js';
|
|
||||||
import { HomeAssistantNaswebIntegration } from '../nasweb/index.js';
|
import { HomeAssistantNaswebIntegration } from '../nasweb/index.js';
|
||||||
import { HomeAssistantNationalGridUsIntegration } from '../national_grid_us/index.js';
|
import { HomeAssistantNationalGridUsIntegration } from '../national_grid_us/index.js';
|
||||||
import { HomeAssistantNeatoIntegration } from '../neato/index.js';
|
import { HomeAssistantNeatoIntegration } from '../neato/index.js';
|
||||||
@@ -1284,7 +1279,6 @@ import { HomeAssistantTraccarIntegration } from '../traccar/index.js';
|
|||||||
import { HomeAssistantTraccarServerIntegration } from '../traccar_server/index.js';
|
import { HomeAssistantTraccarServerIntegration } from '../traccar_server/index.js';
|
||||||
import { HomeAssistantTraceIntegration } from '../trace/index.js';
|
import { HomeAssistantTraceIntegration } from '../trace/index.js';
|
||||||
import { HomeAssistantTractiveIntegration } from '../tractive/index.js';
|
import { HomeAssistantTractiveIntegration } from '../tractive/index.js';
|
||||||
import { HomeAssistantTradfriIntegration } from '../tradfri/index.js';
|
|
||||||
import { HomeAssistantTrafikverketCameraIntegration } from '../trafikverket_camera/index.js';
|
import { HomeAssistantTrafikverketCameraIntegration } from '../trafikverket_camera/index.js';
|
||||||
import { HomeAssistantTrafikverketFerryIntegration } from '../trafikverket_ferry/index.js';
|
import { HomeAssistantTrafikverketFerryIntegration } from '../trafikverket_ferry/index.js';
|
||||||
import { HomeAssistantTrafikverketTrainIntegration } from '../trafikverket_train/index.js';
|
import { HomeAssistantTrafikverketTrainIntegration } from '../trafikverket_train/index.js';
|
||||||
@@ -1399,7 +1393,6 @@ import { HomeAssistantWilightIntegration } from '../wilight/index.js';
|
|||||||
import { HomeAssistantWindowIntegration } from '../window/index.js';
|
import { HomeAssistantWindowIntegration } from '../window/index.js';
|
||||||
import { HomeAssistantWirelesstagIntegration } from '../wirelesstag/index.js';
|
import { HomeAssistantWirelesstagIntegration } from '../wirelesstag/index.js';
|
||||||
import { HomeAssistantWithingsIntegration } from '../withings/index.js';
|
import { HomeAssistantWithingsIntegration } from '../withings/index.js';
|
||||||
import { HomeAssistantWizIntegration } from '../wiz/index.js';
|
|
||||||
import { HomeAssistantWledIntegration } from '../wled/index.js';
|
import { HomeAssistantWledIntegration } from '../wled/index.js';
|
||||||
import { HomeAssistantWmsproIntegration } from '../wmspro/index.js';
|
import { HomeAssistantWmsproIntegration } from '../wmspro/index.js';
|
||||||
import { HomeAssistantWolflinkIntegration } from '../wolflink/index.js';
|
import { HomeAssistantWolflinkIntegration } from '../wolflink/index.js';
|
||||||
@@ -1416,7 +1409,6 @@ import { HomeAssistantXeomaIntegration } from '../xeoma/index.js';
|
|||||||
import { HomeAssistantXiaomiIntegration } from '../xiaomi/index.js';
|
import { HomeAssistantXiaomiIntegration } from '../xiaomi/index.js';
|
||||||
import { HomeAssistantXiaomiAqaraIntegration } from '../xiaomi_aqara/index.js';
|
import { HomeAssistantXiaomiAqaraIntegration } from '../xiaomi_aqara/index.js';
|
||||||
import { HomeAssistantXiaomiBleIntegration } from '../xiaomi_ble/index.js';
|
import { HomeAssistantXiaomiBleIntegration } from '../xiaomi_ble/index.js';
|
||||||
import { HomeAssistantXiaomiMiioIntegration } from '../xiaomi_miio/index.js';
|
|
||||||
import { HomeAssistantXiaomiTvIntegration } from '../xiaomi_tv/index.js';
|
import { HomeAssistantXiaomiTvIntegration } from '../xiaomi_tv/index.js';
|
||||||
import { HomeAssistantXmppIntegration } from '../xmpp/index.js';
|
import { HomeAssistantXmppIntegration } from '../xmpp/index.js';
|
||||||
import { HomeAssistantXs1Integration } from '../xs1/index.js';
|
import { HomeAssistantXs1Integration } from '../xs1/index.js';
|
||||||
@@ -1428,7 +1420,6 @@ import { HomeAssistantYamahaMusiccastIntegration } from '../yamaha_musiccast/ind
|
|||||||
import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js';
|
import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js';
|
||||||
import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
|
import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
|
||||||
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
|
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
|
||||||
import { HomeAssistantYeelightIntegration } from '../yeelight/index.js';
|
|
||||||
import { HomeAssistantYeelightsunflowerIntegration } from '../yeelightsunflower/index.js';
|
import { HomeAssistantYeelightsunflowerIntegration } from '../yeelightsunflower/index.js';
|
||||||
import { HomeAssistantYiIntegration } from '../yi/index.js';
|
import { HomeAssistantYiIntegration } from '../yi/index.js';
|
||||||
import { HomeAssistantYolinkIntegration } from '../yolink/index.js';
|
import { HomeAssistantYolinkIntegration } from '../yolink/index.js';
|
||||||
@@ -1442,7 +1433,6 @@ import { HomeAssistantZeroconfIntegration } from '../zeroconf/index.js';
|
|||||||
import { HomeAssistantZerprocIntegration } from '../zerproc/index.js';
|
import { HomeAssistantZerprocIntegration } from '../zerproc/index.js';
|
||||||
import { HomeAssistantZestimateIntegration } from '../zestimate/index.js';
|
import { HomeAssistantZestimateIntegration } from '../zestimate/index.js';
|
||||||
import { HomeAssistantZeversolarIntegration } from '../zeversolar/index.js';
|
import { HomeAssistantZeversolarIntegration } from '../zeversolar/index.js';
|
||||||
import { HomeAssistantZhaIntegration } from '../zha/index.js';
|
|
||||||
import { HomeAssistantZhongHongIntegration } from '../zhong_hong/index.js';
|
import { HomeAssistantZhongHongIntegration } from '../zhong_hong/index.js';
|
||||||
import { HomeAssistantZiggoMediaboxXlIntegration } from '../ziggo_mediabox_xl/index.js';
|
import { HomeAssistantZiggoMediaboxXlIntegration } from '../ziggo_mediabox_xl/index.js';
|
||||||
import { HomeAssistantZimiIntegration } from '../zimi/index.js';
|
import { HomeAssistantZimiIntegration } from '../zimi/index.js';
|
||||||
@@ -1684,7 +1674,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDatetimeIntegration
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDdwrtIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDdwrtIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeakoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeakoIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDebugpyIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDebugpyIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeconzIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecoraWifiIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecoraWifiIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecorquipIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecorquipIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDefaultConfigIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDefaultConfigIntegration());
|
||||||
@@ -1793,7 +1782,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpsonIntegration())
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEq3btsmartIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEq3btsmartIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsceaIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsceaIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEseraOnewireIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEseraOnewireIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsphomeIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEssentIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEssentIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEtherscanIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEtherscanIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufyIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufyIntegration());
|
||||||
@@ -1974,7 +1962,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitControllerIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
|
||||||
@@ -2177,7 +2164,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMartecIntegration()
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaryttsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaryttsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMastodonIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMastodonIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatrixIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatrixIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatterIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaxcubeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaxcubeIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaytagIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaytagIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMazdaIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMazdaIntegration());
|
||||||
@@ -2259,7 +2245,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMyuplinkIntegration
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNadIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNadIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamecheapdnsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamecheapdnsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNanoleafIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNaswebIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNaswebIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNationalGridUsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNationalGridUsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNeatoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNeatoIntegration());
|
||||||
@@ -2737,7 +2722,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarIntegration(
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarServerIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarServerIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraceIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraceIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTractiveIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTractiveIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTradfriIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketCameraIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketCameraIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketFerryIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketFerryIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketTrainIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketTrainIntegration());
|
||||||
@@ -2852,7 +2836,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantWilightIntegration(
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWindowIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWindowIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWirelesstagIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWirelesstagIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWithingsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWithingsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWizIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWledIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWledIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWmsproIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWmsproIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWolflinkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWolflinkIntegration());
|
||||||
@@ -2869,7 +2852,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantXeomaIntegration())
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiAqaraIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiAqaraIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiBleIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiBleIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiMiioIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiTvIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiTvIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXmppIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXmppIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXs1Integration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXs1Integration());
|
||||||
@@ -2881,7 +2863,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaMusiccastInte
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightsunflowerIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightsunflowerIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYiIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYiIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYolinkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYolinkIntegration());
|
||||||
@@ -2895,7 +2876,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeroconfIntegration
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZerprocIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZerprocIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZestimateIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZestimateIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeversolarIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeversolarIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhaIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhongHongIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhongHongIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZiggoMediaboxXlIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZiggoMediaboxXlIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZimiIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZimiIntegration());
|
||||||
@@ -2906,13 +2886,23 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||||
|
|
||||||
export const generatedHomeAssistantPortCount = 1451;
|
export const generatedHomeAssistantPortCount = 1441;
|
||||||
export const handwrittenHomeAssistantPortDomains = [
|
export const handwrittenHomeAssistantPortDomains = [
|
||||||
"cast",
|
"cast",
|
||||||
|
"deconz",
|
||||||
|
"esphome",
|
||||||
|
"homekit_controller",
|
||||||
"hue",
|
"hue",
|
||||||
|
"matter",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
|
"nanoleaf",
|
||||||
"roku",
|
"roku",
|
||||||
"shelly",
|
"shelly",
|
||||||
"sonos",
|
"sonos",
|
||||||
|
"tradfri",
|
||||||
|
"wiz",
|
||||||
|
"xiaomi_miio",
|
||||||
|
"yeelight",
|
||||||
|
"zha",
|
||||||
"zwave_js"
|
"zwave_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,93 @@
|
|||||||
|
import { HomekitControllerMapper } from './homekit_controller.mapper.js';
|
||||||
|
import type { IHomekitControllerCommand, IHomekitControllerConfig, IHomekitEvent, IHomekitSnapshot } from './homekit_controller.types.js';
|
||||||
|
|
||||||
|
type TEventHandler = (eventArg: IHomekitEvent) => void;
|
||||||
|
|
||||||
|
export class HomekitControllerClient {
|
||||||
|
private readonly eventHandlers = new Set<TEventHandler>();
|
||||||
|
private readonly events: IHomekitEvent[] = [];
|
||||||
|
private snapshot?: IHomekitSnapshot;
|
||||||
|
|
||||||
|
constructor(private readonly config: IHomekitControllerConfig) {
|
||||||
|
this.snapshot = config.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IHomekitSnapshot> {
|
||||||
|
this.snapshot = HomekitControllerMapper.toSnapshot({ ...this.config, snapshot: this.snapshot }, false, this.events);
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
if (commandArg.command === 'snapshot') {
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'pair_setup') {
|
||||||
|
return this.pairSetup(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'pair_verify') {
|
||||||
|
return this.pairVerify(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'read_characteristics') {
|
||||||
|
return this.readCharacteristics(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'write_characteristics' || commandArg.command === 'identify') {
|
||||||
|
return this.writeCharacteristics(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'subscribe_events') {
|
||||||
|
return this.subscribeEvents(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'camera_snapshot') {
|
||||||
|
return this.cameraSnapshot(commandArg);
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported HomeKit Controller command: ${commandArg.command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readCharacteristics(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
this.throwUnsupportedSecureSession('characteristic reads');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pairSetup(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
throw new Error('HomeKit pair setup is not implemented in this native port. Provide existing pairing data and a cached accessory snapshot, or use snapshot mode until native HAP SRP and secure-session support is added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pairVerify(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
this.throwUnsupportedSecureSession('pair-verify sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async subscribeEvents(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
this.throwUnsupportedSecureSession('event subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeCharacteristics(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
this.throwUnsupportedSecureSession('characteristic writes');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cameraSnapshot(commandArg: IHomekitControllerCommand): Promise<unknown> {
|
||||||
|
void commandArg;
|
||||||
|
this.throwUnsupportedSecureSession('camera snapshots');
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwUnsupportedSecureSession(operationArg: string): never {
|
||||||
|
if (!this.config.host && !this.config.pairingData?.AccessoryIP) {
|
||||||
|
throw new Error(`HomeKit ${operationArg} require a host/port and a HAP pair-verify encrypted session. This config only contains snapshot data.`);
|
||||||
|
}
|
||||||
|
if (!this.config.pairingData) {
|
||||||
|
throw new Error(`HomeKit ${operationArg} require pairing data and a HAP pair-verify encrypted session. Pair setup is not implemented in this native port.`);
|
||||||
|
}
|
||||||
|
throw new Error(`HomeKit ${operationArg} require HAP pair-verify session encryption. This native port exposes snapshots and mappings only until a native HAP secure-session implementation is added.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IHomekitAccessory, IHomekitControllerConfig, IHomekitPairingData, IHomekitSnapshot } from './homekit_controller.types.js';
|
||||||
|
|
||||||
|
const pinFormat = /^(\d{3})-?(\d{2})-?(\d{3})$/;
|
||||||
|
const insecureCodes = new Set(['00000000', '11111111', '22222222', '33333333', '44444444', '55555555', '66666666', '77777777', '88888888', '99999999', '12345678', '87654321']);
|
||||||
|
|
||||||
|
export class HomekitControllerConfigFlow implements IConfigFlow<IHomekitControllerConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHomekitControllerConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect HomeKit Device',
|
||||||
|
description: 'Configure a HomeKit Accessory Protocol device from discovery, pairing data, or a cached accessory snapshot.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text' },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number' },
|
||||||
|
{ name: 'setupCode', label: 'Setup code', type: 'password' },
|
||||||
|
{ name: 'allowInsecureSetupCode', label: 'Allow insecure setup code', type: 'boolean' },
|
||||||
|
{ name: 'pairingData', label: 'Pairing data JSON', type: 'text' },
|
||||||
|
{ name: 'snapshotJson', label: 'Accessory snapshot JSON', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => this.finish(valuesArg, candidateArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finish(valuesArg: Record<string, unknown>, candidateArg: IDiscoveryCandidate): Promise<IConfigFlowStep<IHomekitControllerConfig>> {
|
||||||
|
const allowInsecureSetupCode = valuesArg.allowInsecureSetupCode === true || candidateArg.metadata?.allowInsecureSetupCode === true;
|
||||||
|
const setupCode = this.formattedSetupCode(this.stringValue(valuesArg.setupCode) || this.stringValue(candidateArg.metadata?.setupCode), allowInsecureSetupCode);
|
||||||
|
if (setupCode === false) {
|
||||||
|
return { kind: 'error', title: 'Invalid setup code', error: 'HomeKit setup codes must use the 123-45-678 format and cannot be a known insecure code unless explicitly allowed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairingData = this.jsonValue<IHomekitPairingData>(valuesArg.pairingData) || this.objectValue<IHomekitPairingData>(candidateArg.metadata?.pairingData);
|
||||||
|
const snapshot = this.jsonValue<IHomekitSnapshot>(valuesArg.snapshotJson) || this.objectValue<IHomekitSnapshot>(candidateArg.metadata?.snapshot);
|
||||||
|
const accessories = this.objectArrayValue<IHomekitAccessory>(candidateArg.metadata?.accessories) || snapshot?.accessories;
|
||||||
|
const host = this.stringValue(valuesArg.host) || candidateArg.host || pairingData?.AccessoryIP || snapshot?.host;
|
||||||
|
const port = this.numberValue(valuesArg.port) || candidateArg.port || pairingData?.AccessoryPort || snapshot?.port || (host ? 51826 : undefined);
|
||||||
|
|
||||||
|
if (!setupCode && !pairingData && !snapshot && !accessories?.length) {
|
||||||
|
return { kind: 'error', title: 'HomeKit data required', error: 'Provide a setup code, pairing data JSON, or a cached accessory snapshot.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.normalizeId(this.stringValue(candidateArg.id) || pairingData?.AccessoryPairingID || snapshot?.id);
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'HomeKit Device configured',
|
||||||
|
config: {
|
||||||
|
id,
|
||||||
|
name: candidateArg.name || snapshot?.name,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
setupCode: setupCode || undefined,
|
||||||
|
allowInsecureSetupCode: allowInsecureSetupCode || undefined,
|
||||||
|
pairingData,
|
||||||
|
paired: Boolean(pairingData || snapshot?.paired || candidateArg.metadata?.paired),
|
||||||
|
transport: snapshot?.transport || (host ? 'ip' : 'snapshot'),
|
||||||
|
model: candidateArg.model,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
category: candidateArg.metadata?.category as string | number | undefined,
|
||||||
|
configNumber: this.numberValue(candidateArg.metadata?.configNumber) || snapshot?.configNumber,
|
||||||
|
stateNumber: this.numberValue(candidateArg.metadata?.stateNumber) || snapshot?.stateNumber,
|
||||||
|
accessories,
|
||||||
|
snapshot,
|
||||||
|
discovery: {
|
||||||
|
source: candidateArg.source === 'manual' || candidateArg.source === 'mdns' || candidateArg.source === 'bluetooth' ? candidateArg.source : undefined,
|
||||||
|
id,
|
||||||
|
name: candidateArg.name,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
model: candidateArg.model,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
category: candidateArg.metadata?.category as string | number | undefined,
|
||||||
|
paired: typeof candidateArg.metadata?.paired === 'boolean' ? candidateArg.metadata.paired : undefined,
|
||||||
|
configNumber: this.numberValue(candidateArg.metadata?.configNumber),
|
||||||
|
stateNumber: this.numberValue(candidateArg.metadata?.stateNumber),
|
||||||
|
statusFlags: this.numberValue(candidateArg.metadata?.statusFlags),
|
||||||
|
setupHash: this.stringValue(candidateArg.metadata?.setupHash),
|
||||||
|
protocolVersion: this.stringValue(candidateArg.metadata?.protocolVersion),
|
||||||
|
txt: this.objectValue<Record<string, string | undefined>>(candidateArg.metadata?.txt),
|
||||||
|
},
|
||||||
|
connected: snapshot?.connected,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formattedSetupCode(valueArg: string | undefined, allowInsecureArg: boolean): string | false | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const match = pinFormat.exec(valueArg.trim());
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const compact = `${match[1]}${match[2]}${match[3]}`;
|
||||||
|
if (!allowInsecureArg && insecureCodes.has(compact)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private jsonValue<TValue>(valueArg: unknown): TValue | undefined {
|
||||||
|
if (typeof valueArg !== 'string' || !valueArg.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(valueArg) as unknown;
|
||||||
|
return this.isRecord(parsed) || Array.isArray(parsed) ? parsed as TValue : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private objectValue<TValue>(valueArg: unknown): TValue | undefined {
|
||||||
|
return this.isRecord(valueArg) ? valueArg as TValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private objectArrayValue<TValue>(valueArg: unknown): TValue[] | undefined {
|
||||||
|
return Array.isArray(valueArg) ? valueArg as TValue[] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const value = Number.parseInt(valueArg, 10);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeId(valueArg?: string): string | undefined {
|
||||||
|
return valueArg?.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,84 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { HomekitControllerClient } from './homekit_controller.classes.client.js';
|
||||||
|
import { HomekitControllerConfigFlow } from './homekit_controller.classes.configflow.js';
|
||||||
|
import { createHomekitControllerDiscoveryDescriptor } from './homekit_controller.discovery.js';
|
||||||
|
import { HomekitControllerMapper } from './homekit_controller.mapper.js';
|
||||||
|
import type { IHomekitControllerConfig } from './homekit_controller.types.js';
|
||||||
|
|
||||||
export class HomeAssistantHomekitControllerIntegration extends DescriptorOnlyIntegration {
|
export class HomekitControllerIntegration extends BaseIntegration<IHomekitControllerConfig> {
|
||||||
constructor() {
|
public readonly domain = 'homekit_controller';
|
||||||
super({
|
public readonly displayName = 'HomeKit Device';
|
||||||
domain: "homekit_controller",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "HomeKit Device",
|
public readonly discoveryDescriptor = createHomekitControllerDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new HomekitControllerConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/homekit_controller",
|
upstreamPath: 'homeassistant/components/homekit_controller',
|
||||||
"upstreamDomain": "homekit_controller",
|
upstreamDomain: 'homekit_controller',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['aiohomekit==3.2.20'],
|
||||||
"aiohomekit==3.2.20"
|
dependencies: ['bluetooth_adapters', 'zeroconf'],
|
||||||
],
|
afterDependencies: ['thread'],
|
||||||
"dependencies": [
|
codeowners: ['@Jc2k', '@bdraco'],
|
||||||
"bluetooth_adapters",
|
zeroconf: ['_hap._tcp.local.', '_hap._udp.local.'],
|
||||||
"zeroconf"
|
documentation: 'https://www.home-assistant.io/integrations/homekit_controller',
|
||||||
],
|
};
|
||||||
"afterDependencies": [
|
|
||||||
"thread"
|
public async setup(configArg: IHomekitControllerConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
],
|
void contextArg;
|
||||||
"codeowners": [
|
return new HomekitControllerRuntime(new HomekitControllerClient(configArg));
|
||||||
"@Jc2k",
|
}
|
||||||
"@bdraco"
|
|
||||||
]
|
public async destroy(): Promise<void> {}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantHomekitControllerIntegration extends HomekitControllerIntegration {}
|
||||||
|
|
||||||
|
class HomekitControllerRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'homekit_controller';
|
||||||
|
|
||||||
|
constructor(private readonly client: HomekitControllerClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return HomekitControllerMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return HomekitControllerMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: eventArg.type === 'availability_changed' ? 'availability_changed' : eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: 'homekit_controller',
|
||||||
|
deviceId: typeof eventArg.aid === 'number' ? `homekit_controller.accessory.${eventArg.aid}` : undefined,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = HomekitControllerMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `HomeKit Controller service ${requestArg.domain}.${requestArg.service} has no characteristic mapping.` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await this.client.execute(command);
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IHomekitManualEntry, IHomekitMdnsRecord } from './homekit_controller.types.js';
|
||||||
|
|
||||||
|
const hapMdnsTypes = new Set(['_hap._tcp.local', '_hap._udp.local']);
|
||||||
|
|
||||||
|
const categoryNames: Record<number, string> = {
|
||||||
|
1: 'Other',
|
||||||
|
2: 'Bridge',
|
||||||
|
3: 'Fan',
|
||||||
|
4: 'Garage Door Opener',
|
||||||
|
5: 'Lightbulb',
|
||||||
|
6: 'Door Lock',
|
||||||
|
7: 'Outlet',
|
||||||
|
8: 'Switch',
|
||||||
|
9: 'Thermostat',
|
||||||
|
10: 'Sensor',
|
||||||
|
17: 'IP Camera',
|
||||||
|
19: 'Air Purifier',
|
||||||
|
20: 'Heater',
|
||||||
|
21: 'Air Conditioner',
|
||||||
|
22: 'Humidifier',
|
||||||
|
23: 'Dehumidifier',
|
||||||
|
24: 'Sprinkler',
|
||||||
|
26: 'Window Covering',
|
||||||
|
28: 'Security System',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HomekitControllerMdnsMatcher implements IDiscoveryMatcher<IHomekitMdnsRecord> {
|
||||||
|
public id = 'homekit-controller-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize HomeKit Accessory Protocol mDNS advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IHomekitMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const txt = this.normalizedTxt(recordArg);
|
||||||
|
const id = this.stringValue(txt.id);
|
||||||
|
const model = this.stringValue(txt.md);
|
||||||
|
const manufacturer = this.stringValue(txt.mf);
|
||||||
|
const category = this.numberValue(txt.ci);
|
||||||
|
const matched = hapMdnsTypes.has(type) || this.hasHapTxt(txt);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a HomeKit Accessory Protocol advertisement.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFlags = this.numberValue(txt.sf);
|
||||||
|
const paired = statusFlags === undefined ? undefined : (statusFlags & 1) === 0;
|
||||||
|
const configNumber = this.numberValue(txt['c#']);
|
||||||
|
const stateNumber = this.numberValue(txt['s#']);
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const name = this.serviceName(recordArg, txt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'mDNS record matches HomeKit Accessory Protocol metadata.',
|
||||||
|
normalizedDeviceId: this.normalizeId(id),
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'homekit_controller',
|
||||||
|
id: this.normalizeId(id),
|
||||||
|
host,
|
||||||
|
port: recordArg.port || 51826,
|
||||||
|
name,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
metadata: {
|
||||||
|
homekit: true,
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
id,
|
||||||
|
category,
|
||||||
|
categoryName: category === undefined ? undefined : categoryNames[category] || `Category ${category}`,
|
||||||
|
paired,
|
||||||
|
statusFlags,
|
||||||
|
configNumber,
|
||||||
|
stateNumber,
|
||||||
|
setupHash: txt.sh,
|
||||||
|
protocolVersion: txt.pv,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizedTxt(recordArg: IHomekitMdnsRecord): Record<string, string | undefined> {
|
||||||
|
const source = recordArg.txt || recordArg.properties || {};
|
||||||
|
const txt: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
txt[key.toLowerCase()] = this.txtValue(value);
|
||||||
|
}
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasHapTxt(txtArg: Record<string, string | undefined>): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.stringValue(txtArg.id)
|
||||||
|
&& (txtArg.sf !== undefined
|
||||||
|
|| txtArg.ci !== undefined
|
||||||
|
|| txtArg['c#'] !== undefined
|
||||||
|
|| txtArg['s#'] !== undefined
|
||||||
|
|| txtArg.sh !== undefined
|
||||||
|
|| txtArg.pv !== undefined
|
||||||
|
|| txtArg.md !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private txtValue(valueArg: unknown): string | undefined {
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return this.txtValue(valueArg[0]);
|
||||||
|
}
|
||||||
|
if (valueArg instanceof Uint8Array) {
|
||||||
|
return new TextDecoder().decode(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serviceName(recordArg: IHomekitMdnsRecord, txtArg: Record<string, string | undefined>): string | undefined {
|
||||||
|
const name = recordArg.name?.replace(/\._hap\._(?:tcp|udp)\.local\.?$/i, '');
|
||||||
|
return this.stringValue(txtArg.name) || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeId(valueArg?: string): string | undefined {
|
||||||
|
return valueArg?.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const value = Number.parseInt(valueArg, 10);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomekitControllerManualMatcher implements IDiscoveryMatcher<IHomekitManualEntry> {
|
||||||
|
public id = 'homekit-controller-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual HomeKit Controller setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IHomekitManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const pairingId = inputArg.pairingData?.AccessoryPairingID;
|
||||||
|
const snapshot = inputArg.snapshot;
|
||||||
|
const accessories = inputArg.accessories || snapshot?.accessories;
|
||||||
|
const setupCode = this.stringValue(inputArg.setupCode) || this.stringValue(inputArg.metadata?.setupCode);
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(
|
||||||
|
inputArg.host
|
||||||
|
|| setupCode
|
||||||
|
|| inputArg.pairingData
|
||||||
|
|| accessories?.length
|
||||||
|
|| inputArg.metadata?.homekit
|
||||||
|
|| manufacturer.includes('homekit')
|
||||||
|
|| model.includes('homekit')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain HomeKit setup hints.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: pairingId || accessories?.length || setupCode ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start HomeKit Controller setup.',
|
||||||
|
normalizedDeviceId: this.normalizeId(inputArg.id || pairingId || snapshot?.id),
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'homekit_controller',
|
||||||
|
id: this.normalizeId(inputArg.id || pairingId || snapshot?.id),
|
||||||
|
host: inputArg.host || inputArg.pairingData?.AccessoryIP || snapshot?.host,
|
||||||
|
port: inputArg.port || inputArg.pairingData?.AccessoryPort || snapshot?.port || 51826,
|
||||||
|
name: inputArg.name || snapshot?.name,
|
||||||
|
manufacturer: inputArg.manufacturer,
|
||||||
|
model: inputArg.model,
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
homekit: true,
|
||||||
|
category: inputArg.category,
|
||||||
|
categoryName: typeof inputArg.category === 'number' ? categoryNames[inputArg.category] : undefined,
|
||||||
|
pairingData: inputArg.pairingData,
|
||||||
|
accessories,
|
||||||
|
snapshot,
|
||||||
|
setupCode,
|
||||||
|
setupCodeProvided: Boolean(setupCode),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeId(valueArg?: string): string | undefined {
|
||||||
|
return valueArg?.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomekitControllerCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'homekit-controller-candidate-validator';
|
||||||
|
public description = 'Validate HomeKit Controller candidate metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase().replace(/\.$/, '') : '';
|
||||||
|
const id = typeof metadata.id === 'string' ? metadata.id : candidateArg.id;
|
||||||
|
const category = this.numberValue(metadata.category);
|
||||||
|
const hasHomekitMetadata = this.hasHomekitMetadata(metadata, mdnsType, id);
|
||||||
|
const matched = Boolean(
|
||||||
|
candidateArg.integrationDomain === 'homekit_controller'
|
||||||
|
|| hasHomekitMetadata
|
||||||
|
|| manufacturer.includes('homekit')
|
||||||
|
|| model.includes('homekit')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && (candidateArg.host || id || metadata.pairingData || metadata.snapshot) ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has HomeKit metadata.' : 'Candidate is not HomeKit.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: id?.toLowerCase(),
|
||||||
|
metadata: matched ? { categoryName: category === undefined ? undefined : categoryNames[category] } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasHomekitMetadata(metadataArg: Record<string, unknown>, mdnsTypeArg: string, idArg?: string): boolean {
|
||||||
|
return Boolean(
|
||||||
|
metadataArg.homekit === true
|
||||||
|
|| hapMdnsTypes.has(mdnsTypeArg)
|
||||||
|
|| metadataArg.pairingData
|
||||||
|
|| metadataArg.snapshot
|
||||||
|
|| metadataArg.accessories
|
||||||
|
|| metadataArg.setupCode
|
||||||
|
|| metadataArg.setupCodeProvided
|
||||||
|
|| metadataArg.statusFlags !== undefined
|
||||||
|
|| metadataArg.setupHash
|
||||||
|
|| metadataArg.protocolVersion
|
||||||
|
|| metadataArg.configNumber !== undefined
|
||||||
|
|| metadataArg.stateNumber !== undefined
|
||||||
|
|| (this.looksLikeHomekitId(idArg) && (metadataArg.category !== undefined || metadataArg.txt !== undefined))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeHomekitId(valueArg?: string): boolean {
|
||||||
|
return typeof valueArg === 'string' && /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i.test(valueArg.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const value = Number.parseInt(valueArg, 10);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createHomekitControllerDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'homekit_controller', displayName: 'HomeKit Device' })
|
||||||
|
.addMatcher(new HomekitControllerMdnsMatcher())
|
||||||
|
.addMatcher(new HomekitControllerManualMatcher())
|
||||||
|
.addValidator(new HomekitControllerCandidateValidator());
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,181 @@
|
|||||||
export interface IHomeAssistantHomekitControllerConfig {
|
export type THomekitTransport = 'ip' | 'ble' | 'thread' | 'coap' | 'snapshot' | 'unknown';
|
||||||
// TODO: replace with the TypeScript-native config for homekit_controller.
|
|
||||||
|
export type THomekitControllerCommandType =
|
||||||
|
| 'snapshot'
|
||||||
|
| 'pair_setup'
|
||||||
|
| 'pair_verify'
|
||||||
|
| 'read_characteristics'
|
||||||
|
| 'write_characteristics'
|
||||||
|
| 'subscribe_events'
|
||||||
|
| 'identify'
|
||||||
|
| 'camera_snapshot';
|
||||||
|
|
||||||
|
export interface IHomekitControllerConfig {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
setupCode?: string;
|
||||||
|
allowInsecureSetupCode?: boolean;
|
||||||
|
pairingData?: IHomekitPairingData;
|
||||||
|
paired?: boolean;
|
||||||
|
transport?: THomekitTransport;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
category?: string | number;
|
||||||
|
configNumber?: number;
|
||||||
|
stateNumber?: number;
|
||||||
|
accessories?: IHomekitAccessory[];
|
||||||
|
snapshot?: IHomekitSnapshot;
|
||||||
|
discovery?: IHomekitDiscoveryRecord;
|
||||||
|
connected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantHomekitControllerConfig extends IHomekitControllerConfig {}
|
||||||
|
|
||||||
|
export interface IHomekitPairingData {
|
||||||
|
AccessoryPairingID?: string;
|
||||||
|
AccessoryIP?: string;
|
||||||
|
AccessoryIPs?: string[];
|
||||||
|
AccessoryPort?: number;
|
||||||
|
AccessoryAddress?: string;
|
||||||
|
Connection?: string;
|
||||||
|
iOSPairingId?: string;
|
||||||
|
iOSDeviceLTSK?: string;
|
||||||
|
iOSDeviceLTPK?: string;
|
||||||
|
AccessoryLTPK?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IHomekitDiscoveryRecord {
|
||||||
|
source?: 'mdns' | 'manual' | 'bluetooth' | 'snapshot';
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
category?: string | number;
|
||||||
|
paired?: boolean;
|
||||||
|
configNumber?: number;
|
||||||
|
stateNumber?: number;
|
||||||
|
statusFlags?: number;
|
||||||
|
setupHash?: string;
|
||||||
|
protocolVersion?: string;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, unknown>;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitManualEntry {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
setupCode?: string;
|
||||||
|
pairingData?: IHomekitPairingData;
|
||||||
|
accessories?: IHomekitAccessory[];
|
||||||
|
snapshot?: IHomekitSnapshot;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
category?: string | number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitSnapshot {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
paired?: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
transport?: THomekitTransport;
|
||||||
|
configNumber?: number;
|
||||||
|
stateNumber?: number;
|
||||||
|
pairingData?: IHomekitPairingData;
|
||||||
|
discovery?: IHomekitDiscoveryRecord;
|
||||||
|
accessories: IHomekitAccessory[];
|
||||||
|
events?: IHomekitEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitAccessory {
|
||||||
|
aid: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
firmwareRevision?: string;
|
||||||
|
hardwareRevision?: string;
|
||||||
|
services?: IHomekitService[] | Record<string, IHomekitService>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitService {
|
||||||
|
iid: number;
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
primary?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
characteristics?: IHomekitCharacteristic[] | Record<string, IHomekitCharacteristic>;
|
||||||
|
characteristicsByType?: Record<string, IHomekitCharacteristic>;
|
||||||
|
characteristics_by_type?: Record<string, IHomekitCharacteristic>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitCharacteristic {
|
||||||
|
iid: number;
|
||||||
|
type: string;
|
||||||
|
value?: unknown;
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
perms?: string[];
|
||||||
|
format?: string;
|
||||||
|
unit?: string;
|
||||||
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
minStep?: number;
|
||||||
|
maxLen?: number;
|
||||||
|
validValues?: unknown[];
|
||||||
|
available?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitCharacteristicReference {
|
||||||
|
aid: number;
|
||||||
|
iid: number;
|
||||||
|
type?: string;
|
||||||
|
serviceIid?: number;
|
||||||
|
serviceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitCharacteristicWrite extends IHomekitCharacteristicReference {
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitControllerCommand {
|
||||||
|
command: THomekitControllerCommandType;
|
||||||
|
reads?: IHomekitCharacteristicReference[];
|
||||||
|
writes?: IHomekitCharacteristicWrite[];
|
||||||
|
aid?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomekitEvent {
|
||||||
|
type: 'snapshot' | 'characteristic_updated' | 'availability_changed' | 'config_changed' | 'unsupported' | 'error';
|
||||||
|
aid?: number;
|
||||||
|
iid?: number;
|
||||||
|
value?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './homekit_controller.classes.client.js';
|
||||||
|
export * from './homekit_controller.classes.configflow.js';
|
||||||
export * from './homekit_controller.classes.integration.js';
|
export * from './homekit_controller.classes.integration.js';
|
||||||
|
export * from './homekit_controller.discovery.js';
|
||||||
|
export * from './homekit_controller.mapper.js';
|
||||||
export * from './homekit_controller.types.js';
|
export * from './homekit_controller.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.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './matter.classes.integration.js';
|
export * from './matter.classes.integration.js';
|
||||||
|
export * from './matter.classes.client.js';
|
||||||
|
export * from './matter.classes.configflow.js';
|
||||||
|
export * from './matter.discovery.js';
|
||||||
|
export * from './matter.mapper.js';
|
||||||
export * from './matter.types.js';
|
export * from './matter.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IMatterCommandFrame,
|
||||||
|
IMatterConfig,
|
||||||
|
IMatterErrorFrame,
|
||||||
|
IMatterNode,
|
||||||
|
IMatterResultFrame,
|
||||||
|
IMatterServerCommand,
|
||||||
|
IMatterServerEvent,
|
||||||
|
IMatterServerInfo,
|
||||||
|
IMatterSnapshot,
|
||||||
|
} from './matter.types.js';
|
||||||
|
|
||||||
|
const defaultMatterServerUrl = 'ws://localhost:5580/ws';
|
||||||
|
const defaultCommandTimeoutMs = 300000;
|
||||||
|
const bigIntMarker = '__BIGINT__';
|
||||||
|
|
||||||
|
type TMatterEventHandler = (eventArg: IMatterServerEvent) => void;
|
||||||
|
|
||||||
|
interface IPendingRequest {
|
||||||
|
resolve(valueArg: unknown): void;
|
||||||
|
reject(errorArg: Error): void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPendingServerInfo {
|
||||||
|
resolve(): void;
|
||||||
|
reject(errorArg: Error): void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatterClient {
|
||||||
|
private socket?: any;
|
||||||
|
private started = false;
|
||||||
|
private serverInfo?: IMatterServerInfo;
|
||||||
|
private readonly nodes = new Map<string, IMatterNode>();
|
||||||
|
private readonly events: IMatterServerEvent[] = [];
|
||||||
|
private readonly pendingRequests = new Map<string, IPendingRequest>();
|
||||||
|
private readonly eventHandlers = new Set<TMatterEventHandler>();
|
||||||
|
private pendingServerInfo?: IPendingServerInfo;
|
||||||
|
private messageCounter = Math.floor(Math.random() * 0x7fffffff);
|
||||||
|
|
||||||
|
constructor(private readonly config: IMatterConfig) {
|
||||||
|
this.serverInfo = config.snapshot?.serverInfo || config.serverInfo;
|
||||||
|
for (const node of config.snapshot?.nodes || config.nodes || []) {
|
||||||
|
this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node));
|
||||||
|
}
|
||||||
|
for (const event of config.snapshot?.events || config.events || []) {
|
||||||
|
this.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IMatterSnapshot> {
|
||||||
|
if (this.hasLocalSnapshot() && this.config.connect !== true) {
|
||||||
|
return {
|
||||||
|
serverInfo: this.config.snapshot?.serverInfo || this.serverInfo,
|
||||||
|
nodes: (this.config.snapshot?.nodes || [...this.nodes.values()]).map((nodeArg) => this.cloneNode(nodeArg)),
|
||||||
|
events: [...(this.config.snapshot?.events || this.events)],
|
||||||
|
connected: false,
|
||||||
|
url: this.url(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.url() && !this.started) {
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverInfo: this.serverInfo,
|
||||||
|
nodes: [...this.nodes.values()].map((nodeArg) => this.cloneNode(nodeArg)),
|
||||||
|
events: [...this.events],
|
||||||
|
connected: Boolean(this.socket?.readyState === 1),
|
||||||
|
url: this.url(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = this.url();
|
||||||
|
if (!url || (this.hasLocalSnapshot() && this.config.connect !== true)) {
|
||||||
|
this.started = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.connect(url);
|
||||||
|
const nodes = await this.sendConnectedCommand<IMatterNode[]>({ command: 'start_listening', args: {} });
|
||||||
|
this.nodes.clear();
|
||||||
|
if (Array.isArray(nodes)) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand<TResult = unknown>(commandArg: IMatterServerCommand): Promise<TResult> {
|
||||||
|
await this.ensureStarted();
|
||||||
|
return this.sendConnectedCommand<TResult>(commandArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TMatterEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.rejectAll(new Error('Matter client destroyed.'));
|
||||||
|
if (this.pendingServerInfo) {
|
||||||
|
clearTimeout(this.pendingServerInfo.timer);
|
||||||
|
this.pendingServerInfo.reject(new Error('Matter client destroyed.'));
|
||||||
|
this.pendingServerInfo = undefined;
|
||||||
|
}
|
||||||
|
if (this.socket?.readyState === 1 || this.socket?.readyState === 0) {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
this.socket = undefined;
|
||||||
|
this.started = false;
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureStarted(): Promise<void> {
|
||||||
|
if (!this.started) {
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
if (!this.socket || this.socket.readyState !== 1) {
|
||||||
|
throw new Error('Matter Server WebSocket is not connected.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect(urlArg: string): Promise<void> {
|
||||||
|
if (this.socket?.readyState === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: new (urlArg: string) => any }).WebSocket;
|
||||||
|
if (!WebSocketCtor) {
|
||||||
|
throw new Error('Global WebSocket is not available in this runtime.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocketCtor(urlArg);
|
||||||
|
this.socket = socket;
|
||||||
|
this.addSocketEvent(socket, 'message', (eventArg: { data: unknown }) => this.handleMessage(eventArg.data));
|
||||||
|
this.addSocketEvent(socket, 'close', () => this.handleClose());
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingServerInfo = undefined;
|
||||||
|
reject(new Error(`Matter Server at ${urlArg} did not send server info.`));
|
||||||
|
}, 10000);
|
||||||
|
this.pendingServerInfo = { resolve, reject, timer };
|
||||||
|
this.addSocketEvent(socket, 'error', () => {
|
||||||
|
if (this.pendingServerInfo) {
|
||||||
|
clearTimeout(this.pendingServerInfo.timer);
|
||||||
|
this.pendingServerInfo = undefined;
|
||||||
|
}
|
||||||
|
reject(new Error(`Unable to connect to Matter Server at ${urlArg}.`));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendConnectedCommand<TResult>(commandArg: IMatterServerCommand): Promise<TResult> {
|
||||||
|
if (!this.socket || this.socket.readyState !== 1) {
|
||||||
|
throw new Error('Matter Server WebSocket is not connected.');
|
||||||
|
}
|
||||||
|
if (commandArg.requireSchema && this.serverInfo && this.serverInfo.schema_version < commandArg.requireSchema) {
|
||||||
|
throw new Error(`Matter command ${commandArg.command} requires schema ${commandArg.requireSchema}.`);
|
||||||
|
}
|
||||||
|
const messageId = this.nextMessageId();
|
||||||
|
const frame: IMatterCommandFrame = {
|
||||||
|
message_id: messageId,
|
||||||
|
command: commandArg.command,
|
||||||
|
args: commandArg.args || {},
|
||||||
|
};
|
||||||
|
const timeoutMs = commandArg.timeoutMs ?? this.config.commandTimeoutMs ?? defaultCommandTimeoutMs;
|
||||||
|
const promise = new Promise<TResult>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(messageId);
|
||||||
|
reject(new Error(`Matter command ${commandArg.command} timed out.`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pendingRequests.set(messageId, { resolve: resolve as (valueArg: unknown) => void, reject, timer });
|
||||||
|
});
|
||||||
|
this.socket.send(toMatterJson(frame));
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(dataArg: unknown): void {
|
||||||
|
const message = parseMatterMessage(dataArg);
|
||||||
|
if (!this.isRecord(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isServerInfo(message) && !('message_id' in message)) {
|
||||||
|
this.serverInfo = message;
|
||||||
|
if (this.pendingServerInfo) {
|
||||||
|
clearTimeout(this.pendingServerInfo.timer);
|
||||||
|
const pending = this.pendingServerInfo;
|
||||||
|
this.pendingServerInfo = undefined;
|
||||||
|
pending.resolve();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof message.event === 'string') {
|
||||||
|
this.handleEvent(message as unknown as IMatterServerEvent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof message.message_id === 'string' && 'error_code' in message) {
|
||||||
|
this.rejectPending(message as unknown as IMatterErrorFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof message.message_id === 'string' && 'result' in message) {
|
||||||
|
this.resolvePending(message as unknown as IMatterResultFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent(eventArg: IMatterServerEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
if ((eventArg.event === 'node_added' || eventArg.event === 'node_updated') && this.isMatterNode(eventArg.data)) {
|
||||||
|
this.nodes.set(this.nodeKey(eventArg.data.node_id), this.cloneNode(eventArg.data));
|
||||||
|
} else if (eventArg.event === 'node_removed') {
|
||||||
|
this.nodes.delete(this.nodeKey(eventArg.data));
|
||||||
|
} else if (eventArg.event === 'attribute_updated' && Array.isArray(eventArg.data)) {
|
||||||
|
const [nodeId, attributePath, value] = eventArg.data;
|
||||||
|
if (typeof attributePath === 'string') {
|
||||||
|
const node = this.nodes.get(this.nodeKey(nodeId));
|
||||||
|
if (node) {
|
||||||
|
const updated = this.cloneNode(node);
|
||||||
|
updated.attributes[attributePath] = value;
|
||||||
|
this.nodes.set(this.nodeKey(nodeId), updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (eventArg.event === 'server_info_updated' && this.isServerInfo(eventArg.data)) {
|
||||||
|
this.serverInfo = eventArg.data;
|
||||||
|
}
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePending(frameArg: IMatterResultFrame): void {
|
||||||
|
const pending = this.pendingRequests.get(frameArg.message_id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingRequests.delete(frameArg.message_id);
|
||||||
|
pending.resolve(frameArg.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPending(frameArg: IMatterErrorFrame): void {
|
||||||
|
const pending = this.pendingRequests.get(frameArg.message_id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingRequests.delete(frameArg.message_id);
|
||||||
|
pending.reject(new Error(frameArg.details || `Matter command failed with error ${frameArg.error_code}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose(): void {
|
||||||
|
if (this.pendingServerInfo) {
|
||||||
|
clearTimeout(this.pendingServerInfo.timer);
|
||||||
|
this.pendingServerInfo.reject(new Error('Matter Server WebSocket closed before server info.'));
|
||||||
|
this.pendingServerInfo = undefined;
|
||||||
|
}
|
||||||
|
this.rejectAll(new Error('Matter Server WebSocket closed.'));
|
||||||
|
this.socket = undefined;
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAll(errorArg: Error): void {
|
||||||
|
for (const [messageId, pending] of this.pendingRequests) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(errorArg);
|
||||||
|
this.pendingRequests.delete(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSocketEvent(socketArg: any, typeArg: string, handlerArg: (eventArg: any) => void, optionsArg?: { once?: boolean }): void {
|
||||||
|
if (typeof socketArg.addEventListener === 'function') {
|
||||||
|
socketArg.addEventListener(typeArg, handlerArg, optionsArg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const property = `on${typeArg}`;
|
||||||
|
const previous = socketArg[property];
|
||||||
|
socketArg[property] = optionsArg?.once ? (eventArg: any) => {
|
||||||
|
socketArg[property] = previous;
|
||||||
|
handlerArg(eventArg);
|
||||||
|
} : handlerArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextMessageId(): string {
|
||||||
|
if (this.messageCounter >= Number.MAX_SAFE_INTEGER) {
|
||||||
|
this.messageCounter = 0;
|
||||||
|
}
|
||||||
|
return `shx-${++this.messageCounter}-${plugins.crypto.randomBytes(3).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private url(): string | undefined {
|
||||||
|
if (this.config.url) {
|
||||||
|
return this.config.url;
|
||||||
|
}
|
||||||
|
if (this.config.host) {
|
||||||
|
return `ws://${this.config.host}:${this.config.port || 5580}/ws`;
|
||||||
|
}
|
||||||
|
return this.hasLocalSnapshot() ? undefined : defaultMatterServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasLocalSnapshot(): boolean {
|
||||||
|
return Boolean(this.config.snapshot || this.config.nodes?.length || this.config.serverInfo || this.config.events?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneNode(nodeArg: IMatterNode): IMatterNode {
|
||||||
|
return { ...nodeArg, attributes: { ...nodeArg.attributes } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private nodeKey(valueArg: unknown): string {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMatterNode(valueArg: unknown): valueArg is IMatterNode {
|
||||||
|
return this.isRecord(valueArg) && 'node_id' in valueArg && this.isRecord(valueArg.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isServerInfo(valueArg: unknown): valueArg is IMatterServerInfo {
|
||||||
|
return this.isRecord(valueArg)
|
||||||
|
&& typeof valueArg.schema_version === 'number'
|
||||||
|
&& typeof valueArg.min_supported_schema_version === 'number'
|
||||||
|
&& typeof valueArg.sdk_version === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMatterMessage(dataArg: unknown): unknown {
|
||||||
|
const text = typeof dataArg === 'string'
|
||||||
|
? dataArg
|
||||||
|
: Buffer.isBuffer(dataArg)
|
||||||
|
? dataArg.toString('utf8')
|
||||||
|
: dataArg instanceof ArrayBuffer
|
||||||
|
? Buffer.from(dataArg).toString('utf8')
|
||||||
|
: String(dataArg);
|
||||||
|
return parseBigIntAwareJson(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMatterJson(valueArg: unknown): string {
|
||||||
|
const replacements: Array<{ from: string; to: string }> = [];
|
||||||
|
let result = JSON.stringify(valueArg, (_key, value) => {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
|
||||||
|
replacements.push({ from: `"0x${value.toString(16)}"`, to: value.toString() });
|
||||||
|
return `0x${value.toString(16)}`;
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
for (const replacement of replacements) {
|
||||||
|
result = result.replaceAll(replacement.from, replacement.to);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBigIntAwareJson(jsonArg: string): unknown {
|
||||||
|
const result: string[] = [];
|
||||||
|
let index = 0;
|
||||||
|
let inString = false;
|
||||||
|
while (index < jsonArg.length) {
|
||||||
|
const char = jsonArg[index];
|
||||||
|
if (inString) {
|
||||||
|
if (char === '\\') {
|
||||||
|
result.push(char);
|
||||||
|
index++;
|
||||||
|
if (index < jsonArg.length) {
|
||||||
|
result.push(jsonArg[index]);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
} else if (char === '"') {
|
||||||
|
result.push(char);
|
||||||
|
inString = false;
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
result.push(char);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
result.push(char);
|
||||||
|
inString = true;
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char >= '0' && char <= '9') {
|
||||||
|
const hasMinus = result.length > 0 && result[result.length - 1] === '-';
|
||||||
|
if (hasMinus) {
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
const start = index;
|
||||||
|
while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
let isFloat = false;
|
||||||
|
if (jsonArg[index] === '.') {
|
||||||
|
isFloat = true;
|
||||||
|
index++;
|
||||||
|
while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonArg[index] === 'e' || jsonArg[index] === 'E') {
|
||||||
|
isFloat = true;
|
||||||
|
index++;
|
||||||
|
if (jsonArg[index] === '+' || jsonArg[index] === '-') {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const numberString = `${hasMinus ? '-' : ''}${jsonArg.slice(start, index)}`;
|
||||||
|
if (!isFloat && numberString.length - (hasMinus ? 1 : 0) >= 15) {
|
||||||
|
const value = BigInt(numberString);
|
||||||
|
result.push(value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER ? `"${bigIntMarker}${numberString}"` : numberString);
|
||||||
|
} else {
|
||||||
|
result.push(numberString);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(char);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return JSON.parse(result.join(''), (_key, value) => {
|
||||||
|
if (typeof value === 'string' && value.startsWith(bigIntMarker)) {
|
||||||
|
return BigInt(value.slice(bigIntMarker.length));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IMatterConfig } from './matter.types.js';
|
||||||
|
|
||||||
|
const defaultMatterServerUrl = 'ws://localhost:5580/ws';
|
||||||
|
|
||||||
|
export class MatterConfigFlow implements IConfigFlow<IMatterConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMatterConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
const defaultUrl = this.urlFromCandidate(candidateArg);
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Matter',
|
||||||
|
description: `Configure the local Matter Server WebSocket endpoint. Default: ${defaultUrl}`,
|
||||||
|
fields: [
|
||||||
|
{ name: 'url', label: 'Matter Server URL', type: 'text', required: true },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => ({
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Matter configured',
|
||||||
|
config: {
|
||||||
|
url: this.stringValue(valuesArg.url) || defaultUrl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private urlFromCandidate(candidateArg: IDiscoveryCandidate): string {
|
||||||
|
if (typeof candidateArg.metadata?.url === 'string' && candidateArg.metadata.url.trim()) {
|
||||||
|
return candidateArg.metadata.url.trim();
|
||||||
|
}
|
||||||
|
if (candidateArg.host) {
|
||||||
|
return `ws://${candidateArg.host}:${candidateArg.port || 5580}/ws`;
|
||||||
|
}
|
||||||
|
return defaultMatterServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,203 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { MatterClient } from './matter.classes.client.js';
|
||||||
|
import { MatterConfigFlow } from './matter.classes.configflow.js';
|
||||||
|
import { createMatterDiscoveryDescriptor } from './matter.discovery.js';
|
||||||
|
import { MatterMapper } from './matter.mapper.js';
|
||||||
|
import type { IMatterConfig, IMatterServerCommand, TMatterNodeId } from './matter.types.js';
|
||||||
|
|
||||||
export class HomeAssistantMatterIntegration extends DescriptorOnlyIntegration {
|
export class MatterIntegration extends BaseIntegration<IMatterConfig> {
|
||||||
constructor() {
|
public readonly domain = 'matter';
|
||||||
super({
|
public readonly displayName = 'Matter';
|
||||||
domain: "matter",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Matter",
|
public readonly discoveryDescriptor = createMatterDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new MatterConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/matter",
|
domain: 'matter',
|
||||||
"upstreamDomain": "matter",
|
name: 'Matter',
|
||||||
"integrationType": "hub",
|
upstreamPath: 'homeassistant/components/matter',
|
||||||
"iotClass": "local_push",
|
upstreamDomain: 'matter',
|
||||||
"requirements": [
|
configFlow: true,
|
||||||
"matter-python-client==0.6.0"
|
integrationType: 'hub',
|
||||||
],
|
iotClass: 'local_push',
|
||||||
"dependencies": [
|
requirements: ['matter-python-client==0.6.0'],
|
||||||
"websocket_api"
|
dependencies: ['websocket_api'],
|
||||||
],
|
afterDependencies: ['hassio'],
|
||||||
"afterDependencies": [
|
codeowners: ['@home-assistant/matter'],
|
||||||
"hassio"
|
documentation: 'https://www.home-assistant.io/integrations/matter',
|
||||||
],
|
zeroconf: ['_matter._tcp.local.', '_matterc._udp.local.'],
|
||||||
"codeowners": [
|
};
|
||||||
"@home-assistant/matter"
|
|
||||||
]
|
public async setup(configArg: IMatterConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
},
|
void contextArg;
|
||||||
|
return new MatterRuntime(new MatterClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantMatterIntegration extends MatterIntegration {}
|
||||||
|
|
||||||
|
class MatterRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'matter';
|
||||||
|
|
||||||
|
constructor(private readonly client: MatterClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return MatterMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return MatterMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: this.integrationEventType(eventArg.event),
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
deviceId: this.deviceIdFromEvent(eventArg.data),
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const command = requestArg.domain === 'matter'
|
||||||
|
? this.commandFromMatterService(requestArg)
|
||||||
|
: MatterMapper.commandForService(await this.client.getSnapshot(), requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `Unsupported Matter service: ${requestArg.domain}.${requestArg.service}` };
|
||||||
|
}
|
||||||
|
const data = await this.client.sendCommand(command);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandFromMatterService(requestArg: IServiceCallRequest): IMatterServerCommand | undefined {
|
||||||
|
if (requestArg.service === 'commission_with_code') {
|
||||||
|
const code = this.stringValue(requestArg.data?.code);
|
||||||
|
return code ? { command: 'commission_with_code', args: { code, network_only: requestArg.data?.network_only === true || requestArg.data?.networkOnly === true }, timeoutMs: 600000 } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'commission_on_network') {
|
||||||
|
const setupPinCode = this.numberValue(requestArg.data?.setup_pin_code ?? requestArg.data?.setupPinCode);
|
||||||
|
if (setupPinCode === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'commission_on_network',
|
||||||
|
args: {
|
||||||
|
setup_pin_code: setupPinCode,
|
||||||
|
filter_type: this.numberValue(requestArg.data?.filter_type ?? requestArg.data?.filterType),
|
||||||
|
filter: this.numberValue(requestArg.data?.filter),
|
||||||
|
ip_addr: this.stringValue(requestArg.data?.ip_addr ?? requestArg.data?.ipAddr),
|
||||||
|
},
|
||||||
|
timeoutMs: 600000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'open_commissioning_window') {
|
||||||
|
const nodeId = this.nodeIdFromRequest(requestArg);
|
||||||
|
return nodeId === undefined ? undefined : {
|
||||||
|
command: 'open_commissioning_window',
|
||||||
|
args: {
|
||||||
|
node_id: nodeId,
|
||||||
|
timeout: this.numberValue(requestArg.data?.timeout),
|
||||||
|
iteration: this.numberValue(requestArg.data?.iteration),
|
||||||
|
option: this.numberValue(requestArg.data?.option),
|
||||||
|
discriminator: this.numberValue(requestArg.data?.discriminator),
|
||||||
|
},
|
||||||
|
timeoutMs: 600000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'remove_node') {
|
||||||
|
const nodeId = this.nodeIdFromRequest(requestArg);
|
||||||
|
return nodeId === undefined ? undefined : { command: 'remove_node', args: { node_id: nodeId }, timeoutMs: 600000 };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'interview_node') {
|
||||||
|
const nodeId = this.nodeIdFromRequest(requestArg);
|
||||||
|
return nodeId === undefined ? undefined : { command: 'interview_node', args: { node_id: nodeId }, timeoutMs: 600000 };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'ping_node') {
|
||||||
|
const nodeId = this.nodeIdFromRequest(requestArg);
|
||||||
|
return nodeId === undefined ? undefined : { command: 'ping_node', args: { node_id: nodeId, attempts: this.numberValue(requestArg.data?.attempts) } };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_default_fabric_label') {
|
||||||
|
const label = requestArg.data?.label;
|
||||||
|
return label === null || typeof label === 'string' ? { command: 'set_default_fabric_label', requireSchema: 11, args: { label } } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private integrationEventType(eventArg: string): IIntegrationEvent['type'] {
|
||||||
|
if (eventArg === 'node_added' || eventArg === 'endpoint_added') {
|
||||||
|
return 'device_added';
|
||||||
|
}
|
||||||
|
if (eventArg === 'node_removed' || eventArg === 'endpoint_removed') {
|
||||||
|
return 'device_removed';
|
||||||
|
}
|
||||||
|
if (eventArg === 'node_updated') {
|
||||||
|
return 'availability_changed';
|
||||||
|
}
|
||||||
|
if (eventArg === 'attribute_updated' || eventArg === 'node_event') {
|
||||||
|
return 'state_changed';
|
||||||
|
}
|
||||||
|
return eventArg === 'server_shutdown' ? 'error' : 'state_changed';
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceIdFromEvent(dataArg: unknown): string | undefined {
|
||||||
|
if (Array.isArray(dataArg)) {
|
||||||
|
return `matter.node.${this.slug(String(dataArg[0]))}`;
|
||||||
|
}
|
||||||
|
if (this.isRecord(dataArg)) {
|
||||||
|
const nodeId = dataArg.node_id ?? dataArg.nodeId;
|
||||||
|
const endpointId = this.numberValue(dataArg.endpoint_id ?? dataArg.endpointId);
|
||||||
|
if (nodeId !== undefined) {
|
||||||
|
return endpointId === undefined ? `matter.node.${this.slug(String(nodeId))}` : `matter.node.${this.slug(String(nodeId))}.endpoint.${endpointId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof dataArg === 'number' || typeof dataArg === 'bigint' || typeof dataArg === 'string') {
|
||||||
|
return `matter.node.${this.slug(String(dataArg))}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nodeIdFromRequest(requestArg: IServiceCallRequest): TMatterNodeId | undefined {
|
||||||
|
const value = requestArg.data?.node_id ?? requestArg.data?.nodeId;
|
||||||
|
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const deviceId = requestArg.target.deviceId;
|
||||||
|
const match = deviceId?.match(/^matter\.node\.([^.]+)/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'matter';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IMatterManualEntry, IMatterMdnsRecord } from './matter.types.js';
|
||||||
|
|
||||||
|
const defaultMatterServerUrl = 'ws://localhost:5580/ws';
|
||||||
|
|
||||||
|
export class MatterMdnsMatcher implements IDiscoveryMatcher<IMatterMdnsRecord> {
|
||||||
|
public id = 'matter-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Matter zeroconf advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IMatterMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const matched = type === '_matter._tcp.local' || type === '_matterc._udp.local';
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Matter advertisement.' };
|
||||||
|
}
|
||||||
|
const instanceName = this.txt(recordArg, 'dn') || this.txt(recordArg, 'D') || recordArg.name;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: 'high',
|
||||||
|
reason: 'mDNS record matches Matter zeroconf metadata.',
|
||||||
|
normalizedDeviceId: recordArg.name || recordArg.host,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
id: recordArg.name || recordArg.host,
|
||||||
|
host: recordArg.host || recordArg.addresses?.[0],
|
||||||
|
port: recordArg.port,
|
||||||
|
name: instanceName || 'Matter device',
|
||||||
|
manufacturer: 'Matter',
|
||||||
|
model: type === '_matterc._udp.local' ? 'Commissionable Matter device' : 'Matter device',
|
||||||
|
metadata: {
|
||||||
|
matter: true,
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt: recordArg.txt,
|
||||||
|
url: this.txt(recordArg, 'url'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(recordArg: IMatterMdnsRecord, keyArg: string): string | undefined {
|
||||||
|
return recordArg.txt?.[keyArg] || recordArg.txt?.[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatterManualMatcher implements IDiscoveryMatcher<IMatterManualEntry> {
|
||||||
|
public id = 'matter-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Matter Server setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IMatterManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const url = inputArg.url || (inputArg.host ? `ws://${inputArg.host}:${inputArg.port || 5580}/ws` : undefined);
|
||||||
|
const matched = Boolean(url || inputArg.metadata?.matter || model.includes('matter')) || !inputArg.id && !inputArg.model;
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Matter setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: url ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Matter Server setup.',
|
||||||
|
normalizedDeviceId: inputArg.id || url || defaultMatterServerUrl,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
id: inputArg.id || url || defaultMatterServerUrl,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || 5580,
|
||||||
|
name: inputArg.name || 'Matter',
|
||||||
|
manufacturer: 'Matter',
|
||||||
|
model: inputArg.model || 'Matter Server',
|
||||||
|
metadata: { ...inputArg.metadata, matter: true, url: url || defaultMatterServerUrl },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatterCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'matter-candidate-validator';
|
||||||
|
public description = 'Validate Matter candidate metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'matter'
|
||||||
|
|| Boolean(candidateArg.metadata?.matter)
|
||||||
|
|| model.includes('matter')
|
||||||
|
|| manufacturer.includes('matter');
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && (candidateArg.host || candidateArg.metadata?.url) ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Matter metadata.' : 'Candidate is not Matter.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMatterDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'matter', displayName: 'Matter' })
|
||||||
|
.addMatcher(new MatterMdnsMatcher())
|
||||||
|
.addMatcher(new MatterManualMatcher())
|
||||||
|
.addValidator(new MatterCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,873 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type {
|
||||||
|
IMatterAttribute,
|
||||||
|
IMatterCluster,
|
||||||
|
IMatterConfig,
|
||||||
|
IMatterDeviceInfo,
|
||||||
|
IMatterDeviceType,
|
||||||
|
IMatterEndpoint,
|
||||||
|
IMatterNode,
|
||||||
|
IMatterServerCommand,
|
||||||
|
IMatterServerEvent,
|
||||||
|
IMatterServiceCommandArgs,
|
||||||
|
IMatterSnapshot,
|
||||||
|
TMatterEntityPlatform,
|
||||||
|
TMatterNodeId,
|
||||||
|
} from './matter.types.js';
|
||||||
|
|
||||||
|
const clusters = {
|
||||||
|
descriptor: 29,
|
||||||
|
powerSource: 47,
|
||||||
|
bridgedDeviceBasicInformation: 57,
|
||||||
|
booleanState: 69,
|
||||||
|
airQuality: 91,
|
||||||
|
smokeCoAlarm: 92,
|
||||||
|
onOff: 6,
|
||||||
|
levelControl: 8,
|
||||||
|
basicInformation: 40,
|
||||||
|
doorLock: 257,
|
||||||
|
windowCovering: 258,
|
||||||
|
thermostat: 513,
|
||||||
|
colorControl: 768,
|
||||||
|
illuminanceMeasurement: 1024,
|
||||||
|
temperatureMeasurement: 1026,
|
||||||
|
pressureMeasurement: 1027,
|
||||||
|
flowMeasurement: 1028,
|
||||||
|
relativeHumidityMeasurement: 1029,
|
||||||
|
occupancySensing: 1030,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const globalAttributes = {
|
||||||
|
acceptedCommandList: 65529,
|
||||||
|
attributeList: 65531,
|
||||||
|
featureMap: 65532,
|
||||||
|
clusterRevision: 65533,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const deviceTypes: Record<number, string> = {
|
||||||
|
0x000a: 'Door Lock',
|
||||||
|
0x000e: 'Aggregator',
|
||||||
|
0x000f: 'Generic Switch',
|
||||||
|
0x0013: 'Bridged Node',
|
||||||
|
0x0016: 'Root Node',
|
||||||
|
0x0015: 'Contact Sensor',
|
||||||
|
0x002c: 'Air Quality Sensor',
|
||||||
|
0x0041: 'Water Freeze Detector',
|
||||||
|
0x0043: 'Water Leak Detector',
|
||||||
|
0x0044: 'Rain Sensor',
|
||||||
|
0x0072: 'Room Air Conditioner',
|
||||||
|
0x0076: 'Smoke CO Alarm',
|
||||||
|
0x0100: 'On/Off Light',
|
||||||
|
0x0101: 'Dimmable Light',
|
||||||
|
0x0103: 'On/Off Light Switch',
|
||||||
|
0x0104: 'Dimmer Switch',
|
||||||
|
0x0105: 'Color Dimmer Switch',
|
||||||
|
0x0106: 'Light Sensor',
|
||||||
|
0x0107: 'Occupancy Sensor',
|
||||||
|
0x010a: 'On/Off Plug-in Unit',
|
||||||
|
0x010b: 'Dimmable Plug-in Unit',
|
||||||
|
0x010c: 'Color Temperature Light',
|
||||||
|
0x010d: 'Extended Color Light',
|
||||||
|
0x0202: 'Window Covering',
|
||||||
|
0x0301: 'Thermostat',
|
||||||
|
0x0302: 'Temperature Sensor',
|
||||||
|
0x0305: 'Pressure Sensor',
|
||||||
|
0x0306: 'Flow Sensor',
|
||||||
|
0x0307: 'Humidity Sensor',
|
||||||
|
};
|
||||||
|
|
||||||
|
const clusterNames: Record<number, string> = {
|
||||||
|
[clusters.descriptor]: 'Descriptor',
|
||||||
|
[clusters.powerSource]: 'PowerSource',
|
||||||
|
[clusters.bridgedDeviceBasicInformation]: 'BridgedDeviceBasicInformation',
|
||||||
|
[clusters.booleanState]: 'BooleanState',
|
||||||
|
[clusters.airQuality]: 'AirQuality',
|
||||||
|
[clusters.smokeCoAlarm]: 'SmokeCoAlarm',
|
||||||
|
[clusters.onOff]: 'OnOff',
|
||||||
|
[clusters.levelControl]: 'LevelControl',
|
||||||
|
[clusters.basicInformation]: 'BasicInformation',
|
||||||
|
[clusters.doorLock]: 'DoorLock',
|
||||||
|
[clusters.windowCovering]: 'WindowCovering',
|
||||||
|
[clusters.thermostat]: 'Thermostat',
|
||||||
|
[clusters.colorControl]: 'ColorControl',
|
||||||
|
[clusters.illuminanceMeasurement]: 'IlluminanceMeasurement',
|
||||||
|
[clusters.temperatureMeasurement]: 'TemperatureMeasurement',
|
||||||
|
[clusters.pressureMeasurement]: 'PressureMeasurement',
|
||||||
|
[clusters.flowMeasurement]: 'FlowMeasurement',
|
||||||
|
[clusters.relativeHumidityMeasurement]: 'RelativeHumidityMeasurement',
|
||||||
|
[clusters.occupancySensing]: 'OccupancySensing',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeNames: Record<number, Record<number, string>> = {
|
||||||
|
[clusters.descriptor]: { 0: 'DeviceTypeList', 1: 'ServerList', 2: 'ClientList', 3: 'PartsList' },
|
||||||
|
[clusters.basicInformation]: { 1: 'VendorName', 2: 'VendorID', 3: 'ProductName', 4: 'ProductID', 5: 'NodeLabel', 8: 'HardwareVersionString', 10: 'SoftwareVersionString', 14: 'ProductLabel', 15: 'SerialNumber' },
|
||||||
|
[clusters.bridgedDeviceBasicInformation]: { 1: 'VendorName', 3: 'ProductName', 5: 'NodeLabel', 8: 'HardwareVersionString', 10: 'SoftwareVersionString', 14: 'ProductLabel', 15: 'SerialNumber', 17: 'Reachable' },
|
||||||
|
[clusters.onOff]: { 0: 'OnOff' },
|
||||||
|
[clusters.levelControl]: { 0: 'CurrentLevel', 2: 'MinLevel', 3: 'MaxLevel' },
|
||||||
|
[clusters.colorControl]: { 0: 'CurrentHue', 1: 'CurrentSaturation', 3: 'CurrentX', 4: 'CurrentY', 7: 'ColorTemperatureMireds', 8: 'ColorMode' },
|
||||||
|
[clusters.windowCovering]: { 0: 'Type', 10: 'OperationalStatus', 14: 'CurrentPositionLiftPercent100ths', 15: 'CurrentPositionTiltPercent100ths' },
|
||||||
|
[clusters.doorLock]: { 0: 'LockState', 1: 'LockType', 2: 'ActuatorEnabled', 3: 'DoorState' },
|
||||||
|
[clusters.thermostat]: { 0: 'LocalTemperature', 2: 'Occupancy', 17: 'OccupiedCoolingSetpoint', 18: 'OccupiedHeatingSetpoint', 27: 'ControlSequenceOfOperation', 28: 'SystemMode' },
|
||||||
|
[clusters.powerSource]: { 12: 'BatPercentRemaining', 14: 'BatChargeLevel', 26: 'BatChargeState' },
|
||||||
|
[clusters.booleanState]: { 0: 'StateValue' },
|
||||||
|
[clusters.occupancySensing]: { 0: 'Occupancy' },
|
||||||
|
[clusters.airQuality]: { 0: 'AirQuality' },
|
||||||
|
[clusters.temperatureMeasurement]: { 0: 'MeasuredValue' },
|
||||||
|
[clusters.relativeHumidityMeasurement]: { 0: 'MeasuredValue' },
|
||||||
|
[clusters.illuminanceMeasurement]: { 0: 'MeasuredValue' },
|
||||||
|
[clusters.pressureMeasurement]: { 0: 'MeasuredValue' },
|
||||||
|
[clusters.flowMeasurement]: { 0: 'MeasuredValue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightDeviceTypes = new Set([0x0100, 0x0101, 0x010c, 0x010d]);
|
||||||
|
const plugDeviceTypes = new Set([0x010a, 0x010b]);
|
||||||
|
const rootOnlyDeviceTypes = new Set([0x0013, 0x0016]);
|
||||||
|
const infrastructureClusters = new Set<number>([clusters.descriptor, clusters.basicInformation, clusters.bridgedDeviceBasicInformation]);
|
||||||
|
const lockTimedRequestTimeoutMs = 10000;
|
||||||
|
|
||||||
|
export class MatterMapper {
|
||||||
|
public static toSnapshot(configArg: IMatterConfig, connectedArg = false, eventsArg: IMatterServerEvent[] = []): IMatterSnapshot {
|
||||||
|
if (configArg.snapshot) {
|
||||||
|
return {
|
||||||
|
...configArg.snapshot,
|
||||||
|
nodes: configArg.snapshot.nodes || [],
|
||||||
|
events: [...(configArg.snapshot.events || []), ...eventsArg],
|
||||||
|
connected: connectedArg || configArg.snapshot.connected,
|
||||||
|
url: configArg.snapshot.url || this.urlFromConfig(configArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverInfo: configArg.serverInfo,
|
||||||
|
nodes: configArg.nodes || [],
|
||||||
|
events: [...(configArg.events || []), ...eventsArg],
|
||||||
|
connected: connectedArg,
|
||||||
|
url: this.urlFromConfig(configArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDevices(snapshotArg: IMatterSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
const entitiesByDevice = new Map<string, IIntegrationEntity[]>();
|
||||||
|
for (const entity of entities) {
|
||||||
|
const list = entitiesByDevice.get(entity.deviceId) || [];
|
||||||
|
list.push(entity);
|
||||||
|
entitiesByDevice.set(entity.deviceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
|
||||||
|
id: this.controllerDeviceId(snapshotArg),
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
name: 'Matter Server',
|
||||||
|
protocol: 'matter',
|
||||||
|
manufacturer: 'Matter',
|
||||||
|
model: 'Matter Server',
|
||||||
|
online: snapshotArg.connected || Boolean(snapshotArg.nodes.length),
|
||||||
|
features: [
|
||||||
|
{ id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false },
|
||||||
|
{ id: 'node_count', capability: 'sensor', name: 'Node count', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt },
|
||||||
|
{ featureId: 'node_count', value: snapshotArg.nodes.length, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
url: snapshotArg.url,
|
||||||
|
schemaVersion: snapshotArg.serverInfo?.schema_version,
|
||||||
|
sdkVersion: snapshotArg.serverInfo?.sdk_version,
|
||||||
|
fabricId: this.safeStateValue(snapshotArg.serverInfo?.fabric_id),
|
||||||
|
compressedFabricId: this.safeStateValue(snapshotArg.serverInfo?.compressed_fabric_id),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (const node of snapshotArg.nodes) {
|
||||||
|
let addedNodeDevice = false;
|
||||||
|
for (const endpoint of this.nodeEndpoints(node)) {
|
||||||
|
const endpointEntities = entitiesByDevice.get(this.endpointDeviceId(node, endpoint.endpointId)) || [];
|
||||||
|
if (!endpointEntities.length && !this.isUsefulEndpoint(endpoint)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
devices.push(this.deviceForEndpoint(node, endpoint, endpointEntities, updatedAt));
|
||||||
|
addedNodeDevice = true;
|
||||||
|
}
|
||||||
|
if (!addedNodeDevice) {
|
||||||
|
devices.push(this.fallbackDeviceForNode(node, updatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IMatterSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
for (const node of snapshotArg.nodes) {
|
||||||
|
for (const endpoint of this.nodeEndpoints(node)) {
|
||||||
|
entities.push(...this.entitiesForEndpoint(node, endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEndpoints(snapshotArg: IMatterSnapshot): IMatterEndpoint[] {
|
||||||
|
return snapshotArg.nodes.flatMap((nodeArg) => this.nodeEndpoints(nodeArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IMatterSnapshot, requestArg: IServiceCallRequest): IMatterServerCommand<IMatterServiceCommandArgs> | undefined {
|
||||||
|
const target = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
if (!target) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = String(target.platform);
|
||||||
|
if (requestArg.service === 'turn_on' && (platform === 'light' || platform === 'switch')) {
|
||||||
|
return this.deviceCommand(target, clusters.onOff, 'On');
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off' && (platform === 'light' || platform === 'switch')) {
|
||||||
|
return this.deviceCommand(target, clusters.onOff, 'Off');
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'open_cover' && platform === 'cover') {
|
||||||
|
return this.deviceCommand(target, clusters.windowCovering, 'UpOrOpen');
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'close_cover' && platform === 'cover') {
|
||||||
|
return this.deviceCommand(target, clusters.windowCovering, 'DownOrClose');
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_position' && platform === 'cover') {
|
||||||
|
const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.value);
|
||||||
|
if (position === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.deviceCommand(target, clusters.windowCovering, 'GoToLiftPercentage', {
|
||||||
|
liftPercent100thsValue: (100 - this.clamp(position, 0, 100)) * 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'lock' && platform === 'lock') {
|
||||||
|
return this.lockCommand(target, 'LockDoor', requestArg.data?.code);
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'unlock' && platform === 'lock') {
|
||||||
|
return this.lockCommand(target, 'UnlockDoor', requestArg.data?.code);
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
return this.setValueCommand(target, requestArg.data || {});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static nodeEndpoints(nodeArg: IMatterNode): IMatterEndpoint[] {
|
||||||
|
const attributesByEndpoint = new Map<number, IMatterAttribute[]>();
|
||||||
|
for (const [path, value] of Object.entries(nodeArg.attributes || {})) {
|
||||||
|
const parsed = this.parseAttributePath(path);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attribute: IMatterAttribute = {
|
||||||
|
path,
|
||||||
|
endpointId: parsed.endpointId,
|
||||||
|
clusterId: parsed.clusterId,
|
||||||
|
attributeId: parsed.attributeId,
|
||||||
|
clusterName: this.clusterName(parsed.clusterId),
|
||||||
|
attributeName: this.attributeName(parsed.clusterId, parsed.attributeId),
|
||||||
|
value,
|
||||||
|
readable: true,
|
||||||
|
writable: this.attributeWritable(parsed.clusterId, parsed.attributeId),
|
||||||
|
};
|
||||||
|
const list = attributesByEndpoint.get(parsed.endpointId) || [];
|
||||||
|
list.push(attribute);
|
||||||
|
attributesByEndpoint.set(parsed.endpointId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootAttributes = attributesByEndpoint.get(0) || [];
|
||||||
|
return [...attributesByEndpoint.entries()]
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([endpointId, attributes]) => {
|
||||||
|
const serverList = this.numberList(this.attributeValue(attributes, clusters.descriptor, 1));
|
||||||
|
const clusterIds = new Set<number>(serverList);
|
||||||
|
for (const attribute of attributes) {
|
||||||
|
clusterIds.add(attribute.clusterId);
|
||||||
|
}
|
||||||
|
const endpoint: IMatterEndpoint = {
|
||||||
|
nodeId: nodeArg.node_id,
|
||||||
|
endpointId,
|
||||||
|
attributes,
|
||||||
|
deviceTypes: this.deviceTypesFromValue(this.attributeValue(attributes, clusters.descriptor, 0)),
|
||||||
|
clusters: [],
|
||||||
|
deviceInfo: this.deviceInfo(rootAttributes, attributes),
|
||||||
|
};
|
||||||
|
endpoint.clusters = [...clusterIds].sort((left, right) => left - right).map((clusterId) => ({
|
||||||
|
id: clusterId,
|
||||||
|
name: this.clusterName(clusterId),
|
||||||
|
attributes: attributes.filter((attributeArg) => attributeArg.clusterId === clusterId),
|
||||||
|
} satisfies IMatterCluster));
|
||||||
|
return endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entitiesForEndpoint(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const hasOnOff = this.hasCluster(endpointArg, clusters.onOff) && this.hasAttribute(endpointArg, clusters.onOff, 0);
|
||||||
|
if (hasOnOff && this.isLightEndpoint(endpointArg)) {
|
||||||
|
entities.push(this.lightEntity(nodeArg, endpointArg));
|
||||||
|
} else if (hasOnOff) {
|
||||||
|
entities.push(this.switchEntity(nodeArg, endpointArg));
|
||||||
|
}
|
||||||
|
if (this.hasCluster(endpointArg, clusters.windowCovering)) {
|
||||||
|
entities.push(this.coverEntity(nodeArg, endpointArg));
|
||||||
|
}
|
||||||
|
if (this.hasCluster(endpointArg, clusters.doorLock) && this.hasAttribute(endpointArg, clusters.doorLock, 0)) {
|
||||||
|
entities.push(this.lockEntity(nodeArg, endpointArg));
|
||||||
|
}
|
||||||
|
if (this.hasCluster(endpointArg, clusters.thermostat)) {
|
||||||
|
entities.push(this.climateEntity(nodeArg, endpointArg));
|
||||||
|
}
|
||||||
|
entities.push(...this.binarySensorEntities(nodeArg, endpointArg));
|
||||||
|
entities.push(...this.sensorEntities(nodeArg, endpointArg));
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity {
|
||||||
|
const onOff = this.attributeValue(endpointArg.attributes, clusters.onOff, 0);
|
||||||
|
const currentLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 0));
|
||||||
|
const minLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 2)) || 1;
|
||||||
|
const maxLevel = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.levelControl, 3)) || 254;
|
||||||
|
const brightness = currentLevel === undefined ? undefined : Math.round(this.renormalize(currentLevel, [minLevel, maxLevel], [1, 255]));
|
||||||
|
return this.entity(nodeArg, endpointArg, 'light', 'light', 'Light', onOff === true ? 'on' : onOff === false ? 'off' : 'unknown', clusters.onOff, 0, {
|
||||||
|
brightness,
|
||||||
|
colorMode: this.attributeValue(endpointArg.attributes, clusters.colorControl, 8),
|
||||||
|
colorTemperatureMireds: this.attributeValue(endpointArg.attributes, clusters.colorControl, 7),
|
||||||
|
currentHue: this.attributeValue(endpointArg.attributes, clusters.colorControl, 0),
|
||||||
|
currentSaturation: this.attributeValue(endpointArg.attributes, clusters.colorControl, 1),
|
||||||
|
writable: true,
|
||||||
|
commands: ['On', 'Off', 'Toggle', 'MoveToLevelWithOnOff'],
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static switchEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity {
|
||||||
|
const onOff = this.attributeValue(endpointArg.attributes, clusters.onOff, 0);
|
||||||
|
return this.entity(nodeArg, endpointArg, 'switch', 'switch', 'Switch', onOff === true ? 'on' : onOff === false ? 'off' : 'unknown', clusters.onOff, 0, {
|
||||||
|
writable: true,
|
||||||
|
commands: ['On', 'Off', 'Toggle'],
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coverEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity {
|
||||||
|
const operationalStatus = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 10)) || 0;
|
||||||
|
const rawPosition = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 14));
|
||||||
|
const rawTilt = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.windowCovering, 15));
|
||||||
|
const position = rawPosition === undefined ? undefined : 100 - Math.floor(rawPosition / 100);
|
||||||
|
const tiltPosition = rawTilt === undefined ? undefined : 100 - Math.floor(rawTilt / 100);
|
||||||
|
let state = 'idle';
|
||||||
|
if ((operationalStatus & 0b11) === 0b01) {
|
||||||
|
state = 'opening';
|
||||||
|
} else if ((operationalStatus & 0b11) === 0b10) {
|
||||||
|
state = 'closing';
|
||||||
|
} else if (position !== undefined) {
|
||||||
|
state = position <= 0 ? 'closed' : 'open';
|
||||||
|
}
|
||||||
|
return this.entity(nodeArg, endpointArg, 'cover', 'cover', 'Cover', state, clusters.windowCovering, 10, {
|
||||||
|
position,
|
||||||
|
tiltPosition,
|
||||||
|
coverType: this.attributeValue(endpointArg.attributes, clusters.windowCovering, 0),
|
||||||
|
writable: true,
|
||||||
|
commands: ['UpOrOpen', 'DownOrClose', 'StopMotion', 'GoToLiftPercentage', 'GoToTiltPercentage'],
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lockEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity {
|
||||||
|
const lockState = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.doorLock, 0));
|
||||||
|
const state = lockState === 1 ? 'locked' : lockState === 2 ? 'unlocked' : lockState === 3 ? 'open' : 'unknown';
|
||||||
|
return this.entity(nodeArg, endpointArg, 'lock', 'lock', 'Lock', state, clusters.doorLock, 0, {
|
||||||
|
lockState,
|
||||||
|
lockType: this.attributeValue(endpointArg.attributes, clusters.doorLock, 1),
|
||||||
|
actuatorEnabled: this.attributeValue(endpointArg.attributes, clusters.doorLock, 2),
|
||||||
|
writable: true,
|
||||||
|
commands: ['LockDoor', 'UnlockDoor'],
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static climateEntity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity {
|
||||||
|
const mode = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 28));
|
||||||
|
const heatingPath = this.attributePath(endpointArg.endpointId, clusters.thermostat, 18);
|
||||||
|
const coolingPath = this.attributePath(endpointArg.endpointId, clusters.thermostat, 17);
|
||||||
|
return this.entity(nodeArg, endpointArg, 'climate', 'climate', 'Climate', this.thermostatMode(mode), clusters.thermostat, 0, {
|
||||||
|
currentTemperature: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 0)),
|
||||||
|
occupiedCoolingSetpoint: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 17)),
|
||||||
|
occupiedHeatingSetpoint: this.temperatureValue(this.attributeValue(endpointArg.attributes, clusters.thermostat, 18)),
|
||||||
|
systemMode: mode,
|
||||||
|
writable: this.hasAttribute(endpointArg, clusters.thermostat, 17) || this.hasAttribute(endpointArg, clusters.thermostat, 18),
|
||||||
|
writableAttributePath: this.hasAttribute(endpointArg, clusters.thermostat, 18) ? heatingPath : coolingPath,
|
||||||
|
}, this.hasAttribute(endpointArg, clusters.thermostat, 17) || this.hasAttribute(endpointArg, clusters.thermostat, 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static binarySensorEntities(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
if (this.hasAttribute(endpointArg, clusters.occupancySensing, 0)) {
|
||||||
|
const value = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.occupancySensing, 0));
|
||||||
|
entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', 'occupancy', 'Occupancy', value === undefined ? undefined : (value & 1) === 1, clusters.occupancySensing, 0, { deviceClass: 'occupancy' }));
|
||||||
|
}
|
||||||
|
if (this.hasAttribute(endpointArg, clusters.booleanState, 0)) {
|
||||||
|
const value = this.attributeValue(endpointArg.attributes, clusters.booleanState, 0);
|
||||||
|
const sensorType = this.booleanSensorType(endpointArg);
|
||||||
|
entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', sensorType.key, sensorType.name, sensorType.inverted ? !Boolean(value) : Boolean(value), clusters.booleanState, 0, { deviceClass: sensorType.deviceClass }));
|
||||||
|
}
|
||||||
|
if (this.hasAttribute(endpointArg, clusters.doorLock, 3)) {
|
||||||
|
const doorState = this.numberValue(this.attributeValue(endpointArg.attributes, clusters.doorLock, 3));
|
||||||
|
const isOpen = doorState === undefined ? undefined : [0, 2, 3, 5].includes(doorState);
|
||||||
|
entities.push(this.entity(nodeArg, endpointArg, 'binary_sensor', 'door', 'Door', isOpen, clusters.doorLock, 3, { deviceClass: 'door' }));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorEntities(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): IIntegrationEntity[] {
|
||||||
|
const definitions: Array<{ clusterId: number; attributeId: number; key: string; name: string; unit?: string; deviceClass?: string; transform?: (valueArg: unknown) => unknown }> = [
|
||||||
|
{ clusterId: clusters.temperatureMeasurement, attributeId: 0, key: 'temperature', name: 'Temperature', unit: '°C', deviceClass: 'temperature', transform: (valueArg) => this.temperatureValue(valueArg) },
|
||||||
|
{ clusterId: clusters.relativeHumidityMeasurement, attributeId: 0, key: 'humidity', name: 'Humidity', unit: '%', deviceClass: 'humidity', transform: (valueArg) => this.scaledNumber(valueArg, 100) },
|
||||||
|
{ clusterId: clusters.illuminanceMeasurement, attributeId: 0, key: 'illuminance', name: 'Illuminance', unit: 'lx', deviceClass: 'illuminance', transform: (valueArg) => this.illuminanceValue(valueArg) },
|
||||||
|
{ clusterId: clusters.pressureMeasurement, attributeId: 0, key: 'pressure', name: 'Pressure', deviceClass: 'pressure' },
|
||||||
|
{ clusterId: clusters.flowMeasurement, attributeId: 0, key: 'flow', name: 'Flow' },
|
||||||
|
{ clusterId: clusters.powerSource, attributeId: 12, key: 'battery', name: 'Battery', unit: '%', deviceClass: 'battery', transform: (valueArg) => this.scaledNumber(valueArg, 2) },
|
||||||
|
{ clusterId: clusters.airQuality, attributeId: 0, key: 'air_quality', name: 'Air quality', transform: (valueArg) => this.airQualityValue(valueArg) },
|
||||||
|
];
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
for (const definition of definitions) {
|
||||||
|
if (!this.hasAttribute(endpointArg, definition.clusterId, definition.attributeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawValue = this.attributeValue(endpointArg.attributes, definition.clusterId, definition.attributeId);
|
||||||
|
entities.push(this.entity(nodeArg, endpointArg, 'sensor', definition.key, definition.name, definition.transform ? definition.transform(rawValue) : rawValue, definition.clusterId, definition.attributeId, {
|
||||||
|
unit: definition.unit,
|
||||||
|
deviceClass: definition.deviceClass,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entity(nodeArg: IMatterNode, endpointArg: IMatterEndpoint, platformArg: TMatterEntityPlatform, keyArg: string, nameArg: string, stateArg: unknown, clusterIdArg: number, attributeIdArg: number, attributesArg: Record<string, unknown> = {}, writableArg = false): IIntegrationEntity {
|
||||||
|
const deviceName = this.endpointName(nodeArg, endpointArg);
|
||||||
|
const featureId = this.slug(`${keyArg}_${clusterIdArg}_${attributeIdArg}`);
|
||||||
|
return {
|
||||||
|
id: `${platformArg}.${this.slug(`${deviceName} ${nameArg}`)}`,
|
||||||
|
uniqueId: `matter_${this.slug(`${String(nodeArg.node_id)}_${endpointArg.endpointId}_${keyArg}_${clusterIdArg}_${attributeIdArg}`)}`,
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
deviceId: this.endpointDeviceId(nodeArg, endpointArg.endpointId),
|
||||||
|
platform: platformArg as TEntityPlatform,
|
||||||
|
name: nameArg,
|
||||||
|
state: stateArg ?? 'unknown',
|
||||||
|
attributes: {
|
||||||
|
...attributesArg,
|
||||||
|
nodeId: nodeArg.node_id,
|
||||||
|
endpointId: endpointArg.endpointId,
|
||||||
|
clusterId: clusterIdArg,
|
||||||
|
attributeId: attributeIdArg,
|
||||||
|
attributePath: this.attributePath(endpointArg.endpointId, clusterIdArg, attributeIdArg),
|
||||||
|
clusterName: this.clusterName(clusterIdArg),
|
||||||
|
attributeName: this.attributeName(clusterIdArg, attributeIdArg),
|
||||||
|
featureId,
|
||||||
|
readable: true,
|
||||||
|
writable: writableArg,
|
||||||
|
},
|
||||||
|
available: nodeArg.available !== false && this.reachable(endpointArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceForEndpoint(nodeArg: IMatterNode, endpointArg: IMatterEndpoint, entitiesArg: IIntegrationEntity[], updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
|
||||||
|
...entitiesArg.map((entityArg) => this.featureForEntity(entityArg)),
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'availability', value: nodeArg.available !== false && this.reachable(endpointArg) ? 'online' : 'offline', updatedAt: updatedAtArg },
|
||||||
|
...entitiesArg.map((entityArg) => ({ featureId: this.stringValue(entityArg.attributes?.featureId) || this.slug(entityArg.uniqueId), value: this.safeStateValue(entityArg.state), updatedAt: updatedAtArg })),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: this.endpointDeviceId(nodeArg, endpointArg.endpointId),
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
name: this.endpointName(nodeArg, endpointArg),
|
||||||
|
protocol: 'matter',
|
||||||
|
manufacturer: endpointArg.deviceInfo.vendorName,
|
||||||
|
model: endpointArg.deviceInfo.productLabel || endpointArg.deviceInfo.productName || endpointArg.deviceTypes[0]?.name,
|
||||||
|
online: nodeArg.available !== false && this.reachable(endpointArg),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
nodeId: this.safeStateValue(nodeArg.node_id),
|
||||||
|
endpointId: endpointArg.endpointId,
|
||||||
|
deviceTypes: endpointArg.deviceTypes.map((typeArg) => ({ id: typeArg.id, name: typeArg.name, revision: typeArg.revision })),
|
||||||
|
vendorId: endpointArg.deviceInfo.vendorId,
|
||||||
|
productId: endpointArg.deviceInfo.productId,
|
||||||
|
serialNumber: endpointArg.deviceInfo.serialNumber,
|
||||||
|
hardwareVersion: endpointArg.deviceInfo.hardwareVersionString,
|
||||||
|
softwareVersion: endpointArg.deviceInfo.softwareVersionString,
|
||||||
|
matterVersion: nodeArg.matter_version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fallbackDeviceForNode(nodeArg: IMatterNode, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const endpoint = this.nodeEndpoints(nodeArg)[0];
|
||||||
|
return {
|
||||||
|
id: `matter.node.${this.slug(String(nodeArg.node_id))}`,
|
||||||
|
integrationDomain: 'matter',
|
||||||
|
name: endpoint ? this.endpointName(nodeArg, endpoint) : `Matter node ${String(nodeArg.node_id)}`,
|
||||||
|
protocol: 'matter',
|
||||||
|
manufacturer: endpoint?.deviceInfo.vendorName,
|
||||||
|
model: endpoint?.deviceInfo.productName,
|
||||||
|
online: nodeArg.available !== false,
|
||||||
|
features: [{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false }],
|
||||||
|
state: [{ featureId: 'availability', value: nodeArg.available !== false ? 'online' : 'offline', updatedAt: updatedAtArg }],
|
||||||
|
metadata: { nodeId: this.safeStateValue(nodeArg.node_id), matterVersion: nodeArg.matter_version },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static featureForEntity(entityArg: IIntegrationEntity): plugins.shxInterfaces.data.IDeviceFeature {
|
||||||
|
return {
|
||||||
|
id: this.stringValue(entityArg.attributes?.featureId) || this.slug(entityArg.uniqueId),
|
||||||
|
capability: this.capabilityForPlatform(String(entityArg.platform)),
|
||||||
|
name: entityArg.name,
|
||||||
|
readable: entityArg.attributes?.readable !== false,
|
||||||
|
writable: entityArg.attributes?.writable === true,
|
||||||
|
unit: this.stringValue(entityArg.attributes?.unit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static capabilityForPlatform(platformArg: string): plugins.shxInterfaces.data.TDeviceCapability {
|
||||||
|
if (platformArg === 'light') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
if (platformArg === 'cover') {
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
if (platformArg === 'climate') {
|
||||||
|
return 'climate';
|
||||||
|
}
|
||||||
|
if (platformArg === 'fan') {
|
||||||
|
return 'fan';
|
||||||
|
}
|
||||||
|
return platformArg === 'lock' || platformArg === 'switch' ? 'switch' : 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetEntity(snapshotArg: IMatterSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||||
|
}
|
||||||
|
if (requestArg.target.deviceId) {
|
||||||
|
return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.attributes?.writable === true)
|
||||||
|
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId);
|
||||||
|
}
|
||||||
|
return entities.find((entityArg) => entityArg.attributes?.writable === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static setValueCommand(entityArg: IIntegrationEntity, dataArg: Record<string, unknown>): IMatterServerCommand<IMatterServiceCommandArgs> | undefined {
|
||||||
|
const platform = String(entityArg.platform);
|
||||||
|
const value = dataArg.value ?? dataArg.brightness ?? dataArg.temperature;
|
||||||
|
if ((platform === 'light' || platform === 'switch') && typeof value === 'boolean') {
|
||||||
|
return this.deviceCommand(entityArg, clusters.onOff, value ? 'On' : 'Off');
|
||||||
|
}
|
||||||
|
if (platform === 'light') {
|
||||||
|
const brightness = this.numberValue(dataArg.brightness ?? dataArg.value);
|
||||||
|
if (brightness !== undefined) {
|
||||||
|
return this.deviceCommand(entityArg, clusters.levelControl, 'MoveToLevelWithOnOff', {
|
||||||
|
level: this.clamp(Math.round(this.renormalize(brightness, [0, 255], [1, 254])), 1, 254),
|
||||||
|
transitionTime: this.numberValue(dataArg.transition) ? Math.round((this.numberValue(dataArg.transition) || 0) * 10) : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'lock' && typeof value === 'boolean') {
|
||||||
|
return this.lockCommand(entityArg, value ? 'LockDoor' : 'UnlockDoor', dataArg.code);
|
||||||
|
}
|
||||||
|
const nodeId = this.nodeIdFromEntity(entityArg);
|
||||||
|
const attributePath = this.stringValue(dataArg.attribute_path)
|
||||||
|
|| this.stringValue(dataArg.attributePath)
|
||||||
|
|| this.stringValue(entityArg.attributes?.writableAttributePath)
|
||||||
|
|| this.stringValue(entityArg.attributes?.attributePath);
|
||||||
|
if (nodeId === undefined || !attributePath || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const writeValue = platform === 'climate' && typeof value === 'number' ? Math.round(value * 100) : value;
|
||||||
|
return {
|
||||||
|
command: 'write_attribute',
|
||||||
|
requireSchema: 4,
|
||||||
|
args: {
|
||||||
|
node_id: nodeId,
|
||||||
|
attribute_path: attributePath,
|
||||||
|
value: writeValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceCommand(entityArg: IIntegrationEntity, clusterIdArg: number, commandNameArg: string, payloadArg: Record<string, unknown> = {}): IMatterServerCommand<IMatterServiceCommandArgs> | undefined {
|
||||||
|
const nodeId = this.nodeIdFromEntity(entityArg);
|
||||||
|
const endpointId = this.numberValue(entityArg.attributes?.endpointId);
|
||||||
|
if (nodeId === undefined || endpointId === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: 'device_command',
|
||||||
|
args: {
|
||||||
|
node_id: nodeId,
|
||||||
|
endpoint_id: endpointId,
|
||||||
|
cluster_id: clusterIdArg,
|
||||||
|
command_name: commandNameArg,
|
||||||
|
payload: payloadArg,
|
||||||
|
response_type: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lockCommand(entityArg: IIntegrationEntity, commandNameArg: string, codeArg: unknown): IMatterServerCommand<IMatterServiceCommandArgs> | undefined {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (typeof codeArg === 'string' && codeArg) {
|
||||||
|
payload.PINCode = codeArg;
|
||||||
|
}
|
||||||
|
const command = this.deviceCommand(entityArg, clusters.doorLock, commandNameArg, payload);
|
||||||
|
if (command?.args) {
|
||||||
|
command.args.timed_request_timeout_ms = lockTimedRequestTimeoutMs;
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceInfo(rootAttributesArg: IMatterAttribute[], attributesArg: IMatterAttribute[]): IMatterDeviceInfo {
|
||||||
|
const endpointInfoCluster = this.hasAttributeList(attributesArg, clusters.bridgedDeviceBasicInformation) ? clusters.bridgedDeviceBasicInformation : clusters.basicInformation;
|
||||||
|
return {
|
||||||
|
vendorName: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 1)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 1)),
|
||||||
|
vendorId: this.numberValue(this.attributeValue(attributesArg, endpointInfoCluster, 2)) || this.numberValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 2)),
|
||||||
|
productName: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 3)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 3)),
|
||||||
|
productId: this.numberValue(this.attributeValue(attributesArg, endpointInfoCluster, 4)) || this.numberValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 4)),
|
||||||
|
nodeLabel: this.cleanName(this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 5))) || this.cleanName(this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 5))),
|
||||||
|
hardwareVersionString: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 8)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 8)),
|
||||||
|
softwareVersionString: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 10)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 10)),
|
||||||
|
productLabel: this.cleanName(this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 14))) || this.cleanName(this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 14))),
|
||||||
|
serialNumber: this.stringValue(this.attributeValue(attributesArg, endpointInfoCluster, 15)) || this.stringValue(this.attributeValue(rootAttributesArg, clusters.basicInformation, 15)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceTypesFromValue(valueArg: unknown): IMatterDeviceType[] {
|
||||||
|
if (!Array.isArray(valueArg)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return valueArg.map((itemArg) => {
|
||||||
|
if (typeof itemArg === 'number') {
|
||||||
|
return { id: itemArg, name: deviceTypes[itemArg] || `Device type ${itemArg}`, raw: itemArg };
|
||||||
|
}
|
||||||
|
const item = this.asRecord(itemArg);
|
||||||
|
const id = this.numberValue(item.deviceType ?? item.device_type ?? item.deviceTypeId ?? item.id) || 0;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: deviceTypes[id] || `Device type ${id}`,
|
||||||
|
revision: this.numberValue(item.revision),
|
||||||
|
raw: itemArg,
|
||||||
|
};
|
||||||
|
}).filter((typeArg) => typeArg.id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static booleanSensorType(endpointArg: IMatterEndpoint): { key: string; name: string; deviceClass: string; inverted?: boolean } {
|
||||||
|
if (this.hasDeviceType(endpointArg, [0x0015])) {
|
||||||
|
return { key: 'contact', name: 'Contact', deviceClass: 'door', inverted: true };
|
||||||
|
}
|
||||||
|
if (this.hasDeviceType(endpointArg, [0x0043])) {
|
||||||
|
return { key: 'water_leak', name: 'Water leak', deviceClass: 'moisture' };
|
||||||
|
}
|
||||||
|
if (this.hasDeviceType(endpointArg, [0x0041])) {
|
||||||
|
return { key: 'freeze', name: 'Freeze', deviceClass: 'cold' };
|
||||||
|
}
|
||||||
|
if (this.hasDeviceType(endpointArg, [0x0044])) {
|
||||||
|
return { key: 'rain', name: 'Rain', deviceClass: 'moisture' };
|
||||||
|
}
|
||||||
|
return { key: 'state', name: 'State', deviceClass: 'problem' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isLightEndpoint(endpointArg: IMatterEndpoint): boolean {
|
||||||
|
if (endpointArg.deviceTypes.some((typeArg) => lightDeviceTypes.has(typeArg.id))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (endpointArg.deviceTypes.some((typeArg) => plugDeviceTypes.has(typeArg.id))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const name = `${endpointArg.deviceInfo.nodeLabel || ''} ${endpointArg.deviceInfo.productName || ''} ${endpointArg.deviceTypes.map((typeArg) => typeArg.name).join(' ')}`.toLowerCase();
|
||||||
|
return name.includes('light') && (this.hasCluster(endpointArg, clusters.levelControl) || this.hasCluster(endpointArg, clusters.colorControl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isUsefulEndpoint(endpointArg: IMatterEndpoint): boolean {
|
||||||
|
if (endpointArg.endpointId === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return endpointArg.deviceTypes.some((typeArg) => !rootOnlyDeviceTypes.has(typeArg.id))
|
||||||
|
|| endpointArg.clusters.some((clusterArg) => !infrastructureClusters.has(clusterArg.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static reachable(endpointArg: IMatterEndpoint): boolean {
|
||||||
|
const value = this.attributeValue(endpointArg.attributes, clusters.bridgedDeviceBasicInformation, 17);
|
||||||
|
return typeof value === 'boolean' ? value : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static endpointName(nodeArg: IMatterNode, endpointArg: IMatterEndpoint): string {
|
||||||
|
return endpointArg.deviceInfo.nodeLabel
|
||||||
|
|| endpointArg.deviceInfo.productLabel
|
||||||
|
|| endpointArg.deviceInfo.productName
|
||||||
|
|| endpointArg.deviceTypes.find((typeArg) => !rootOnlyDeviceTypes.has(typeArg.id))?.name
|
||||||
|
|| `Matter node ${String(nodeArg.node_id)}${endpointArg.endpointId ? ` endpoint ${endpointArg.endpointId}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static endpointDeviceId(nodeArg: IMatterNode, endpointIdArg: number): string {
|
||||||
|
return `matter.node.${this.slug(String(nodeArg.node_id))}.endpoint.${endpointIdArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static controllerDeviceId(snapshotArg: IMatterSnapshot): string {
|
||||||
|
return `matter.server.${this.slug(String(snapshotArg.serverInfo?.compressed_fabric_id || snapshotArg.url || 'local'))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nodeIdFromEntity(entityArg: IIntegrationEntity): TMatterNodeId | undefined {
|
||||||
|
const value = entityArg.attributes?.nodeId;
|
||||||
|
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseAttributePath(pathArg: string): { endpointId: number; clusterId: number; attributeId: number } | undefined {
|
||||||
|
const parts = pathArg.split('/').map((partArg) => Number(partArg));
|
||||||
|
if (parts.length !== 3 || parts.some((partArg) => !Number.isFinite(partArg))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { endpointId: parts[0], clusterId: parts[1], attributeId: parts[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static attributeValue(attributesArg: IMatterAttribute[], clusterIdArg: number, attributeIdArg: number): unknown {
|
||||||
|
return attributesArg.find((attributeArg) => attributeArg.clusterId === clusterIdArg && attributeArg.attributeId === attributeIdArg)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hasAttribute(endpointArg: IMatterEndpoint, clusterIdArg: number, attributeIdArg: number): boolean {
|
||||||
|
return endpointArg.attributes.some((attributeArg) => attributeArg.clusterId === clusterIdArg && attributeArg.attributeId === attributeIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hasAttributeList(attributesArg: IMatterAttribute[], clusterIdArg: number): boolean {
|
||||||
|
return attributesArg.some((attributeArg) => attributeArg.clusterId === clusterIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hasCluster(endpointArg: IMatterEndpoint, clusterIdArg: number): boolean {
|
||||||
|
return endpointArg.clusters.some((clusterArg) => clusterArg.id === clusterIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hasDeviceType(endpointArg: IMatterEndpoint, typeIdsArg: number[]): boolean {
|
||||||
|
return endpointArg.deviceTypes.some((typeArg) => typeIdsArg.includes(typeArg.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberList(valueArg: unknown): number[] {
|
||||||
|
if (!Array.isArray(valueArg)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return valueArg.map((itemArg) => this.numberValue(itemArg)).filter((itemArg): itemArg is number => itemArg !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static attributeWritable(clusterIdArg: number, attributeIdArg: number): boolean {
|
||||||
|
return clusterIdArg === clusters.thermostat && [17, 18].includes(attributeIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static attributePath(endpointIdArg: number, clusterIdArg: number, attributeIdArg: number): string {
|
||||||
|
return `${endpointIdArg}/${clusterIdArg}/${attributeIdArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clusterName(clusterIdArg: number): string {
|
||||||
|
return clusterNames[clusterIdArg] || `Cluster ${clusterIdArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static attributeName(clusterIdArg: number, attributeIdArg: number): string {
|
||||||
|
return attributeNames[clusterIdArg]?.[attributeIdArg]
|
||||||
|
|| (attributeIdArg === globalAttributes.acceptedCommandList ? 'AcceptedCommandList' : undefined)
|
||||||
|
|| (attributeIdArg === globalAttributes.attributeList ? 'AttributeList' : undefined)
|
||||||
|
|| (attributeIdArg === globalAttributes.featureMap ? 'FeatureMap' : undefined)
|
||||||
|
|| (attributeIdArg === globalAttributes.clusterRevision ? 'ClusterRevision' : undefined)
|
||||||
|
|| `Attribute ${attributeIdArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static thermostatMode(valueArg?: number): string {
|
||||||
|
return ({ 0: 'off', 1: 'auto', 3: 'cool', 4: 'heat', 5: 'emergency_heat', 7: 'fan_only', 8: 'dry', 9: 'sleep' } as Record<number, string>)[valueArg ?? -1] || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static airQualityValue(valueArg: unknown): unknown {
|
||||||
|
const value = this.numberValue(valueArg);
|
||||||
|
return ({ 0: 'unknown', 1: 'good', 2: 'fair', 3: 'moderate', 4: 'poor', 5: 'very_poor', 6: 'extremely_poor' } as Record<number, string>)[value ?? -1] || valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static temperatureValue(valueArg: unknown): number | undefined {
|
||||||
|
return this.scaledNumber(valueArg, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static illuminanceValue(valueArg: unknown): number | undefined {
|
||||||
|
const value = this.numberValue(valueArg);
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.round(Math.pow(10, (value - 1) / 10000) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static scaledNumber(valueArg: unknown, divisorArg: number): number | undefined {
|
||||||
|
const value = this.numberValue(valueArg);
|
||||||
|
return value === undefined ? undefined : Math.round((value / divisorArg) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static renormalize(valueArg: number, fromArg: [number, number], toArg: [number, number]): number {
|
||||||
|
const fromSpan = fromArg[1] - fromArg[0] || 1;
|
||||||
|
return ((valueArg - fromArg[0]) / fromSpan) * (toArg[1] - toArg[0]) + toArg[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static urlFromConfig(configArg: IMatterConfig): string | undefined {
|
||||||
|
if (configArg.url) {
|
||||||
|
return configArg.url;
|
||||||
|
}
|
||||||
|
if (configArg.host) {
|
||||||
|
return `ws://${configArg.host}:${configArg.port || 5580}/ws`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cleanName(valueArg?: string): string | undefined {
|
||||||
|
const cleaned = valueArg?.replace(/\x00/g, '').trim();
|
||||||
|
return cleaned || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'bigint' && valueArg <= BigInt(Number.MAX_SAFE_INTEGER) && valueArg >= BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static safeStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (typeof valueArg === 'bigint') {
|
||||||
|
return valueArg.toString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return JSON.stringify(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? null : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static asRecord(valueArg: unknown): Record<string, unknown> {
|
||||||
|
return this.isRecord(valueArg) ? valueArg : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'matter';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,179 @@
|
|||||||
export interface IHomeAssistantMatterConfig {
|
import type { TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for matter.
|
|
||||||
[key: string]: unknown;
|
export type TMatterNodeId = number | bigint | string;
|
||||||
|
|
||||||
|
export type TMatterEntityPlatform = TEntityPlatform | 'lock';
|
||||||
|
|
||||||
|
export interface IMatterConfig {
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
connect?: boolean;
|
||||||
|
commandTimeoutMs?: number;
|
||||||
|
serverInfo?: IMatterServerInfo;
|
||||||
|
nodes?: IMatterNode[];
|
||||||
|
events?: IMatterServerEvent[];
|
||||||
|
snapshot?: IMatterSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IMatterServerInfo {
|
||||||
|
fabric_id: TMatterNodeId;
|
||||||
|
compressed_fabric_id: TMatterNodeId;
|
||||||
|
fabric_index?: number;
|
||||||
|
schema_version: number;
|
||||||
|
min_supported_schema_version: number;
|
||||||
|
sdk_version: string;
|
||||||
|
wifi_credentials_set: boolean;
|
||||||
|
thread_credentials_set: boolean;
|
||||||
|
bluetooth_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterNode {
|
||||||
|
node_id: TMatterNodeId;
|
||||||
|
date_commissioned?: string;
|
||||||
|
last_interview?: string;
|
||||||
|
interview_version?: number;
|
||||||
|
available?: boolean;
|
||||||
|
is_bridge?: boolean;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
attribute_subscriptions?: readonly unknown[];
|
||||||
|
matter_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterEndpoint {
|
||||||
|
nodeId: TMatterNodeId;
|
||||||
|
endpointId: number;
|
||||||
|
deviceTypes: IMatterDeviceType[];
|
||||||
|
clusters: IMatterCluster[];
|
||||||
|
attributes: IMatterAttribute[];
|
||||||
|
deviceInfo: IMatterDeviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterDeviceType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
revision?: number;
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterCluster {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
attributes: IMatterAttribute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterAttribute {
|
||||||
|
path: string;
|
||||||
|
endpointId: number;
|
||||||
|
clusterId: number;
|
||||||
|
attributeId: number;
|
||||||
|
clusterName: string;
|
||||||
|
attributeName: string;
|
||||||
|
value: unknown;
|
||||||
|
readable: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterDeviceInfo {
|
||||||
|
nodeLabel?: string;
|
||||||
|
productLabel?: string;
|
||||||
|
productName?: string;
|
||||||
|
vendorName?: string;
|
||||||
|
vendorId?: number;
|
||||||
|
productId?: number;
|
||||||
|
serialNumber?: string;
|
||||||
|
hardwareVersionString?: string;
|
||||||
|
softwareVersionString?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterSnapshot {
|
||||||
|
serverInfo?: IMatterServerInfo;
|
||||||
|
nodes: IMatterNode[];
|
||||||
|
events: IMatterServerEvent[];
|
||||||
|
connected: boolean;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TMatterServerEventName =
|
||||||
|
| 'node_added'
|
||||||
|
| 'node_updated'
|
||||||
|
| 'node_removed'
|
||||||
|
| 'node_event'
|
||||||
|
| 'attribute_updated'
|
||||||
|
| 'server_shutdown'
|
||||||
|
| 'endpoint_added'
|
||||||
|
| 'endpoint_removed'
|
||||||
|
| 'server_info_updated';
|
||||||
|
|
||||||
|
export interface IMatterServerEvent<TData = unknown> {
|
||||||
|
event: TMatterServerEventName | string;
|
||||||
|
data: TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterNodeEvent {
|
||||||
|
node_id: TMatterNodeId;
|
||||||
|
endpoint_id: number;
|
||||||
|
cluster_id: number;
|
||||||
|
event_id: number;
|
||||||
|
event_number: TMatterNodeId;
|
||||||
|
priority: number;
|
||||||
|
timestamp: TMatterNodeId;
|
||||||
|
timestamp_type: number;
|
||||||
|
data: unknown | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterCommandFrame {
|
||||||
|
message_id: string;
|
||||||
|
command: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterResultFrame<TResult = unknown> {
|
||||||
|
message_id: string;
|
||||||
|
result: TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterErrorFrame {
|
||||||
|
message_id: string;
|
||||||
|
error_code: number;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterServerCommand<TArgs extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
|
command: string;
|
||||||
|
args?: TArgs;
|
||||||
|
requireSchema?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterServiceCommandArgs extends Record<string, unknown> {
|
||||||
|
node_id?: TMatterNodeId;
|
||||||
|
endpoint_id?: number;
|
||||||
|
cluster_id?: number;
|
||||||
|
command_name?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
response_type?: unknown;
|
||||||
|
timed_request_timeout_ms?: number;
|
||||||
|
interaction_timeout_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
addresses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatterManualEntry {
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TMatterDiscoveryRecord = IMatterMdnsRecord | IMatterManualEntry;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './nanoleaf.classes.client.js';
|
||||||
|
export * from './nanoleaf.classes.configflow.js';
|
||||||
export * from './nanoleaf.classes.integration.js';
|
export * from './nanoleaf.classes.integration.js';
|
||||||
|
export * from './nanoleaf.discovery.js';
|
||||||
|
export * from './nanoleaf.mapper.js';
|
||||||
export * from './nanoleaf.types.js';
|
export * from './nanoleaf.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import type {
|
||||||
|
INanoleafAuthTokenResult,
|
||||||
|
INanoleafConfig,
|
||||||
|
INanoleafControllerInfo,
|
||||||
|
INanoleafEffects,
|
||||||
|
INanoleafEffectsCommand,
|
||||||
|
INanoleafPanelLayout,
|
||||||
|
INanoleafRhythmInfo,
|
||||||
|
INanoleafSnapshot,
|
||||||
|
INanoleafState,
|
||||||
|
INanoleafStateCommand,
|
||||||
|
} from './nanoleaf.types.js';
|
||||||
|
|
||||||
|
const DEFAULT_NANOLEAF_PORT = 16021;
|
||||||
|
|
||||||
|
export class NanoleafClient {
|
||||||
|
constructor(private readonly config: INanoleafConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<INanoleafSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
return this.normalizeSnapshot(this.config.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canRequest()) {
|
||||||
|
const controllerInfo = await this.requestJson<INanoleafControllerInfo>(this.authenticatedPath(''));
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
controllerInfo,
|
||||||
|
state: controllerInfo.state ?? {},
|
||||||
|
effects: controllerInfo.effects ?? {},
|
||||||
|
panelLayout: controllerInfo.panelLayout,
|
||||||
|
rhythm: controllerInfo.rhythm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
controllerInfo: this.config.controllerInfo ?? this.defaultControllerInfo(),
|
||||||
|
state: this.config.state ?? this.config.controllerInfo?.state ?? {},
|
||||||
|
effects: this.config.effects ?? this.config.controllerInfo?.effects ?? {},
|
||||||
|
panelLayout: this.config.panelLayout ?? this.config.controllerInfo?.panelLayout,
|
||||||
|
rhythm: this.config.rhythm ?? this.config.controllerInfo?.rhythm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getControllerInfo(): Promise<INanoleafControllerInfo> {
|
||||||
|
return (await this.getSnapshot()).controllerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getState(): Promise<INanoleafState> {
|
||||||
|
if (this.config.snapshot || this.config.state || this.config.controllerInfo?.state || !this.canRequest()) {
|
||||||
|
return (await this.getSnapshot()).state;
|
||||||
|
}
|
||||||
|
return this.requestJson<INanoleafState>(this.authenticatedPath('/state'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEffects(): Promise<INanoleafEffects> {
|
||||||
|
if (this.config.snapshot || this.config.effects || this.config.controllerInfo?.effects || !this.canRequest()) {
|
||||||
|
return (await this.getSnapshot()).effects;
|
||||||
|
}
|
||||||
|
return this.requestJson<INanoleafEffects>(this.authenticatedPath('/effects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPanelLayout(): Promise<INanoleafPanelLayout | undefined> {
|
||||||
|
return (await this.getSnapshot()).panelLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRhythm(): Promise<INanoleafRhythmInfo | undefined> {
|
||||||
|
return (await this.getSnapshot()).rhythm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async turnOn(): Promise<void> {
|
||||||
|
await this.setState({ on: { value: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async turnOff(transitionArg?: number): Promise<void> {
|
||||||
|
if (transitionArg === undefined) {
|
||||||
|
await this.setState({ on: { value: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.setState({ on: { value: false }, brightness: { value: 0, duration: transitionArg } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setBrightness(percentArg: number, transitionArg?: number): Promise<void> {
|
||||||
|
await this.setState({
|
||||||
|
brightness: {
|
||||||
|
value: this.clamp(Math.round(percentArg), 0, 100),
|
||||||
|
duration: transitionArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setHue(hueArg: number): Promise<void> {
|
||||||
|
await this.setState({ hue: { value: this.clamp(Math.round(hueArg), 0, 360) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSaturation(saturationArg: number): Promise<void> {
|
||||||
|
await this.setState({ sat: { value: this.clamp(Math.round(saturationArg), 0, 100) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setColorTemperature(kelvinArg: number): Promise<void> {
|
||||||
|
await this.setState({ ct: { value: Math.round(kelvinArg) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setState(stateArg: INanoleafStateCommand): Promise<void> {
|
||||||
|
this.applyStatePatch(stateArg);
|
||||||
|
if (this.canRequest()) {
|
||||||
|
await this.requestJson<void>(this.authenticatedPath('/state'), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: stateArg,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assertFixtureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setEffect(effectArg: string): Promise<void> {
|
||||||
|
this.applyEffectsPatch({ select: effectArg });
|
||||||
|
if (this.canRequest()) {
|
||||||
|
await this.requestJson<void>(this.authenticatedPath('/effects'), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { select: effectArg },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assertFixtureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeEffectsCommand(commandArg: INanoleafEffectsCommand): Promise<void> {
|
||||||
|
if (this.canRequest()) {
|
||||||
|
await this.requestJson<void>(this.authenticatedPath('/effects'), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { write: commandArg },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assertFixtureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async identify(): Promise<void> {
|
||||||
|
if (this.canRequest()) {
|
||||||
|
await this.requestJson<void>(this.authenticatedPath('/identify'), { method: 'PUT' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.assertFixtureMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAuthToken(): Promise<INanoleafAuthTokenResult> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Nanoleaf pairing/token generation is not implemented. Put the controller in pairing mode and provide an existing authToken.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: INanoleafSnapshot): INanoleafSnapshot {
|
||||||
|
const controllerInfo = snapshotArg.controllerInfo;
|
||||||
|
const state = snapshotArg.state ?? controllerInfo.state ?? {};
|
||||||
|
const effects = snapshotArg.effects ?? controllerInfo.effects ?? {};
|
||||||
|
const panelLayout = snapshotArg.panelLayout ?? controllerInfo.panelLayout;
|
||||||
|
const rhythm = snapshotArg.rhythm ?? controllerInfo.rhythm;
|
||||||
|
controllerInfo.state = state;
|
||||||
|
controllerInfo.effects = effects;
|
||||||
|
controllerInfo.panelLayout = panelLayout;
|
||||||
|
controllerInfo.rhythm = rhythm;
|
||||||
|
return { controllerInfo, state, effects, panelLayout, rhythm };
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyStatePatch(stateArg: INanoleafStateCommand): void {
|
||||||
|
const state = this.ensureMutableState();
|
||||||
|
if (stateArg.on) {
|
||||||
|
state.on = { ...state.on, ...stateArg.on };
|
||||||
|
}
|
||||||
|
if (stateArg.brightness) {
|
||||||
|
state.brightness = { ...state.brightness, ...stateArg.brightness };
|
||||||
|
}
|
||||||
|
if (stateArg.hue) {
|
||||||
|
state.hue = { ...state.hue, ...stateArg.hue };
|
||||||
|
}
|
||||||
|
if (stateArg.sat) {
|
||||||
|
state.sat = { ...state.sat, ...stateArg.sat };
|
||||||
|
}
|
||||||
|
if (stateArg.ct) {
|
||||||
|
state.ct = { ...state.ct, ...stateArg.ct };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyEffectsPatch(effectsArg: Partial<INanoleafEffects>): void {
|
||||||
|
const effects = this.ensureMutableEffects();
|
||||||
|
Object.assign(effects, effectsArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMutableState(): INanoleafState {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.config.snapshot.state ||= {};
|
||||||
|
this.config.snapshot.controllerInfo.state = this.config.snapshot.state;
|
||||||
|
return this.config.snapshot.state;
|
||||||
|
}
|
||||||
|
this.config.state ||= this.config.controllerInfo?.state ?? {};
|
||||||
|
if (this.config.controllerInfo) {
|
||||||
|
this.config.controllerInfo.state = this.config.state;
|
||||||
|
}
|
||||||
|
return this.config.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMutableEffects(): INanoleafEffects {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.config.snapshot.effects ||= {};
|
||||||
|
this.config.snapshot.controllerInfo.effects = this.config.snapshot.effects;
|
||||||
|
return this.config.snapshot.effects;
|
||||||
|
}
|
||||||
|
this.config.effects ||= this.config.controllerInfo?.effects ?? {};
|
||||||
|
if (this.config.controllerInfo) {
|
||||||
|
this.config.controllerInfo.effects = this.config.effects;
|
||||||
|
}
|
||||||
|
return this.config.effects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertFixtureMode(): void {
|
||||||
|
if (!this.hasFixtureData()) {
|
||||||
|
throw new Error('Nanoleaf host and authToken are required when snapshot/manual config data is not provided.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFixtureData(): boolean {
|
||||||
|
return Boolean(this.config.snapshot || this.config.controllerInfo || this.config.state || this.config.effects || this.config.panelLayout || this.config.rhythm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canRequest(): boolean {
|
||||||
|
return Boolean(this.config.host && this.authToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
private authToken(): string | undefined {
|
||||||
|
return this.config.authToken || this.config.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private authenticatedPath(pathArg: string): string {
|
||||||
|
const token = this.authToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Nanoleaf authToken is required for local HTTP requests.');
|
||||||
|
}
|
||||||
|
return `/api/v1/${encodeURIComponent(token)}${pathArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestJson<TResult>(pathArg: string, optionsArg: { method?: string; body?: unknown } = {}): Promise<TResult> {
|
||||||
|
const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`, {
|
||||||
|
method: optionsArg.method ?? (optionsArg.body === undefined ? 'GET' : 'POST'),
|
||||||
|
headers: optionsArg.body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||||
|
body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nanoleaf request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return undefined as TResult;
|
||||||
|
}
|
||||||
|
return JSON.parse(text) as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl(): string {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Nanoleaf host is required for local HTTP requests.');
|
||||||
|
}
|
||||||
|
const protocol = this.config.protocol ?? 'http';
|
||||||
|
const port = this.config.port ?? DEFAULT_NANOLEAF_PORT;
|
||||||
|
return `${protocol}://${this.config.host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultControllerInfo(): INanoleafControllerInfo {
|
||||||
|
return {
|
||||||
|
name: 'Nanoleaf',
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: 'Nanoleaf Controller',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { INanoleafConfig } from './nanoleaf.types.js';
|
||||||
|
|
||||||
|
const DEFAULT_NANOLEAF_PORT = 16021;
|
||||||
|
|
||||||
|
export class NanoleafConfigFlow implements IConfigFlow<INanoleafConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<INanoleafConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Nanoleaf',
|
||||||
|
description: 'Configure the local Nanoleaf HTTP endpoint with an existing auth token from the controller.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number', required: true },
|
||||||
|
{ name: 'authToken', label: 'Auth token', type: 'password', required: true },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => ({
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Nanoleaf configured',
|
||||||
|
config: {
|
||||||
|
host: String(valuesArg.host || candidateArg.host || ''),
|
||||||
|
port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port || DEFAULT_NANOLEAF_PORT,
|
||||||
|
authToken: String(valuesArg.authToken || ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,227 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
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 HomeAssistantNanoleafIntegration extends DescriptorOnlyIntegration {
|
export class NanoleafIntegration extends BaseIntegration<INanoleafConfig> {
|
||||||
constructor() {
|
public readonly domain = 'nanoleaf';
|
||||||
super({
|
public readonly displayName = 'Nanoleaf';
|
||||||
domain: "nanoleaf",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Nanoleaf",
|
public readonly discoveryDescriptor = createNanoleafDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new NanoleafConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/nanoleaf",
|
upstreamPath: 'homeassistant/components/nanoleaf',
|
||||||
"upstreamDomain": "nanoleaf",
|
upstreamDomain: 'nanoleaf',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
documentation: 'https://www.home-assistant.io/integrations/nanoleaf',
|
||||||
"aionanoleaf2==1.0.2"
|
requirements: ['aionanoleaf2==1.0.2'],
|
||||||
],
|
codeowners: ['@milanmeu', '@joostlek', '@loebi-ch', '@JaspervRijbroek', '@jonathanrobichaud4'],
|
||||||
"dependencies": [],
|
zeroconf: ['_nanoleafms._tcp.local.', '_nanoleafapi._tcp.local.'],
|
||||||
"afterDependencies": [],
|
ssdp: ['Nanoleaf_aurora:light', 'nanoleaf:nl29', 'nanoleaf:nl42', 'nanoleaf:nl52', 'nanoleaf:nl69', 'inanoleaf:nl81'],
|
||||||
"codeowners": [
|
homekitModels: ['NL29', 'NL42', 'NL47', 'NL48', 'NL52', 'NL59', 'NL69', 'NL81'],
|
||||||
"@milanmeu",
|
};
|
||||||
"@joostlek",
|
|
||||||
"@loebi-ch",
|
public async setup(configArg: INanoleafConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"@JaspervRijbroek",
|
void contextArg;
|
||||||
"@jonathanrobichaud4"
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { INanoleafManualEntry, INanoleafMdnsRecord, INanoleafSsdpRecord } from './nanoleaf.types.js';
|
||||||
|
|
||||||
|
const DEFAULT_NANOLEAF_PORT = 16021;
|
||||||
|
const NANOLEAF_MDNS_TYPES = new Set(['_nanoleafapi._tcp.local.', '_nanoleafms._tcp.local.']);
|
||||||
|
const NANOLEAF_SSDP_TYPES = new Set([
|
||||||
|
'nanoleaf_aurora:light',
|
||||||
|
'nanoleaf:nl29',
|
||||||
|
'nanoleaf:nl42',
|
||||||
|
'nanoleaf:nl52',
|
||||||
|
'nanoleaf:nl69',
|
||||||
|
'inanoleaf:nl81',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class NanoleafMdnsMatcher implements IDiscoveryMatcher<INanoleafMdnsRecord> {
|
||||||
|
public id = 'nanoleaf-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Nanoleaf zeroconf records for _nanoleafapi._tcp and _nanoleafms._tcp.';
|
||||||
|
|
||||||
|
public async matches(recordArg: INanoleafMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = recordArg.type?.toLowerCase() || '';
|
||||||
|
const name = recordArg.name?.toLowerCase() || '';
|
||||||
|
const matched = NANOLEAF_MDNS_TYPES.has(type) || name.includes('nanoleaf') && (type.includes('nanoleaf') || type === '_http._tcp.local.');
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'mDNS record is not a Nanoleaf advertisement.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = this.txtValue(recordArg.txt, 'id', 'deviceid', 'nl-deviceid') || recordArg.name;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: deviceId ? 'certain' : 'high',
|
||||||
|
reason: 'mDNS record matches Nanoleaf zeroconf metadata.',
|
||||||
|
normalizedDeviceId: deviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
id: deviceId,
|
||||||
|
host: recordArg.host,
|
||||||
|
port: recordArg.port || DEFAULT_NANOLEAF_PORT,
|
||||||
|
name: recordArg.name,
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: this.txtValue(recordArg.txt, 'model', 'modelid', 'md'),
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt: recordArg.txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txtValue(txtArg: Record<string, string | undefined> | undefined, ...keysArg: string[]): string | undefined {
|
||||||
|
if (!txtArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const wanted = new Set(keysArg.map((keyArg) => keyArg.toLowerCase()));
|
||||||
|
for (const [key, value] of Object.entries(txtArg)) {
|
||||||
|
if (value !== undefined && wanted.has(key.toLowerCase())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NanoleafSsdpMatcher implements IDiscoveryMatcher<INanoleafSsdpRecord> {
|
||||||
|
public id = 'nanoleaf-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Nanoleaf SSDP responses by ST and nl-* headers.';
|
||||||
|
|
||||||
|
public async matches(recordArg: INanoleafSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const st = (recordArg.st || this.header(recordArg.headers, 'st') || '').toLowerCase();
|
||||||
|
const matched = NANOLEAF_SSDP_TYPES.has(st) || st.includes('nanoleaf') || Boolean(this.header(recordArg.headers, 'nl-deviceid'));
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'SSDP response is not a Nanoleaf advertisement.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPort = this.parseHostPort(this.header(recordArg.headers, '_host') || recordArg.location);
|
||||||
|
const deviceId = this.header(recordArg.headers, 'nl-deviceid') || recordArg.usn;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: deviceId ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP response matches Nanoleaf metadata.',
|
||||||
|
normalizedDeviceId: deviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
id: deviceId,
|
||||||
|
host: hostPort.host,
|
||||||
|
port: hostPort.port || DEFAULT_NANOLEAF_PORT,
|
||||||
|
name: this.header(recordArg.headers, 'nl-devicename'),
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: st.startsWith('nanoleaf:') || st.startsWith('inanoleaf:') ? st.split(':')[1]?.toUpperCase() : undefined,
|
||||||
|
metadata: {
|
||||||
|
st: recordArg.st,
|
||||||
|
usn: recordArg.usn,
|
||||||
|
location: recordArg.location,
|
||||||
|
headers: recordArg.headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private header(headersArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined {
|
||||||
|
if (!headersArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const wanted = keyArg.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headersArg)) {
|
||||||
|
if (key.toLowerCase() === wanted) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseHostPort(valueArg: string | undefined): { host?: string; port?: number } {
|
||||||
|
if (!valueArg) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(valueArg.includes('://') ? valueArg : `http://${valueArg}`);
|
||||||
|
return {
|
||||||
|
host: url.hostname,
|
||||||
|
port: url.port ? Number(url.port) : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const [host, port] = valueArg.split(':');
|
||||||
|
return { host, port: port ? Number(port) : undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NanoleafManualMatcher implements IDiscoveryMatcher<INanoleafManualEntry> {
|
||||||
|
public id = 'nanoleaf-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Nanoleaf setup entries by host or Nanoleaf metadata.';
|
||||||
|
|
||||||
|
public async matches(inputArg: INanoleafManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const name = inputArg.name?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(inputArg.host || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf') || inputArg.metadata?.nanoleaf);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: 'Manual entry does not contain Nanoleaf setup hints.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Nanoleaf setup.',
|
||||||
|
normalizedDeviceId: inputArg.id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
id: inputArg.id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || DEFAULT_NANOLEAF_PORT,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: inputArg.model,
|
||||||
|
metadata: inputArg.metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NanoleafCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'nanoleaf-candidate-validator';
|
||||||
|
public description = 'Validate candidate metadata before starting Nanoleaf setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const name = candidateArg.name?.toLowerCase() || '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'nanoleaf' || manufacturer.includes('nanoleaf') || model.startsWith('nl') || model.includes('nanoleaf') || name.includes('nanoleaf');
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Nanoleaf metadata.' : 'Candidate is not Nanoleaf.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNanoleafDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
displayName: 'Nanoleaf',
|
||||||
|
})
|
||||||
|
.addMatcher(new NanoleafMdnsMatcher())
|
||||||
|
.addMatcher(new NanoleafSsdpMatcher())
|
||||||
|
.addMatcher(new NanoleafManualMatcher())
|
||||||
|
.addValidator(new NanoleafCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js';
|
||||||
|
import type {
|
||||||
|
INanoleafEvent,
|
||||||
|
INanoleafPanelInfo,
|
||||||
|
INanoleafSnapshot,
|
||||||
|
INanoleafState,
|
||||||
|
INanoleafValue,
|
||||||
|
} from './nanoleaf.types.js';
|
||||||
|
|
||||||
|
const RESERVED_EFFECTS = new Set(['*Solid*', '*Static*', '*Dynamic*']);
|
||||||
|
|
||||||
|
export class NanoleafMapper {
|
||||||
|
public static toDevices(snapshotArg: INanoleafSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const controllerDeviceId = this.controllerDeviceId(snapshotArg);
|
||||||
|
const controllerName = this.controllerName(snapshotArg);
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
{ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||||
|
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' },
|
||||||
|
{ id: 'hue', capability: 'light', name: 'Hue', readable: true, writable: true },
|
||||||
|
{ id: 'saturation', capability: 'light', name: 'Saturation', readable: true, writable: true, unit: '%' },
|
||||||
|
{ id: 'color_temperature', capability: 'light', name: 'Color temperature', readable: true, writable: true, unit: 'K' },
|
||||||
|
{ id: 'color_mode', capability: 'sensor', name: 'Color mode', readable: true, writable: false },
|
||||||
|
{ id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: 'online', updatedAt },
|
||||||
|
{ featureId: 'on', value: this.stateValue(snapshotArg.state.on) ?? null, updatedAt },
|
||||||
|
{ featureId: 'brightness', value: this.stateValue(snapshotArg.state.brightness) ?? null, updatedAt },
|
||||||
|
{ featureId: 'hue', value: this.stateValue(snapshotArg.state.hue) ?? null, updatedAt },
|
||||||
|
{ featureId: 'saturation', value: this.stateValue(snapshotArg.state.sat) ?? null, updatedAt },
|
||||||
|
{ featureId: 'color_temperature', value: this.stateValue(snapshotArg.state.ct) ?? null, updatedAt },
|
||||||
|
{ featureId: 'color_mode', value: this.colorMode(snapshotArg.state) ?? null, updatedAt },
|
||||||
|
{ featureId: 'effect', value: this.currentEffect(snapshotArg) ?? null, updatedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (snapshotArg.controllerInfo.firmwareVersion) {
|
||||||
|
features.push({ id: 'firmware', capability: 'sensor', name: 'Firmware', readable: true, writable: false });
|
||||||
|
state.push({ featureId: 'firmware', value: snapshotArg.controllerInfo.firmwareVersion, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelCount = this.panelCount(snapshotArg);
|
||||||
|
if (panelCount !== undefined) {
|
||||||
|
features.push({ id: 'panel_count', capability: 'sensor', name: 'Panel count', readable: true, writable: false });
|
||||||
|
state.push({ featureId: 'panel_count', value: panelCount, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rhythmConnected = this.valueLike(snapshotArg.rhythm?.rhythmConnected);
|
||||||
|
if (rhythmConnected !== undefined) {
|
||||||
|
features.push({ id: 'rhythm_connected', capability: 'sensor', name: 'Rhythm connected', readable: true, writable: false });
|
||||||
|
state.push({ featureId: 'rhythm_connected', value: rhythmConnected, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: controllerDeviceId,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
name: controllerName,
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: snapshotArg.controllerInfo.manufacturer || 'Nanoleaf',
|
||||||
|
model: snapshotArg.controllerInfo.model || 'Nanoleaf Controller',
|
||||||
|
online: true,
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
serialNumber: snapshotArg.controllerInfo.serialNo,
|
||||||
|
hardwareVersion: snapshotArg.controllerInfo.hardwareVersion,
|
||||||
|
firmwareVersion: snapshotArg.controllerInfo.firmwareVersion,
|
||||||
|
effectsList: this.availableEffects(snapshotArg),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const panel of this.panels(snapshotArg)) {
|
||||||
|
devices.push(this.panelDevice(snapshotArg, panel, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: INanoleafSnapshot): IIntegrationEntity[] {
|
||||||
|
const deviceId = this.controllerDeviceId(snapshotArg);
|
||||||
|
const deviceSlug = this.slug(this.controllerName(snapshotArg));
|
||||||
|
const entities: IIntegrationEntity[] = [
|
||||||
|
{
|
||||||
|
id: `light.${deviceSlug}`,
|
||||||
|
uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_light`,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
deviceId,
|
||||||
|
platform: 'light',
|
||||||
|
name: this.controllerName(snapshotArg),
|
||||||
|
state: this.stateValue(snapshotArg.state.on) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
brightness: this.stateValue(snapshotArg.state.brightness),
|
||||||
|
hue: this.stateValue(snapshotArg.state.hue),
|
||||||
|
saturation: this.stateValue(snapshotArg.state.sat),
|
||||||
|
color_temp_kelvin: this.stateValue(snapshotArg.state.ct),
|
||||||
|
color_mode: this.colorMode(snapshotArg.state),
|
||||||
|
effect: this.currentEffect(snapshotArg),
|
||||||
|
effect_list: this.availableEffects(snapshotArg),
|
||||||
|
},
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `button.${deviceSlug}_identify`,
|
||||||
|
uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_identify`,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
deviceId,
|
||||||
|
platform: 'button',
|
||||||
|
name: `${this.controllerName(snapshotArg)} identify`,
|
||||||
|
state: null,
|
||||||
|
attributes: { deviceClass: 'identify' },
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
entities.push({
|
||||||
|
id: `select.${deviceSlug}_effect`,
|
||||||
|
uniqueId: `nanoleaf_${this.slug(snapshotArg.controllerInfo.serialNo || deviceId)}_effect`,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
deviceId,
|
||||||
|
platform: 'select',
|
||||||
|
name: `${this.controllerName(snapshotArg)} effect`,
|
||||||
|
state: this.currentEffect(snapshotArg) ?? null,
|
||||||
|
attributes: { options: this.availableEffects(snapshotArg) },
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_color_mode`, `sensor.${deviceSlug}_color_mode`, `${this.controllerName(snapshotArg)} color mode`, this.colorMode(snapshotArg.state));
|
||||||
|
this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_firmware`, `sensor.${deviceSlug}_firmware`, `${this.controllerName(snapshotArg)} firmware`, snapshotArg.controllerInfo.firmwareVersion);
|
||||||
|
this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_panel_count`, `sensor.${deviceSlug}_panel_count`, `${this.controllerName(snapshotArg)} panel count`, this.panelCount(snapshotArg));
|
||||||
|
this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_rhythm_connected`, `sensor.${deviceSlug}_rhythm_connected`, `${this.controllerName(snapshotArg)} rhythm connected`, this.valueLike(snapshotArg.rhythm?.rhythmConnected));
|
||||||
|
this.pushSensorEntity(entities, deviceId, `nanoleaf_${this.slug(deviceId)}_rhythm_active`, `sensor.${deviceSlug}_rhythm_active`, `${this.controllerName(snapshotArg)} rhythm active`, this.valueLike(snapshotArg.rhythm?.rhythmActive));
|
||||||
|
|
||||||
|
for (const panel of this.panels(snapshotArg)) {
|
||||||
|
const panelDeviceId = this.panelDeviceId(snapshotArg, panel);
|
||||||
|
entities.push({
|
||||||
|
id: `sensor.${deviceSlug}_panel_${panel.panelId}`,
|
||||||
|
uniqueId: `nanoleaf_${this.slug(deviceId)}_panel_${panel.panelId}`,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
deviceId: panelDeviceId,
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${this.controllerName(snapshotArg)} panel ${panel.panelId}`,
|
||||||
|
state: panel.panelId,
|
||||||
|
attributes: {
|
||||||
|
x: panel.x,
|
||||||
|
y: panel.y,
|
||||||
|
orientation: panel.o,
|
||||||
|
shapeType: panel.shapeType,
|
||||||
|
},
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: INanoleafEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: 'state_changed',
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp ?? Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static panelDevice(snapshotArg: INanoleafSnapshot, panelArg: INanoleafPanelInfo, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'layout_x', capability: 'sensor', name: 'Layout X', readable: true, writable: false },
|
||||||
|
{ id: 'layout_y', capability: 'sensor', name: 'Layout Y', readable: true, writable: false },
|
||||||
|
{ id: 'orientation', capability: 'sensor', name: 'Orientation', readable: true, writable: false },
|
||||||
|
{ id: 'shape_type', capability: 'sensor', name: 'Shape type', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: this.panelDeviceId(snapshotArg, panelArg),
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
name: `${this.controllerName(snapshotArg)} Panel ${panelArg.panelId}`,
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Nanoleaf',
|
||||||
|
model: panelArg.shapeType === undefined ? 'Panel' : `Panel shape ${panelArg.shapeType}`,
|
||||||
|
online: true,
|
||||||
|
features,
|
||||||
|
state: [
|
||||||
|
{ featureId: 'layout_x', value: panelArg.x ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'layout_y', value: panelArg.y ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'orientation', value: panelArg.o ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'shape_type', value: panelArg.shapeType ?? null, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
panelId: panelArg.panelId,
|
||||||
|
controllerDeviceId: this.controllerDeviceId(snapshotArg),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushSensorEntity(
|
||||||
|
entitiesArg: IIntegrationEntity[],
|
||||||
|
deviceIdArg: string,
|
||||||
|
uniqueIdArg: string,
|
||||||
|
idArg: string,
|
||||||
|
nameArg: string,
|
||||||
|
valueArg: unknown
|
||||||
|
): void {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitiesArg.push({
|
||||||
|
id: idArg,
|
||||||
|
uniqueId: uniqueIdArg,
|
||||||
|
integrationDomain: 'nanoleaf',
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: 'sensor',
|
||||||
|
name: nameArg,
|
||||||
|
state: valueArg,
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static controllerDeviceId(snapshotArg: INanoleafSnapshot): string {
|
||||||
|
return `nanoleaf.controller.${this.slug(snapshotArg.controllerInfo.serialNo || snapshotArg.controllerInfo.name || 'unknown')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static panelDeviceId(snapshotArg: INanoleafSnapshot, panelArg: INanoleafPanelInfo): string {
|
||||||
|
return `nanoleaf.panel.${this.slug(snapshotArg.controllerInfo.serialNo || snapshotArg.controllerInfo.name || 'unknown')}.${panelArg.panelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static controllerName(snapshotArg: INanoleafSnapshot): string {
|
||||||
|
return snapshotArg.controllerInfo.name || snapshotArg.controllerInfo.model || 'Nanoleaf';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static panels(snapshotArg: INanoleafSnapshot): INanoleafPanelInfo[] {
|
||||||
|
return snapshotArg.panelLayout?.layout?.positionData ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static panelCount(snapshotArg: INanoleafSnapshot): number | undefined {
|
||||||
|
return snapshotArg.panelLayout?.layout?.numPanels ?? snapshotArg.panelLayout?.layout?.positionData?.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static currentEffect(snapshotArg: INanoleafSnapshot): string | undefined {
|
||||||
|
const effect = snapshotArg.effects.select;
|
||||||
|
if (!effect || RESERVED_EFFECTS.has(effect)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return effect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static availableEffects(snapshotArg: INanoleafSnapshot): string[] {
|
||||||
|
return (snapshotArg.effects.effectsList ?? []).filter((effectArg) => !RESERVED_EFFECTS.has(effectArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static colorMode(stateArg: INanoleafState): string | undefined {
|
||||||
|
return this.valueLike(stateArg.colorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stateValue<TValue>(valueArg: INanoleafValue<TValue> | undefined): TValue | undefined {
|
||||||
|
return valueArg?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static valueLike<TValue>(valueArg: INanoleafValue<TValue> | TValue | undefined): TValue | undefined {
|
||||||
|
if (this.isRecord(valueArg) && 'value' in valueArg) {
|
||||||
|
return valueArg.value as TValue;
|
||||||
|
}
|
||||||
|
return valueArg as TValue | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'nanoleaf';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,185 @@
|
|||||||
export interface IHomeAssistantNanoleafConfig {
|
export type TNanoleafProtocol = 'http' | 'https';
|
||||||
// TODO: replace with the TypeScript-native config for nanoleaf.
|
export type TNanoleafColorMode = 'hs' | 'ct' | 'effect' | string;
|
||||||
|
export type TNanoleafGesture = 'swipe_up' | 'swipe_down' | 'swipe_left' | 'swipe_right';
|
||||||
|
|
||||||
|
export interface INanoleafConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: TNanoleafProtocol;
|
||||||
|
authToken?: string;
|
||||||
|
token?: string;
|
||||||
|
controllerInfo?: INanoleafControllerInfo;
|
||||||
|
state?: INanoleafState;
|
||||||
|
effects?: INanoleafEffects;
|
||||||
|
panelLayout?: INanoleafPanelLayout;
|
||||||
|
rhythm?: INanoleafRhythmInfo;
|
||||||
|
snapshot?: INanoleafSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafValue<TValue> {
|
||||||
|
value: TValue;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafState {
|
||||||
|
on?: INanoleafValue<boolean>;
|
||||||
|
brightness?: INanoleafValue<number>;
|
||||||
|
hue?: INanoleafValue<number>;
|
||||||
|
sat?: INanoleafValue<number>;
|
||||||
|
ct?: INanoleafValue<number>;
|
||||||
|
colorMode?: TNanoleafColorMode | INanoleafValue<TNanoleafColorMode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafPanelInfo {
|
||||||
|
panelId: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
o?: number;
|
||||||
|
shapeType?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafPanelLayout {
|
||||||
|
globalOrientation?: INanoleafValue<number>;
|
||||||
|
layout?: {
|
||||||
|
numPanels?: number;
|
||||||
|
sideLength?: number;
|
||||||
|
positionData?: INanoleafPanelInfo[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafRhythmInfo {
|
||||||
|
rhythmConnected?: boolean | INanoleafValue<boolean>;
|
||||||
|
rhythmActive?: boolean | INanoleafValue<boolean>;
|
||||||
|
rhythmId?: number | INanoleafValue<number>;
|
||||||
|
hardwareVersion?: string | INanoleafValue<string>;
|
||||||
|
firmwareVersion?: string | INanoleafValue<string>;
|
||||||
|
auxAvailable?: boolean | INanoleafValue<boolean>;
|
||||||
|
rhythmMode?: number | string | INanoleafValue<number | string>;
|
||||||
|
rhythmPos?: number | INanoleafValue<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEffects {
|
||||||
|
select?: string;
|
||||||
|
effectsList?: string[];
|
||||||
|
write?: INanoleafEffectsCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEffectPaletteColor {
|
||||||
|
hue?: number;
|
||||||
|
saturation?: number;
|
||||||
|
brightness?: number;
|
||||||
|
probability?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEffectPluginOption {
|
||||||
|
name: string;
|
||||||
|
value?: string | number | boolean;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string | number | boolean;
|
||||||
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
strings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEffectsCommand {
|
||||||
|
command: 'add' | 'request' | 'delete' | 'display' | 'displayTemp' | 'rename' | 'requestAll' | 'requestPlugins' | string;
|
||||||
|
version?: string;
|
||||||
|
duration?: number;
|
||||||
|
animName?: string;
|
||||||
|
newName?: string;
|
||||||
|
animType?: string;
|
||||||
|
animData?: string | null;
|
||||||
|
colorType?: string;
|
||||||
|
palette?: INanoleafEffectPaletteColor[];
|
||||||
|
Palette?: INanoleafEffectPaletteColor[];
|
||||||
|
pluginUuid?: string;
|
||||||
|
pluginType?: string;
|
||||||
|
pluginOptions?: INanoleafEffectPluginOption[];
|
||||||
|
loop?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface INanoleafStateCommand {
|
||||||
|
on?: INanoleafValue<boolean>;
|
||||||
|
brightness?: INanoleafValue<number> & { duration?: number; increment?: number };
|
||||||
|
hue?: INanoleafValue<number> & { increment?: number };
|
||||||
|
sat?: INanoleafValue<number> & { increment?: number };
|
||||||
|
ct?: INanoleafValue<number> & { increment?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafControllerInfo {
|
||||||
|
name?: string;
|
||||||
|
serialNo?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
hardwareVersion?: string;
|
||||||
|
model?: string;
|
||||||
|
state?: INanoleafState;
|
||||||
|
effects?: INanoleafEffects;
|
||||||
|
panelLayout?: INanoleafPanelLayout;
|
||||||
|
rhythm?: INanoleafRhythmInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafSnapshot {
|
||||||
|
controllerInfo: INanoleafControllerInfo;
|
||||||
|
state: INanoleafState;
|
||||||
|
effects: INanoleafEffects;
|
||||||
|
panelLayout?: INanoleafPanelLayout;
|
||||||
|
rhythm?: INanoleafRhythmInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafAuthTokenResult {
|
||||||
|
success: boolean;
|
||||||
|
authToken?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEvent<TData = unknown> {
|
||||||
|
type: 'state' | 'effects' | 'layout' | 'touch' | string;
|
||||||
|
data: TData;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafStateEventData {
|
||||||
|
attribute: keyof INanoleafState | string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafEffectsEventData {
|
||||||
|
effect?: string;
|
||||||
|
effectsList?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafTouchEventData {
|
||||||
|
panelId?: number;
|
||||||
|
gestureId?: number;
|
||||||
|
gesture?: TNanoleafGesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafMdnsRecord {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafSsdpRecord {
|
||||||
|
st?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INanoleafManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IHomeAssistantNanoleafConfig = INanoleafConfig;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './tradfri.classes.integration.js';
|
export * from './tradfri.classes.client.js';
|
||||||
|
export * from './tradfri.classes.configflow.js';
|
||||||
|
export { HomeAssistantTradfriIntegration, TradfriIntegration } from './tradfri.classes.integration.js';
|
||||||
|
export * from './tradfri.discovery.js';
|
||||||
|
export * from './tradfri.mapper.js';
|
||||||
export * from './tradfri.types.js';
|
export * from './tradfri.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ITradfriCommand, ITradfriCommandResult, ITradfriConfig, ITradfriEvent, ITradfriGateway, ITradfriManualEntry, ITradfriSnapshot } from './tradfri.types.js';
|
||||||
|
import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js';
|
||||||
|
|
||||||
|
type TTradfriEventHandler = (eventArg: ITradfriEvent) => void;
|
||||||
|
|
||||||
|
export class TradfriClient {
|
||||||
|
private readonly events: ITradfriEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TTradfriEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: ITradfriConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<ITradfriSnapshot> {
|
||||||
|
const source = this.config.snapshot;
|
||||||
|
const manualEntry = this.config.manualEntries?.[0];
|
||||||
|
const gateway = this.gatewayFromConfig(source, manualEntry);
|
||||||
|
return {
|
||||||
|
host: this.config.host || source?.host || manualEntry?.host,
|
||||||
|
port: this.config.port || source?.port || manualEntry?.port || tradfriDefaultCoapDtlsPort,
|
||||||
|
connected: source?.connected ?? false,
|
||||||
|
gateway,
|
||||||
|
devices: [
|
||||||
|
...(source?.devices || []),
|
||||||
|
...(this.config.devices || []),
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
...(source?.groups || []),
|
||||||
|
...(this.config.groups || []),
|
||||||
|
],
|
||||||
|
states: source?.states || [],
|
||||||
|
events: [
|
||||||
|
...(source?.events || []),
|
||||||
|
...this.events,
|
||||||
|
],
|
||||||
|
updatedAt: source?.updatedAt,
|
||||||
|
raw: source?.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TTradfriEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: ITradfriCommand): Promise<ITradfriCommandResult> {
|
||||||
|
let result: ITradfriCommandResult;
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: this.unsupportedLiveControlMessage(),
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.emit({
|
||||||
|
type: result.success ? 'command_mapped' : 'command_failed',
|
||||||
|
command: commandArg,
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
deviceId: commandArg.deviceId,
|
||||||
|
entityId: commandArg.entityId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectLive(): Promise<void> {
|
||||||
|
throw new Error(this.unsupportedLiveControlMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private gatewayFromConfig(sourceArg?: ITradfriSnapshot, manualEntryArg?: ITradfriManualEntry): ITradfriGateway {
|
||||||
|
const discoveryRecord = this.config.discoveryRecords?.[0];
|
||||||
|
const host = this.config.host || sourceArg?.host || manualEntryArg?.host || discoveryRecord?.host;
|
||||||
|
const configuredGateway = this.config.gateway || sourceArg?.gateway;
|
||||||
|
return {
|
||||||
|
...configuredGateway,
|
||||||
|
id: configuredGateway?.id || this.config.gatewayId || manualEntryArg?.gatewayId || manualEntryArg?.gateway_id || discoveryRecord?.gatewayId || discoveryRecord?.gateway_id || discoveryRecord?.id || host || 'configured',
|
||||||
|
name: configuredGateway?.name || manualEntryArg?.name || discoveryRecord?.name || 'IKEA TRADFRI Gateway',
|
||||||
|
model: configuredGateway?.model || manualEntryArg?.model || discoveryRecord?.model || 'E1526',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: ITradfriCommand): ITradfriCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is ITradfriCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: ITradfriEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedLiveControlMessage(): string {
|
||||||
|
if (this.config.identity && (this.config.psk || this.config.key)) {
|
||||||
|
return 'IKEA Tradfri live writes require CoAP over DTLS with PSK authentication. This dependency-free port maps snapshot/manual state only unless a commandExecutor is supplied; the mapped command was not sent.';
|
||||||
|
}
|
||||||
|
if (this.config.securityCode) {
|
||||||
|
return 'IKEA Tradfri pairing requires generating a PSK over CoAP/DTLS from the gateway security code. This dependency-free port does not perform DTLS pairing; the mapped command was not sent.';
|
||||||
|
}
|
||||||
|
return 'IKEA Tradfri live writes require CoAP over DTLS, which is not implemented in this dependency-free port. Supply snapshot data or a commandExecutor to handle mapped commands.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { ITradfriConfig } from './tradfri.types.js';
|
||||||
|
import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js';
|
||||||
|
|
||||||
|
export class TradfriConfigFlow implements IConfigFlow<ITradfriConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ITradfriConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect IKEA TRADFRI Gateway',
|
||||||
|
description: 'Configure the local gateway. Existing identity plus PSK can be used for an external command executor; security-code pairing over CoAP/DTLS is not performed by this dependency-free port.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'CoAP/DTLS port', type: 'number' },
|
||||||
|
{ name: 'identity', label: 'Identity', type: 'text' },
|
||||||
|
{ name: 'securityCode', label: 'Gateway security code', type: 'password' },
|
||||||
|
{ name: 'psk', label: 'Pre-shared key', type: 'password' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const psk = this.stringValue(valuesArg.psk) || this.stringValue(candidateArg.metadata?.psk) || this.stringValue(candidateArg.metadata?.key);
|
||||||
|
const gatewayId = this.stringValue(candidateArg.metadata?.gatewayId) || candidateArg.id;
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'IKEA TRADFRI configured',
|
||||||
|
config: {
|
||||||
|
host: this.stringValue(valuesArg.host) || candidateArg.host || '',
|
||||||
|
port: this.numberValue(valuesArg.port) || candidateArg.port || tradfriDefaultCoapDtlsPort,
|
||||||
|
gatewayId,
|
||||||
|
identity: this.stringValue(valuesArg.identity) || this.stringValue(candidateArg.metadata?.identity),
|
||||||
|
securityCode: this.stringValue(valuesArg.securityCode) || this.stringValue(candidateArg.metadata?.securityCode),
|
||||||
|
psk,
|
||||||
|
key: psk,
|
||||||
|
gateway: {
|
||||||
|
id: gatewayId,
|
||||||
|
name: candidateArg.name || 'IKEA TRADFRI Gateway',
|
||||||
|
model: candidateArg.model || 'E1526',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,82 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { TradfriClient } from './tradfri.classes.client.js';
|
||||||
|
import { TradfriConfigFlow } from './tradfri.classes.configflow.js';
|
||||||
|
import { createTradfriDiscoveryDescriptor } from './tradfri.discovery.js';
|
||||||
|
import { TradfriMapper } from './tradfri.mapper.js';
|
||||||
|
import type { ITradfriConfig } from './tradfri.types.js';
|
||||||
|
|
||||||
export class HomeAssistantTradfriIntegration extends DescriptorOnlyIntegration {
|
export class TradfriIntegration extends BaseIntegration<ITradfriConfig> {
|
||||||
constructor() {
|
public readonly domain = 'tradfri';
|
||||||
super({
|
public readonly displayName = 'IKEA TR\u00c5DFRI';
|
||||||
domain: "tradfri",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "IKEA TRÅDFRI",
|
public readonly discoveryDescriptor = createTradfriDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new TradfriConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/tradfri",
|
upstreamPath: 'homeassistant/components/tradfri',
|
||||||
"upstreamDomain": "tradfri",
|
upstreamDomain: 'tradfri',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
requirements: ['pytradfri[async]==9.0.1'],
|
||||||
"pytradfri[async]==9.0.1"
|
dependencies: [] as string[],
|
||||||
],
|
afterDependencies: [] as string[],
|
||||||
"dependencies": [],
|
codeowners: [] as string[],
|
||||||
"afterDependencies": [],
|
documentation: 'https://www.home-assistant.io/integrations/tradfri',
|
||||||
"codeowners": []
|
homekit: { models: ['TRADFRI'] },
|
||||||
},
|
configFlow: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: ITradfriConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new TradfriRuntime(new TradfriClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantTradfriIntegration extends TradfriIntegration {}
|
||||||
|
|
||||||
|
class TradfriRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'tradfri';
|
||||||
|
|
||||||
|
constructor(private readonly client: TradfriClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return TradfriMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return TradfriMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: eventArg.type === 'command_failed' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = TradfriMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `IKEA Tradfri service ${requestArg.domain}.${requestArg.service} has no safe native command mapping.` };
|
||||||
|
}
|
||||||
|
const result = await this.client.sendCommand(command);
|
||||||
|
return { success: result.success, error: result.error, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { ITradfriManualEntry, ITradfriMdnsRecord } from './tradfri.types.js';
|
||||||
|
import { tradfriDefaultCoapDtlsPort } from './tradfri.types.js';
|
||||||
|
|
||||||
|
const tradfriMdnsTypes = new Set(['_hap._tcp.local', '_homekit._tcp.local', '_tradfri._udp.local', '_coap._udp.local']);
|
||||||
|
|
||||||
|
export class TradfriMdnsMatcher implements IDiscoveryMatcher<ITradfriMdnsRecord> {
|
||||||
|
public id = 'tradfri-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize IKEA TRADFRI gateway mDNS and HomeKit zeroconf records.';
|
||||||
|
|
||||||
|
public async matches(recordArg: ITradfriMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const name = recordArg.name || recordArg.hostname || '';
|
||||||
|
const model = this.txt(txt, 'md') || this.txt(txt, 'model') || this.txt(txt, 'modelid');
|
||||||
|
const manufacturer = this.txt(txt, 'manufacturer') || this.txt(txt, 'mfg');
|
||||||
|
const gatewayId = this.txt(txt, 'gateway_id') || this.txt(txt, 'gatewayid') || this.txt(txt, 'id') || this.txt(txt, 'serial') || this.txt(txt, 'sn');
|
||||||
|
const matched = tradfriMdnsTypes.has(type) && this.containsTradfri(`${name} ${model} ${manufacturer}`)
|
||||||
|
|| type === '_tradfri._udp.local'
|
||||||
|
|| this.containsTradfri(`${name} ${model} ${manufacturer}`)
|
||||||
|
|| Boolean(gatewayId && this.containsTradfri(name));
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not an IKEA TRADFRI gateway advertisement.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: gatewayId ? 'certain' : tradfriMdnsTypes.has(type) ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches IKEA TRADFRI gateway metadata.',
|
||||||
|
normalizedDeviceId: gatewayId || host || name,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
id: gatewayId || host || name,
|
||||||
|
host,
|
||||||
|
port: type === '_hap._tcp.local' || type === '_homekit._tcp.local' ? tradfriDefaultCoapDtlsPort : recordArg.port || tradfriDefaultCoapDtlsPort,
|
||||||
|
name: this.cleanName(name) || 'IKEA TRADFRI Gateway',
|
||||||
|
manufacturer: 'IKEA of Sweden',
|
||||||
|
model: model || 'TRADFRI Gateway',
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
gatewayId,
|
||||||
|
homekitId: this.txt(txt, 'id'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
||||||
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsTradfri(valueArg: string): boolean {
|
||||||
|
const value = valueArg.toLowerCase();
|
||||||
|
return value.includes('tradfri') || value.includes('tr\u00e5dfri') || value.includes('ikea');
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanName(valueArg: string): string | undefined {
|
||||||
|
const cleaned = valueArg.replace(/\._[^.]+\._(?:tcp|udp)\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim();
|
||||||
|
return cleaned || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TradfriManualMatcher implements IDiscoveryMatcher<ITradfriManualEntry> {
|
||||||
|
public id = 'tradfri-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual IKEA TRADFRI gateway host and security-code entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: ITradfriManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const metadata = inputArg.metadata || {};
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.securityCode || inputArg.security_code || inputArg.psk || inputArg.key || inputArg.identity || metadata.tradfri || metadata.securityCode);
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain IKEA TRADFRI setup hints.' };
|
||||||
|
}
|
||||||
|
const gatewayId = inputArg.gatewayId || inputArg.gateway_id || inputArg.id;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host && (inputArg.securityCode || inputArg.security_code || inputArg.psk || inputArg.key) ? 'high' : inputArg.host ? 'medium' : 'low',
|
||||||
|
reason: 'Manual entry can start IKEA TRADFRI setup.',
|
||||||
|
normalizedDeviceId: gatewayId || inputArg.host,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
id: gatewayId || inputArg.host,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || tradfriDefaultCoapDtlsPort,
|
||||||
|
name: inputArg.name || 'IKEA TRADFRI Gateway',
|
||||||
|
manufacturer: inputArg.manufacturer || 'IKEA of Sweden',
|
||||||
|
model: inputArg.model || 'TRADFRI Gateway',
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
gatewayId,
|
||||||
|
identity: inputArg.identity,
|
||||||
|
securityCode: inputArg.securityCode || inputArg.security_code,
|
||||||
|
psk: inputArg.psk || inputArg.key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TradfriCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'tradfri-candidate-validator';
|
||||||
|
public description = 'Validate IKEA TRADFRI gateway candidates before setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const value = `${candidateArg.integrationDomain || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${candidateArg.name || ''}`.toLowerCase();
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const matched = candidateArg.integrationDomain === 'tradfri'
|
||||||
|
|| value.includes('tradfri')
|
||||||
|
|| value.includes('tr\u00e5dfri')
|
||||||
|
|| value.includes('ikea')
|
||||||
|
|| Boolean(metadata.tradfri || metadata.securityCode || metadata.psk || metadata.gatewayId);
|
||||||
|
if (!matched || !candidateArg.host) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate lacks a host for local TRADFRI setup.' : 'Candidate is not IKEA TRADFRI.',
|
||||||
|
normalizedDeviceId: candidateArg.id || candidateArg.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: candidateArg.id ? 'high' : 'medium',
|
||||||
|
reason: 'Candidate has IKEA TRADFRI gateway metadata and host information.',
|
||||||
|
candidate: {
|
||||||
|
...candidateArg,
|
||||||
|
port: candidateArg.port || tradfriDefaultCoapDtlsPort,
|
||||||
|
},
|
||||||
|
normalizedDeviceId: candidateArg.id || candidateArg.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTradfriDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'tradfri', displayName: 'IKEA TR\u00c5DFRI' })
|
||||||
|
.addMatcher(new TradfriMdnsMatcher())
|
||||||
|
.addMatcher(new TradfriManualMatcher())
|
||||||
|
.addValidator(new TradfriCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type { ITradfriAirPurifier, ITradfriBlind, ITradfriCommand, ITradfriDevice, ITradfriDeviceInfo, ITradfriGateway, ITradfriGroup, ITradfriLight, ITradfriSensor, ITradfriSnapshot, ITradfriSocket, TTradfriResourceType } from './tradfri.types.js';
|
||||||
|
|
||||||
|
const ROOT_DEVICES = '15001';
|
||||||
|
const ROOT_GROUPS = '15004';
|
||||||
|
const ATTR_ID = '9003';
|
||||||
|
const ATTR_NAME = '9001';
|
||||||
|
const ATTR_DEVICE_INFO = '3';
|
||||||
|
const ATTR_REACHABLE_STATE = '9019';
|
||||||
|
const ATTR_DEVICE_MANUFACTURER = '0';
|
||||||
|
const ATTR_DEVICE_MODEL_NUMBER = '1';
|
||||||
|
const ATTR_DEVICE_SERIAL = '2';
|
||||||
|
const ATTR_DEVICE_FIRMWARE_VERSION = '3';
|
||||||
|
const ATTR_DEVICE_BATTERY = '9';
|
||||||
|
const ATTR_DEVICE_STATE = '5850';
|
||||||
|
const ATTR_LIGHT_CONTROL = '3311';
|
||||||
|
const ATTR_LIGHT_DIMMER = '5851';
|
||||||
|
const ATTR_LIGHT_COLOR_HEX = '5706';
|
||||||
|
const ATTR_LIGHT_COLOR_HUE = '5707';
|
||||||
|
const ATTR_LIGHT_COLOR_SATURATION = '5708';
|
||||||
|
const ATTR_LIGHT_MIREDS = '5711';
|
||||||
|
const ATTR_SWITCH_PLUG = '3312';
|
||||||
|
const ATTR_SENSOR = '3300';
|
||||||
|
const ATTR_SENSOR_VALUE = '5700';
|
||||||
|
const ATTR_SENSOR_UNIT = '5701';
|
||||||
|
const ATTR_SENSOR_TYPE = '5751';
|
||||||
|
const ATTR_START_BLINDS = '15015';
|
||||||
|
const ATTR_BLIND_CURRENT_POSITION = '5536';
|
||||||
|
const ROOT_AIR_PURIFIER = '15025';
|
||||||
|
const ATTR_AIR_PURIFIER_MODE = '5900';
|
||||||
|
const ATTR_AIR_PURIFIER_AIR_QUALITY = '5907';
|
||||||
|
const ATTR_AIR_PURIFIER_FAN_SPEED = '5908';
|
||||||
|
const ATTR_AIR_PURIFIER_FILTER_LIFETIME_REMAINING = '5910';
|
||||||
|
const ATTR_GROUP_MEMBERS = '9018';
|
||||||
|
const ATTR_HS_LINK = '15002';
|
||||||
|
|
||||||
|
export class TradfriMapper {
|
||||||
|
public static toDevices(snapshotArg: ITradfriSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const gateway = this.gateway(snapshotArg);
|
||||||
|
const gatewayId = this.gatewayIdentifier(snapshotArg);
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.gatewayDevice(snapshotArg, gateway, updatedAt)];
|
||||||
|
|
||||||
|
for (const [index, device] of snapshotArg.devices.entries()) {
|
||||||
|
const deviceResourceId = this.deviceResourceId(device, index);
|
||||||
|
const deviceId = this.deviceId(snapshotArg, device, index);
|
||||||
|
const deviceInfo = this.deviceInfo(device);
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: this.reachable(device) ? 'online' : 'offline', updatedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
this.pushOptionalFeature(features, state, 'firmware', 'Firmware', this.stringValue(deviceInfo.firmwareVersion || deviceInfo.firmware_version), undefined, updatedAt);
|
||||||
|
this.pushOptionalFeature(features, state, 'battery', 'Battery', this.numberValue(deviceInfo.batteryLevel ?? deviceInfo.battery_level), '%', updatedAt);
|
||||||
|
|
||||||
|
for (const [lightIndex, light] of this.lightControls(device).entries()) {
|
||||||
|
const prefix = this.indexedFeatureId('light', lightIndex);
|
||||||
|
features.push({ id: `${prefix}_on`, capability: 'light', name: this.indexedName('Light', lightIndex), readable: true, writable: true });
|
||||||
|
state.push({ featureId: `${prefix}_on`, value: this.booleanState(this.value(light, ['state', 'on', ATTR_DEVICE_STATE])), updatedAt });
|
||||||
|
this.pushOptionalFeature(features, state, `${prefix}_brightness`, `${this.indexedName('Light', lightIndex)} brightness`, this.lightBrightnessPercent(light), '%', updatedAt, 'light', true);
|
||||||
|
this.pushOptionalFeature(features, state, `${prefix}_color_temp`, `${this.indexedName('Light', lightIndex)} color temperature`, this.numberValue(this.value(light, ['colorTemp', 'color_temp', 'colorMireds', 'color_mireds', ATTR_LIGHT_MIREDS])), 'mired', updatedAt, 'light', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [socketIndex, socket] of this.socketControls(device).entries()) {
|
||||||
|
const prefix = this.indexedFeatureId('outlet', socketIndex);
|
||||||
|
features.push({ id: `${prefix}_on`, capability: 'switch', name: this.indexedName('Outlet', socketIndex), readable: true, writable: true });
|
||||||
|
state.push({ featureId: `${prefix}_on`, value: this.booleanState(this.value(socket, ['state', 'on', ATTR_DEVICE_STATE])), updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [blindIndex, blind] of this.blindControls(device).entries()) {
|
||||||
|
const prefix = this.indexedFeatureId('cover', blindIndex);
|
||||||
|
features.push({ id: `${prefix}_position`, capability: 'cover', name: this.indexedName('Blind position', blindIndex), readable: true, writable: true, unit: '%' });
|
||||||
|
state.push({ featureId: `${prefix}_position`, value: this.coverPosition(blind), updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fanIndex, fan] of this.airPurifierControls(device).entries()) {
|
||||||
|
const prefix = this.indexedFeatureId('fan', fanIndex);
|
||||||
|
features.push({ id: `${prefix}_speed`, capability: 'fan', name: this.indexedName('Air purifier fan', fanIndex), readable: true, writable: true, unit: '%' });
|
||||||
|
state.push({ featureId: `${prefix}_speed`, value: this.fanPercentage(fan), updatedAt });
|
||||||
|
this.pushOptionalFeature(features, state, `${prefix}_air_quality`, `${this.indexedName('Air quality', fanIndex)}`, this.airQuality(fan), 'ug/m3', updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sensorIndex, sensor] of this.sensorControls(device).entries()) {
|
||||||
|
const sensorName = this.sensorName(sensor, sensorIndex);
|
||||||
|
const sensorFeature = this.indexedFeatureId(this.binarySensor(sensor) ? 'binary_sensor' : 'sensor', sensorIndex);
|
||||||
|
features.push({ id: sensorFeature, capability: 'sensor', name: sensorName, readable: true, writable: false, unit: this.sensorUnit(sensor) });
|
||||||
|
state.push({ featureId: sensorFeature, value: this.deviceStateValue(this.sensorValue(sensor)), updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: deviceId,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
name: this.deviceName(device, deviceResourceId),
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: this.stringValue(deviceInfo.manufacturer) || 'IKEA of Sweden',
|
||||||
|
model: this.stringValue(deviceInfo.modelNumber || deviceInfo.model_number),
|
||||||
|
online: this.reachable(device),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
gatewayId,
|
||||||
|
tradfriDeviceId: deviceResourceId,
|
||||||
|
serial: deviceInfo.serial,
|
||||||
|
powerSource: deviceInfo.powerSource ?? deviceInfo.power_source,
|
||||||
|
rawDeviceInfo: deviceInfo.raw,
|
||||||
|
transport: 'coap-dtls',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, group] of snapshotArg.groups.entries()) {
|
||||||
|
const groupResourceId = this.groupResourceId(group, index);
|
||||||
|
devices.push({
|
||||||
|
id: this.groupDeviceId(snapshotArg, group, index),
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
name: this.groupName(group, groupResourceId),
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: 'IKEA of Sweden',
|
||||||
|
model: 'TRADFRI group',
|
||||||
|
online: snapshotArg.connected ?? true,
|
||||||
|
features: [
|
||||||
|
{ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||||
|
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' },
|
||||||
|
{ id: 'member_count', capability: 'sensor', name: 'Member count', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'on', value: this.booleanState(this.value(group, ['state', ATTR_DEVICE_STATE])), updatedAt },
|
||||||
|
{ featureId: 'brightness', value: this.groupBrightnessPercent(group) ?? null, updatedAt },
|
||||||
|
{ featureId: 'member_count', value: this.groupMemberIds(group).length, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
gatewayId,
|
||||||
|
tradfriGroupId: groupResourceId,
|
||||||
|
memberIds: this.groupMemberIds(group),
|
||||||
|
transport: 'coap-dtls',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: ITradfriSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
for (const [index, device] of snapshotArg.devices.entries()) {
|
||||||
|
const deviceId = this.deviceId(snapshotArg, device, index);
|
||||||
|
const deviceResourceId = this.deviceResourceId(device, index);
|
||||||
|
const deviceSlug = this.slug(this.deviceName(device, deviceResourceId));
|
||||||
|
const reachable = this.reachable(device);
|
||||||
|
const deviceInfo = this.deviceInfo(device);
|
||||||
|
|
||||||
|
for (const [lightIndex, light] of this.lightControls(device).entries()) {
|
||||||
|
entities.push({
|
||||||
|
id: `light.${this.entitySlug(deviceSlug, lightIndex)}`,
|
||||||
|
uniqueId: `tradfri_light_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${lightIndex}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId,
|
||||||
|
platform: 'light',
|
||||||
|
name: this.indexedEntityName(this.deviceName(device, deviceResourceId), lightIndex),
|
||||||
|
state: this.booleanState(this.value(light, ['state', 'on', ATTR_DEVICE_STATE])) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
brightness: this.numberValue(this.value(light, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])),
|
||||||
|
brightnessPercent: this.lightBrightnessPercent(light),
|
||||||
|
colorTempMireds: this.numberValue(this.value(light, ['colorTemp', 'color_temp', 'colorMireds', 'color_mireds', ATTR_LIGHT_MIREDS])),
|
||||||
|
colorHex: this.stringValue(this.value(light, ['colorHex', 'color_hex', ATTR_LIGHT_COLOR_HEX])),
|
||||||
|
hue: this.numberValue(this.value(light, ['hue', 'colorHue', 'color_hue', ATTR_LIGHT_COLOR_HUE])),
|
||||||
|
saturation: this.numberValue(this.value(light, ['saturation', 'colorSaturation', 'color_saturation', ATTR_LIGHT_COLOR_SATURATION])),
|
||||||
|
tradfriResourceType: 'device',
|
||||||
|
tradfriResourceId: deviceResourceId,
|
||||||
|
tradfriControl: 'light',
|
||||||
|
tradfriIndex: lightIndex,
|
||||||
|
},
|
||||||
|
available: reachable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [socketIndex, socket] of this.socketControls(device).entries()) {
|
||||||
|
entities.push({
|
||||||
|
id: `switch.${this.entitySlug(deviceSlug, socketIndex)}`,
|
||||||
|
uniqueId: `tradfri_switch_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${socketIndex}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId,
|
||||||
|
platform: 'switch',
|
||||||
|
name: this.indexedEntityName(this.deviceName(device, deviceResourceId), socketIndex),
|
||||||
|
state: this.booleanState(this.value(socket, ['state', 'on', ATTR_DEVICE_STATE])) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
outlet: true,
|
||||||
|
tradfriResourceType: 'device',
|
||||||
|
tradfriResourceId: deviceResourceId,
|
||||||
|
tradfriControl: 'socket',
|
||||||
|
tradfriIndex: socketIndex,
|
||||||
|
},
|
||||||
|
available: reachable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [blindIndex, blind] of this.blindControls(device).entries()) {
|
||||||
|
const position = this.coverPosition(blind);
|
||||||
|
entities.push({
|
||||||
|
id: `cover.${this.entitySlug(deviceSlug, blindIndex)}`,
|
||||||
|
uniqueId: `tradfri_cover_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${blindIndex}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId,
|
||||||
|
platform: 'cover',
|
||||||
|
name: this.indexedEntityName(this.deviceName(device, deviceResourceId), blindIndex),
|
||||||
|
state: position,
|
||||||
|
attributes: {
|
||||||
|
position,
|
||||||
|
rawPosition: 100 - position,
|
||||||
|
isClosed: position === 0,
|
||||||
|
model: this.stringValue(deviceInfo.modelNumber || deviceInfo.model_number),
|
||||||
|
tradfriResourceType: 'device',
|
||||||
|
tradfriResourceId: deviceResourceId,
|
||||||
|
tradfriControl: 'blind',
|
||||||
|
tradfriIndex: blindIndex,
|
||||||
|
},
|
||||||
|
available: reachable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushBatteryEntity(entities, snapshotArg, device, deviceInfo, deviceId, deviceSlug, reachable);
|
||||||
|
|
||||||
|
for (const [fanIndex, fan] of this.airPurifierControls(device).entries()) {
|
||||||
|
const fanSlug = this.entitySlug(deviceSlug, fanIndex);
|
||||||
|
const percentage = this.fanPercentage(fan);
|
||||||
|
entities.push({
|
||||||
|
id: `fan.${fanSlug}`,
|
||||||
|
uniqueId: `tradfri_fan_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${fanIndex}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId,
|
||||||
|
platform: 'fan',
|
||||||
|
name: this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex),
|
||||||
|
state: this.airPurifierOn(fan) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
percentage,
|
||||||
|
presetMode: this.airPurifierAuto(fan) ? 'Auto' : undefined,
|
||||||
|
airQuality: this.airQuality(fan),
|
||||||
|
tradfriResourceType: 'device',
|
||||||
|
tradfriResourceId: deviceResourceId,
|
||||||
|
tradfriControl: 'air_purifier',
|
||||||
|
tradfriIndex: fanIndex,
|
||||||
|
},
|
||||||
|
available: reachable,
|
||||||
|
});
|
||||||
|
this.pushSensorEntity(entities, deviceId, `tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_air_quality_${fanIndex}`, `sensor.${fanSlug}_air_quality`, `${this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex)} air quality`, this.airQuality(fan), reachable, { unit: 'ug/m3', deviceClass: 'pm25' });
|
||||||
|
this.pushSensorEntity(entities, deviceId, `tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_filter_life_${fanIndex}`, `sensor.${fanSlug}_filter_life_remaining`, `${this.indexedEntityName(this.deviceName(device, deviceResourceId), fanIndex)} filter life remaining`, this.numberValue(this.value(fan, ['filterLifetimeRemaining', 'filter_lifetime_remaining', ATTR_AIR_PURIFIER_FILTER_LIFETIME_REMAINING])), reachable, { unit: 'min' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sensorIndex, sensor] of this.sensorControls(device).entries()) {
|
||||||
|
const sensorValue = this.sensorValue(sensor);
|
||||||
|
const binary = this.binarySensor(sensor);
|
||||||
|
const platform = binary ? 'binary_sensor' : 'sensor';
|
||||||
|
const sensorSlug = `${deviceSlug}_${this.slug(this.sensorName(sensor, sensorIndex))}`;
|
||||||
|
entities.push({
|
||||||
|
id: `${platform}.${sensorSlug}`,
|
||||||
|
uniqueId: `tradfri_${platform}_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_${sensorIndex}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
name: this.sensorName(sensor, sensorIndex),
|
||||||
|
state: binary ? this.binaryState(sensorValue) : sensorValue ?? 'unknown',
|
||||||
|
attributes: {
|
||||||
|
unit: this.sensorUnit(sensor),
|
||||||
|
deviceClass: this.sensorDeviceClass(sensor),
|
||||||
|
tradfriResourceType: 'device',
|
||||||
|
tradfriResourceId: deviceResourceId,
|
||||||
|
tradfriControl: binary ? 'binary_sensor' : 'sensor',
|
||||||
|
tradfriIndex: sensorIndex,
|
||||||
|
},
|
||||||
|
available: reachable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, group] of snapshotArg.groups.entries()) {
|
||||||
|
const groupResourceId = this.groupResourceId(group, index);
|
||||||
|
const groupSlug = this.slug(this.groupName(group, groupResourceId));
|
||||||
|
entities.push({
|
||||||
|
id: `light.${groupSlug}`,
|
||||||
|
uniqueId: `tradfri_group_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(groupResourceId)}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId: this.groupDeviceId(snapshotArg, group, index),
|
||||||
|
platform: 'light',
|
||||||
|
name: this.groupName(group, groupResourceId),
|
||||||
|
state: this.booleanState(this.value(group, ['state', ATTR_DEVICE_STATE])) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
brightness: this.numberValue(this.value(group, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER])),
|
||||||
|
brightnessPercent: this.groupBrightnessPercent(group),
|
||||||
|
memberIds: this.groupMemberIds(group),
|
||||||
|
tradfriResourceType: 'group',
|
||||||
|
tradfriResourceId: groupResourceId,
|
||||||
|
tradfriControl: 'group',
|
||||||
|
tradfriIndex: index,
|
||||||
|
},
|
||||||
|
available: snapshotArg.connected ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: ITradfriSnapshot, requestArg: IServiceCallRequest): ITradfriCommand | undefined {
|
||||||
|
const target = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
if (!target || !this.serviceMatchesEntity(target, requestArg)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const payload = this.payloadForService(target, requestArg);
|
||||||
|
if (!payload) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const resourceType = target.attributes?.tradfriResourceType === 'group' ? 'group' : 'device';
|
||||||
|
const resourceId = this.stringValue(target.attributes?.tradfriResourceId);
|
||||||
|
if (!resourceId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.commandFromPayload(resourceType, resourceId, target, requestArg, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static gatewayDevice(snapshotArg: ITradfriSnapshot, gatewayArg: ITradfriGateway, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const gatewayId = this.gatewayIdentifier(snapshotArg);
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
{ id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false },
|
||||||
|
{ id: 'group_count', capability: 'sensor', name: 'Group count', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'configured', updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'device_count', value: snapshotArg.devices.length, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'group_count', value: snapshotArg.groups.length, updatedAt: updatedAtArg },
|
||||||
|
];
|
||||||
|
const firmware = this.stringValue(gatewayArg.firmwareVersion || gatewayArg.firmware_version || this.value(gatewayArg, ['9029']));
|
||||||
|
if (firmware) {
|
||||||
|
features.push({ id: 'firmware', capability: 'sensor', name: 'Firmware', readable: true, writable: false });
|
||||||
|
state.push({ featureId: 'firmware', value: firmware, updatedAt: updatedAtArg });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `tradfri.gateway.${this.slug(gatewayId)}`,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
name: this.stringValue(gatewayArg.name) || 'IKEA TRADFRI Gateway',
|
||||||
|
protocol: 'zigbee',
|
||||||
|
manufacturer: 'IKEA of Sweden',
|
||||||
|
model: this.stringValue(gatewayArg.model) || 'E1526',
|
||||||
|
online: snapshotArg.connected ?? Boolean(snapshotArg.devices.length || snapshotArg.groups.length),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
gatewayId,
|
||||||
|
host: snapshotArg.host,
|
||||||
|
port: snapshotArg.port,
|
||||||
|
homekitId: gatewayArg.homekitId || gatewayArg.homekit_id,
|
||||||
|
transport: 'coap-dtls',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushBatteryEntity(entitiesArg: IIntegrationEntity[], snapshotArg: ITradfriSnapshot, deviceArg: ITradfriDevice, deviceInfoArg: ITradfriDeviceInfo, deviceIdArg: string, deviceSlugArg: string, reachableArg: boolean): void {
|
||||||
|
const battery = this.numberValue(deviceInfoArg.batteryLevel ?? deviceInfoArg.battery_level);
|
||||||
|
if (battery === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deviceResourceId = this.deviceResourceId(deviceArg);
|
||||||
|
this.pushSensorEntity(
|
||||||
|
entitiesArg,
|
||||||
|
deviceIdArg,
|
||||||
|
`tradfri_sensor_${this.slug(this.gatewayIdentifier(snapshotArg))}_${this.slug(deviceResourceId)}_battery`,
|
||||||
|
`sensor.${deviceSlugArg}_battery`,
|
||||||
|
`${this.deviceName(deviceArg, deviceResourceId)} battery`,
|
||||||
|
battery,
|
||||||
|
reachableArg,
|
||||||
|
{ unit: '%', deviceClass: 'battery', tradfriResourceType: 'device', tradfriResourceId: deviceResourceId, tradfriControl: 'battery' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushSensorEntity(entitiesArg: IIntegrationEntity[], deviceIdArg: string, uniqueIdArg: string, idArg: string, nameArg: string, valueArg: unknown, availableArg: boolean, attributesArg: Record<string, unknown>): void {
|
||||||
|
if (valueArg === undefined || valueArg === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitiesArg.push({
|
||||||
|
id: idArg,
|
||||||
|
uniqueId: uniqueIdArg,
|
||||||
|
integrationDomain: 'tradfri',
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: 'sensor',
|
||||||
|
name: nameArg,
|
||||||
|
state: valueArg,
|
||||||
|
attributes: attributesArg,
|
||||||
|
available: availableArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushOptionalFeature(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, unitArg: string | undefined, updatedAtArg: string, capabilityArg: plugins.shxInterfaces.data.TDeviceCapability = 'sensor', writableArg = false): void {
|
||||||
|
if (valueArg === undefined || valueArg === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
featuresArg.push({ id: idArg, capability: capabilityArg, name: nameArg, readable: true, writable: writableArg, unit: unitArg });
|
||||||
|
stateArg.push({ featureId: idArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetEntity(snapshotArg: ITradfriSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||||
|
}
|
||||||
|
const candidates = requestArg.target.deviceId ? entities.filter((entityArg) => entityArg.deviceId === requestArg.target.deviceId) : entities;
|
||||||
|
return candidates.find((entityArg) => this.serviceMatchesEntity(entityArg, requestArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serviceMatchesEntity(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): boolean {
|
||||||
|
if (requestArg.domain !== 'tradfri' && requestArg.domain !== entityArg.platform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') {
|
||||||
|
return ['light', 'switch', 'fan'].includes(entityArg.platform);
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_position' || requestArg.service === 'open_cover' || requestArg.service === 'close_cover') {
|
||||||
|
return entityArg.platform === 'cover';
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_percentage') {
|
||||||
|
return entityArg.platform === 'fan' || entityArg.platform === 'light';
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
return ['light', 'switch', 'cover', 'fan'].includes(entityArg.platform);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static payloadForService(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): Record<string, unknown> | undefined {
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
const brightness = this.brightnessPayload(requestArg);
|
||||||
|
if (entityArg.platform === 'fan') {
|
||||||
|
return { state: true, percentage: this.numberValue(requestArg.data?.percentage) ?? this.numberValue(entityArg.attributes?.percentage) ?? 100 };
|
||||||
|
}
|
||||||
|
return { state: true, ...brightness };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
return { state: false };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_position') {
|
||||||
|
const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.positionPercentage ?? requestArg.data?.position_percentage);
|
||||||
|
return position === undefined ? undefined : { position: this.clampPercent(position), rawPosition: 100 - this.clampPercent(position) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'open_cover') {
|
||||||
|
return { position: 100, rawPosition: 0 };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'close_cover') {
|
||||||
|
return { position: 0, rawPosition: 100 };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_percentage') {
|
||||||
|
const percentage = this.numberValue(requestArg.data?.percentage);
|
||||||
|
if (percentage === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (entityArg.platform === 'light') {
|
||||||
|
return { brightnessPercent: this.clampPercent(percentage), brightness: this.percentToBrightness(percentage), state: percentage > 0 };
|
||||||
|
}
|
||||||
|
return { percentage: this.clampPercent(percentage), mode: this.percentageToFanMode(percentage), state: percentage > 0 };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
const value = requestArg.data?.value;
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (entityArg.platform === 'cover' && typeof value === 'number') {
|
||||||
|
const position = this.clampPercent(value);
|
||||||
|
return { value, position, rawPosition: 100 - position };
|
||||||
|
}
|
||||||
|
if (entityArg.platform === 'fan' && typeof value === 'number') {
|
||||||
|
return { value, percentage: this.clampPercent(value), mode: this.percentageToFanMode(value), state: value > 0 };
|
||||||
|
}
|
||||||
|
if ((entityArg.platform === 'light' || entityArg.platform === 'switch') && typeof value === 'boolean') {
|
||||||
|
return { value, state: value };
|
||||||
|
}
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static commandFromPayload(resourceTypeArg: Exclude<TTradfriResourceType, 'gateway'>, resourceIdArg: string, entityArg: IIntegrationEntity, requestArg: IServiceCallRequest, payloadArg: Record<string, unknown>): ITradfriCommand {
|
||||||
|
const coapPayload = this.coapPayload(resourceTypeArg, entityArg.platform, payloadArg);
|
||||||
|
return {
|
||||||
|
type: `${entityArg.platform}.command`,
|
||||||
|
service: requestArg.service,
|
||||||
|
resourceType: resourceTypeArg,
|
||||||
|
resourceId: resourceIdArg,
|
||||||
|
platform: entityArg.platform,
|
||||||
|
deviceId: entityArg.deviceId,
|
||||||
|
entityId: entityArg.id,
|
||||||
|
uniqueId: entityArg.uniqueId,
|
||||||
|
payload: payloadArg,
|
||||||
|
coap: {
|
||||||
|
method: 'put',
|
||||||
|
path: [resourceTypeArg === 'group' ? ROOT_GROUPS : ROOT_DEVICES, resourceIdArg],
|
||||||
|
payload: coapPayload,
|
||||||
|
},
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coapPayload(resourceTypeArg: Exclude<TTradfriResourceType, 'gateway'>, platformArg: TEntityPlatform, payloadArg: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
if (typeof payloadArg.state === 'boolean') {
|
||||||
|
values[ATTR_DEVICE_STATE] = payloadArg.state ? 1 : 0;
|
||||||
|
}
|
||||||
|
const brightness = this.numberValue(payloadArg.brightness);
|
||||||
|
if (brightness !== undefined) {
|
||||||
|
values[ATTR_LIGHT_DIMMER] = this.clamp(brightness, 0, 254);
|
||||||
|
}
|
||||||
|
const rawPosition = this.numberValue(payloadArg.rawPosition);
|
||||||
|
if (platformArg === 'cover' && rawPosition !== undefined) {
|
||||||
|
return { [ATTR_START_BLINDS]: [{ [ATTR_BLIND_CURRENT_POSITION]: this.clamp(rawPosition, 0, 100) }] };
|
||||||
|
}
|
||||||
|
const mode = this.numberValue(payloadArg.mode);
|
||||||
|
if (platformArg === 'fan') {
|
||||||
|
return { [ROOT_AIR_PURIFIER]: [{ [ATTR_AIR_PURIFIER_MODE]: mode ?? (payloadArg.state === false ? 0 : 1) }] };
|
||||||
|
}
|
||||||
|
if (resourceTypeArg === 'group') {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
if (platformArg === 'switch') {
|
||||||
|
return { [ATTR_SWITCH_PLUG]: [values] };
|
||||||
|
}
|
||||||
|
return { [ATTR_LIGHT_CONTROL]: [values] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static gateway(snapshotArg: ITradfriSnapshot): ITradfriGateway {
|
||||||
|
return snapshotArg.gateway || { id: snapshotArg.host || 'configured', name: 'IKEA TRADFRI Gateway', model: 'E1526' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static gatewayIdentifier(snapshotArg: ITradfriSnapshot): string {
|
||||||
|
const gateway = this.gateway(snapshotArg);
|
||||||
|
return this.stringValue(gateway.id || this.value(gateway, ['9081']) || snapshotArg.host) || 'configured';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceId(snapshotArg: ITradfriSnapshot, deviceArg: ITradfriDevice, fallbackIndexArg = 0): string {
|
||||||
|
return `tradfri.device.${this.slug(this.gatewayIdentifier(snapshotArg))}.${this.slug(this.deviceResourceId(deviceArg, fallbackIndexArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupDeviceId(snapshotArg: ITradfriSnapshot, groupArg: ITradfriGroup, fallbackIndexArg = 0): string {
|
||||||
|
return `tradfri.group.${this.slug(this.gatewayIdentifier(snapshotArg))}.${this.slug(this.groupResourceId(groupArg, fallbackIndexArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceResourceId(deviceArg: ITradfriDevice, fallbackIndexArg = 0): string {
|
||||||
|
return this.stringValue(this.value(deviceArg, ['id', ATTR_ID])) || `device_${fallbackIndexArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupResourceId(groupArg: ITradfriGroup, fallbackIndexArg = 0): string {
|
||||||
|
return this.stringValue(this.value(groupArg, ['id', ATTR_ID])) || `group_${fallbackIndexArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(deviceArg: ITradfriDevice, fallbackArg: string): string {
|
||||||
|
return this.stringValue(this.value(deviceArg, ['name', ATTR_NAME])) || `IKEA TRADFRI ${fallbackArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupName(groupArg: ITradfriGroup, fallbackArg: string): string {
|
||||||
|
return this.stringValue(this.value(groupArg, ['name', ATTR_NAME])) || `IKEA TRADFRI Group ${fallbackArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceInfo(deviceArg: ITradfriDevice): ITradfriDeviceInfo {
|
||||||
|
const rawInfo = this.value(deviceArg, ['deviceInfo', 'device_info', ATTR_DEVICE_INFO]);
|
||||||
|
const info = this.isRecord(rawInfo) ? rawInfo : {};
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
manufacturer: this.stringValue(info.manufacturer ?? info[ATTR_DEVICE_MANUFACTURER]),
|
||||||
|
modelNumber: this.stringValue(info.modelNumber ?? info.model_number ?? info[ATTR_DEVICE_MODEL_NUMBER]),
|
||||||
|
serial: this.stringValue(info.serial ?? info[ATTR_DEVICE_SERIAL]),
|
||||||
|
firmwareVersion: this.stringValue(info.firmwareVersion ?? info.firmware_version ?? info[ATTR_DEVICE_FIRMWARE_VERSION]),
|
||||||
|
powerSource: this.numberValue(info.powerSource ?? info.power_source ?? info['6']),
|
||||||
|
batteryLevel: this.numberValue(info.batteryLevel ?? info.battery_level ?? info[ATTR_DEVICE_BATTERY]),
|
||||||
|
raw: info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static reachable(deviceArg: ITradfriDevice): boolean {
|
||||||
|
const value = this.value(deviceArg, ['reachable', ATTR_REACHABLE_STATE]);
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightControls(deviceArg: ITradfriDevice): ITradfriLight[] {
|
||||||
|
return this.arrayValue<ITradfriLight>(this.value(deviceArg, ['lightControl', 'light_control', ATTR_LIGHT_CONTROL]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static socketControls(deviceArg: ITradfriDevice): ITradfriSocket[] {
|
||||||
|
return this.arrayValue<ITradfriSocket>(this.value(deviceArg, ['socketControl', 'socket_control', 'outletControl', 'outlet_control', ATTR_SWITCH_PLUG]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static blindControls(deviceArg: ITradfriDevice): ITradfriBlind[] {
|
||||||
|
return this.arrayValue<ITradfriBlind>(this.value(deviceArg, ['blindControl', 'blind_control', 'coverControl', 'cover_control', ATTR_START_BLINDS]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static airPurifierControls(deviceArg: ITradfriDevice): ITradfriAirPurifier[] {
|
||||||
|
return this.arrayValue<ITradfriAirPurifier>(this.value(deviceArg, ['airPurifierControl', 'air_purifier_control', ROOT_AIR_PURIFIER]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorControls(deviceArg: ITradfriDevice): ITradfriSensor[] {
|
||||||
|
return [
|
||||||
|
...this.arrayValue<ITradfriSensor>(this.value(deviceArg, ['sensorControl', 'sensor_control', 'sensors', ATTR_SENSOR])),
|
||||||
|
...this.arrayValue<ITradfriSensor>(this.value(deviceArg, ['binarySensors', 'binary_sensors'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static lightBrightnessPercent(lightArg: ITradfriLight): number | undefined {
|
||||||
|
const percent = this.numberValue(this.value(lightArg, ['brightnessPercent', 'brightness_percent']));
|
||||||
|
if (percent !== undefined) {
|
||||||
|
return this.clampPercent(percent);
|
||||||
|
}
|
||||||
|
const raw = this.numberValue(this.value(lightArg, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER]));
|
||||||
|
return raw === undefined ? undefined : Math.round(this.clamp(raw, 0, 254) / 254 * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupBrightnessPercent(groupArg: ITradfriGroup): number | undefined {
|
||||||
|
const raw = this.numberValue(this.value(groupArg, ['dimmer', 'brightness', ATTR_LIGHT_DIMMER]));
|
||||||
|
return raw === undefined ? undefined : Math.round(this.clamp(raw, 0, 254) / 254 * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coverPosition(blindArg: ITradfriBlind): number {
|
||||||
|
const position = this.numberValue(this.value(blindArg, ['position', 'positionPercentage', 'position_percentage']));
|
||||||
|
if (position !== undefined) {
|
||||||
|
return this.clampPercent(position);
|
||||||
|
}
|
||||||
|
const rawPosition = this.numberValue(this.value(blindArg, ['currentCoverPosition', 'current_cover_position', 'rawPosition', 'raw_position', ATTR_BLIND_CURRENT_POSITION]));
|
||||||
|
return rawPosition === undefined ? 0 : this.clampPercent(100 - rawPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static airPurifierOn(fanArg: ITradfriAirPurifier): boolean {
|
||||||
|
const state = this.value(fanArg, ['state']);
|
||||||
|
if (typeof state === 'boolean') {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const mode = this.numberValue(this.value(fanArg, ['mode', ATTR_AIR_PURIFIER_MODE]));
|
||||||
|
return mode !== undefined ? mode > 0 : this.fanPercentage(fanArg) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static airPurifierAuto(fanArg: ITradfriAirPurifier): boolean {
|
||||||
|
return this.numberValue(this.value(fanArg, ['mode', ATTR_AIR_PURIFIER_MODE])) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fanPercentage(fanArg: ITradfriAirPurifier): number {
|
||||||
|
const percentage = this.numberValue(this.value(fanArg, ['percentage']));
|
||||||
|
if (percentage !== undefined) {
|
||||||
|
return this.clampPercent(percentage);
|
||||||
|
}
|
||||||
|
const speed = this.numberValue(this.value(fanArg, ['fanSpeed', 'fan_speed', ATTR_AIR_PURIFIER_FAN_SPEED]));
|
||||||
|
if (speed === undefined) {
|
||||||
|
return this.airPurifierOn(fanArg) ? 100 : 0;
|
||||||
|
}
|
||||||
|
return Math.max(Math.round((speed - 1) / 49 * 100), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static airQuality(fanArg: ITradfriAirPurifier): number | undefined {
|
||||||
|
const value = this.numberValue(this.value(fanArg, ['airQuality', 'air_quality', ATTR_AIR_PURIFIER_AIR_QUALITY]));
|
||||||
|
return value === 65535 ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorName(sensorArg: ITradfriSensor, indexArg: number): string {
|
||||||
|
return this.stringValue(this.value(sensorArg, ['name', 'type', 'deviceClass', 'device_class', ATTR_SENSOR_TYPE])) || `Sensor ${indexArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorValue(sensorArg: ITradfriSensor): unknown {
|
||||||
|
return this.value(sensorArg, ['value', 'state', ATTR_SENSOR_VALUE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorUnit(sensorArg: ITradfriSensor): string | undefined {
|
||||||
|
return this.stringValue(this.value(sensorArg, ['unit', 'unitOfMeasurement', 'unit_of_measurement', ATTR_SENSOR_UNIT]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorDeviceClass(sensorArg: ITradfriSensor): string | undefined {
|
||||||
|
return this.stringValue(this.value(sensorArg, ['deviceClass', 'device_class', 'type', ATTR_SENSOR_TYPE]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static binarySensor(sensorArg: ITradfriSensor): boolean {
|
||||||
|
if (sensorArg.binary === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const type = `${this.sensorDeviceClass(sensorArg) || ''} ${this.sensorName(sensorArg, 0)}`.toLowerCase();
|
||||||
|
if (type.includes('motion') || type.includes('presence') || type.includes('occupancy') || type.includes('contact') || type.includes('open')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return typeof this.sensorValue(sensorArg) === 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static binaryState(valueArg: unknown): 'on' | 'off' {
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
return ['on', 'open', 'true', '1', 'detected'].includes(valueArg.toLowerCase()) ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
return valueArg === true || valueArg === 1 ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupMemberIds(groupArg: ITradfriGroup): Array<string | number> {
|
||||||
|
const direct = this.value(groupArg, ['memberIds', 'member_ids']);
|
||||||
|
if (Array.isArray(direct)) {
|
||||||
|
return direct.filter((valueArg) => typeof valueArg === 'string' || typeof valueArg === 'number');
|
||||||
|
}
|
||||||
|
const members = this.value(groupArg, ['groupMembers', 'group_members', ATTR_GROUP_MEMBERS]);
|
||||||
|
if (this.isRecord(members)) {
|
||||||
|
const homeSmartLink = members[ATTR_HS_LINK];
|
||||||
|
if (this.isRecord(homeSmartLink) && Array.isArray(homeSmartLink[ATTR_ID])) {
|
||||||
|
return homeSmartLink[ATTR_ID].filter((valueArg) => typeof valueArg === 'string' || typeof valueArg === 'number');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static brightnessPayload(requestArg: IServiceCallRequest): Record<string, unknown> {
|
||||||
|
const rawBrightness = this.numberValue(requestArg.data?.brightness);
|
||||||
|
if (rawBrightness !== undefined) {
|
||||||
|
return { brightness: this.clamp(rawBrightness, 0, 254), brightnessPercent: Math.round(this.clamp(rawBrightness, 0, 254) / 254 * 100) };
|
||||||
|
}
|
||||||
|
const percent = this.numberValue(requestArg.data?.brightnessPct ?? requestArg.data?.brightness_pct ?? requestArg.data?.percentage);
|
||||||
|
if (percent !== undefined) {
|
||||||
|
return { brightness: this.percentToBrightness(percent), brightnessPercent: this.clampPercent(percent) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentageToFanMode(valueArg: number): number {
|
||||||
|
const percentage = this.clampPercent(valueArg);
|
||||||
|
if (percentage <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round(Math.max(2, (percentage / 100 * 49) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentToBrightness(valueArg: number): number {
|
||||||
|
return Math.round(this.clampPercent(valueArg) / 100 * 254);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static indexedFeatureId(prefixArg: string, indexArg: number): string {
|
||||||
|
return indexArg === 0 ? prefixArg : `${prefixArg}_${indexArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static indexedName(nameArg: string, indexArg: number): string {
|
||||||
|
return indexArg === 0 ? nameArg : `${nameArg} ${indexArg + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static indexedEntityName(nameArg: string, indexArg: number): string {
|
||||||
|
return indexArg === 0 ? nameArg : `${nameArg} ${indexArg + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entitySlug(deviceSlugArg: string, indexArg: number): string {
|
||||||
|
return indexArg === 0 ? deviceSlugArg : `${deviceSlugArg}_${indexArg + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static booleanState(valueArg: unknown): boolean {
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number') {
|
||||||
|
return valueArg === 1;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
return ['1', 'true', 'on'].includes(valueArg.toLowerCase());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static value(sourceArg: unknown, keysArg: string[]): unknown {
|
||||||
|
if (!this.isRecord(sourceArg)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const key of keysArg) {
|
||||||
|
if (sourceArg[key] !== undefined) {
|
||||||
|
return sourceArg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static arrayValue<TValue>(valueArg: unknown): TValue[] {
|
||||||
|
if (!valueArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return valueArg as TValue[];
|
||||||
|
}
|
||||||
|
if (this.isRecord(valueArg)) {
|
||||||
|
return [valueArg as TValue];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringValue(valueArg: unknown): string | undefined {
|
||||||
|
if (typeof valueArg === 'string' && valueArg) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clampPercent(valueArg: number): number {
|
||||||
|
return this.clamp(valueArg, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return JSON.stringify(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? null : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'tradfri';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,296 @@
|
|||||||
export interface IHomeAssistantTradfriConfig {
|
import type { IDiscoveryCandidate, TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for tradfri.
|
|
||||||
|
export const tradfriDefaultCoapDtlsPort = 5684;
|
||||||
|
|
||||||
|
export type TTradfriResourceType = 'gateway' | 'device' | 'group';
|
||||||
|
export type TTradfriEventType = 'snapshot_updated' | 'command_mapped' | 'command_failed' | 'state_changed' | 'error' | string;
|
||||||
|
|
||||||
|
export interface ITradfriConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
gatewayId?: string;
|
||||||
|
identity?: string;
|
||||||
|
securityCode?: string;
|
||||||
|
psk?: string;
|
||||||
|
key?: string;
|
||||||
|
gateway?: ITradfriGateway;
|
||||||
|
devices?: ITradfriDevice[];
|
||||||
|
groups?: ITradfriGroup[];
|
||||||
|
snapshot?: ITradfriSnapshot;
|
||||||
|
discoveryRecords?: ITradfriDiscoveryRecord[];
|
||||||
|
manualEntries?: ITradfriManualEntry[];
|
||||||
|
commandExecutor?: TTradfriCommandExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantTradfriConfig extends ITradfriConfig {}
|
||||||
|
|
||||||
|
export interface ITradfriGateway {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
firmware_version?: string;
|
||||||
|
homekitId?: string;
|
||||||
|
homekit_id?: string;
|
||||||
|
currentTime?: string | number;
|
||||||
|
current_time?: string | number;
|
||||||
|
commissioningMode?: number;
|
||||||
|
commissioning_mode?: number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITradfriDeviceInfo {
|
||||||
|
manufacturer?: string;
|
||||||
|
modelNumber?: string;
|
||||||
|
model_number?: string;
|
||||||
|
serial?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
firmware_version?: string;
|
||||||
|
powerSource?: number;
|
||||||
|
power_source?: number;
|
||||||
|
batteryLevel?: number;
|
||||||
|
battery_level?: number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriLight {
|
||||||
|
id?: string | number;
|
||||||
|
index?: number;
|
||||||
|
state?: boolean | number;
|
||||||
|
on?: boolean;
|
||||||
|
dimmer?: number;
|
||||||
|
brightness?: number;
|
||||||
|
brightnessPercent?: number;
|
||||||
|
brightness_percent?: number;
|
||||||
|
colorTemp?: number;
|
||||||
|
color_temp?: number;
|
||||||
|
colorMireds?: number;
|
||||||
|
color_mireds?: number;
|
||||||
|
colorHex?: string;
|
||||||
|
color_hex?: string;
|
||||||
|
hue?: number;
|
||||||
|
saturation?: number;
|
||||||
|
colorHue?: number;
|
||||||
|
color_hue?: number;
|
||||||
|
colorSaturation?: number;
|
||||||
|
color_saturation?: number;
|
||||||
|
xy?: [number, number];
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriSocket {
|
||||||
|
id?: string | number;
|
||||||
|
index?: number;
|
||||||
|
state?: boolean | number;
|
||||||
|
on?: boolean;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriBlind {
|
||||||
|
id?: string | number;
|
||||||
|
index?: number;
|
||||||
|
position?: number;
|
||||||
|
positionPercentage?: number;
|
||||||
|
position_percentage?: number;
|
||||||
|
currentCoverPosition?: number;
|
||||||
|
current_cover_position?: number;
|
||||||
|
rawPosition?: number;
|
||||||
|
raw_position?: number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriSensor {
|
||||||
|
id?: string | number;
|
||||||
|
index?: number;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
device_class?: string;
|
||||||
|
unit?: string;
|
||||||
|
unitOfMeasurement?: string;
|
||||||
|
unit_of_measurement?: string;
|
||||||
|
value?: string | number | boolean | null;
|
||||||
|
state?: string | number | boolean | null;
|
||||||
|
binary?: boolean;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriBinarySensor extends ITradfriSensor {
|
||||||
|
binary: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriAirPurifier {
|
||||||
|
id?: string | number;
|
||||||
|
index?: number;
|
||||||
|
state?: boolean | number;
|
||||||
|
mode?: number;
|
||||||
|
fanSpeed?: number;
|
||||||
|
fan_speed?: number;
|
||||||
|
percentage?: number;
|
||||||
|
airQuality?: number;
|
||||||
|
air_quality?: number;
|
||||||
|
filterLifetimeRemaining?: number;
|
||||||
|
filter_lifetime_remaining?: number;
|
||||||
|
filterLifetimeTotal?: number;
|
||||||
|
filter_lifetime_total?: number;
|
||||||
|
controlsLocked?: boolean | number;
|
||||||
|
controls_locked?: boolean | number;
|
||||||
|
ledsOff?: boolean | number;
|
||||||
|
leds_off?: boolean | number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriDevice {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
applicationType?: number;
|
||||||
|
application_type?: number;
|
||||||
|
reachable?: boolean | number;
|
||||||
|
lastSeen?: string | number;
|
||||||
|
last_seen?: string | number;
|
||||||
|
createdAt?: string | number;
|
||||||
|
created_at?: string | number;
|
||||||
|
deviceInfo?: ITradfriDeviceInfo;
|
||||||
|
device_info?: ITradfriDeviceInfo;
|
||||||
|
lightControl?: ITradfriLight[];
|
||||||
|
light_control?: ITradfriLight[];
|
||||||
|
socketControl?: ITradfriSocket[];
|
||||||
|
socket_control?: ITradfriSocket[];
|
||||||
|
outletControl?: ITradfriSocket[];
|
||||||
|
outlet_control?: ITradfriSocket[];
|
||||||
|
blindControl?: ITradfriBlind[];
|
||||||
|
blind_control?: ITradfriBlind[];
|
||||||
|
coverControl?: ITradfriBlind[];
|
||||||
|
cover_control?: ITradfriBlind[];
|
||||||
|
sensorControl?: ITradfriSensor[];
|
||||||
|
sensor_control?: ITradfriSensor[];
|
||||||
|
sensors?: ITradfriSensor[];
|
||||||
|
binarySensors?: ITradfriBinarySensor[];
|
||||||
|
binary_sensors?: ITradfriBinarySensor[];
|
||||||
|
airPurifierControl?: ITradfriAirPurifier[];
|
||||||
|
air_purifier_control?: ITradfriAirPurifier[];
|
||||||
|
signalRepeaterControl?: unknown[];
|
||||||
|
signal_repeater_control?: unknown[];
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriGroup {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
state?: boolean | number;
|
||||||
|
dimmer?: number;
|
||||||
|
brightness?: number;
|
||||||
|
colorHex?: string;
|
||||||
|
color_hex?: string;
|
||||||
|
memberIds?: Array<string | number>;
|
||||||
|
member_ids?: Array<string | number>;
|
||||||
|
groupMembers?: Record<string, unknown>;
|
||||||
|
group_members?: Record<string, unknown>;
|
||||||
|
moodId?: string | number;
|
||||||
|
mood_id?: string | number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriSnapshot {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
gateway?: ITradfriGateway;
|
||||||
|
devices: ITradfriDevice[];
|
||||||
|
groups: ITradfriGroup[];
|
||||||
|
states?: ITradfriState[];
|
||||||
|
events?: ITradfriEvent[];
|
||||||
|
updatedAt?: string | number;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriState {
|
||||||
|
resourceType: TTradfriResourceType;
|
||||||
|
resourceId: string;
|
||||||
|
featureId: string;
|
||||||
|
value: unknown;
|
||||||
|
updatedAt?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriCommand {
|
||||||
|
type: string;
|
||||||
|
service: string;
|
||||||
|
resourceType: Exclude<TTradfriResourceType, 'gateway'>;
|
||||||
|
resourceId: string;
|
||||||
|
platform?: TEntityPlatform;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
coap: {
|
||||||
|
method: 'put' | 'post' | 'get';
|
||||||
|
path: string[];
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
target?: {
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TTradfriCommandExecutor = (
|
||||||
|
commandArg: ITradfriCommand
|
||||||
|
) => Promise<ITradfriCommandResult | unknown> | ITradfriCommandResult | unknown;
|
||||||
|
|
||||||
|
export interface ITradfriEvent {
|
||||||
|
type: TTradfriEventType;
|
||||||
|
timestamp?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
command?: ITradfriCommand;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
gatewayId?: string;
|
||||||
|
gateway_id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
identity?: string;
|
||||||
|
securityCode?: string;
|
||||||
|
security_code?: string;
|
||||||
|
psk?: string;
|
||||||
|
key?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITradfriDiscoveryRecord extends ITradfriManualEntry {
|
||||||
|
source?: IDiscoveryCandidate['source'] | string;
|
||||||
|
type?: string;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './wiz.classes.client.js';
|
||||||
|
export * from './wiz.classes.configflow.js';
|
||||||
export * from './wiz.classes.integration.js';
|
export * from './wiz.classes.integration.js';
|
||||||
|
export * from './wiz.discovery.js';
|
||||||
|
export * from './wiz.mapper.js';
|
||||||
export * from './wiz.types.js';
|
export * from './wiz.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import type {
|
||||||
|
IWizClientCommand,
|
||||||
|
IWizCommandResult,
|
||||||
|
IWizConfig,
|
||||||
|
IWizDeviceInfo,
|
||||||
|
IWizEvent,
|
||||||
|
IWizPilotPatch,
|
||||||
|
IWizPilotState,
|
||||||
|
IWizSnapshot,
|
||||||
|
IWizSnapshotDevice,
|
||||||
|
IWizUdpCommand,
|
||||||
|
IWizUdpResponse,
|
||||||
|
} from './wiz.types.js';
|
||||||
|
import { wizDefaultPort } from './wiz.types.js';
|
||||||
|
import { WizMapper } from './wiz.mapper.js';
|
||||||
|
|
||||||
|
type TWizEventHandler = (eventArg: IWizEvent) => void;
|
||||||
|
|
||||||
|
export class WizClient {
|
||||||
|
private readonly events: IWizEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TWizEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IWizConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IWizSnapshot> {
|
||||||
|
const host = this.host();
|
||||||
|
if (!host) {
|
||||||
|
return WizMapper.toSnapshot(this.config, false, this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pilot = await this.getPilot();
|
||||||
|
const deviceInfo = await this.liveDeviceInfo(pilot).catch(() => this.staticDeviceInfo(pilot));
|
||||||
|
const device: IWizSnapshotDevice = {
|
||||||
|
host,
|
||||||
|
port: this.port(),
|
||||||
|
mac: pilot.mac || deviceInfo.mac || this.config.mac,
|
||||||
|
name: this.config.name || deviceInfo.name,
|
||||||
|
deviceInfo,
|
||||||
|
pilot,
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
return WizMapper.toSnapshot({ ...this.config, snapshot: undefined, devices: [device], manualEntries: undefined }, true, this.events);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.emit({ type: 'error', data: { message }, timestamp: Date.now() });
|
||||||
|
return WizMapper.toSnapshot(this.config, false, this.events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TWizEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPilot(): Promise<IWizPilotState> {
|
||||||
|
const response = await this.sendUdp<IWizPilotState>({ method: 'getPilot', params: {} });
|
||||||
|
const result = this.record(response.result) ? response.result as IWizPilotState : {};
|
||||||
|
this.emit({ type: 'pilot', data: result, timestamp: Date.now() });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPilot(payloadArg: IWizPilotPatch): Promise<IWizUdpResponse<Record<string, unknown>>> {
|
||||||
|
return this.sendUdp<Record<string, unknown>>({ method: 'setPilot', params: payloadArg as Record<string, unknown> });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSystemConfig(): Promise<IWizUdpResponse<Record<string, unknown>>> {
|
||||||
|
return this.sendUdp<Record<string, unknown>>({ method: 'getSystemConfig', params: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IWizClientCommand): Promise<IWizCommandResult> {
|
||||||
|
let result: IWizCommandResult;
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||||
|
} else if (!this.host()) {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: this.unsupportedLiveControlMessage(),
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const response = await this.setPilot(commandArg.payload);
|
||||||
|
result = { success: true, data: response.result || response };
|
||||||
|
} catch (error) {
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: result.success ? 'command_mapped' : 'command_failed',
|
||||||
|
command: commandArg,
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
deviceId: commandArg.deviceId,
|
||||||
|
entityId: commandArg.entityId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async liveDeviceInfo(pilotArg: IWizPilotState): Promise<IWizDeviceInfo> {
|
||||||
|
const systemConfig = await this.getSystemConfig();
|
||||||
|
const result = this.record(systemConfig.result) ? systemConfig.result : {};
|
||||||
|
return this.staticDeviceInfo(pilotArg, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private staticDeviceInfo(pilotArg?: IWizPilotState, systemArg: Record<string, unknown> = {}): IWizDeviceInfo {
|
||||||
|
const moduleName = this.stringValue(systemArg.moduleName) || this.config.deviceInfo?.moduleName;
|
||||||
|
const model = this.config.deviceInfo?.model || moduleName;
|
||||||
|
const isSocket = this.config.deviceInfo?.isSocket ?? this.textContainsSocket(model, moduleName, systemArg.typeId);
|
||||||
|
return {
|
||||||
|
...this.config.deviceInfo,
|
||||||
|
host: this.host(),
|
||||||
|
port: this.port(),
|
||||||
|
mac: this.stringValue(systemArg.mac) || pilotArg?.mac || this.config.mac || this.config.deviceInfo?.mac,
|
||||||
|
name: this.config.name || this.config.deviceInfo?.name,
|
||||||
|
manufacturer: this.config.deviceInfo?.manufacturer || 'WiZ',
|
||||||
|
model,
|
||||||
|
moduleName,
|
||||||
|
fwVersion: this.stringValue(systemArg.fwVersion) || this.config.deviceInfo?.fwVersion,
|
||||||
|
typeId: this.stringValue(systemArg.typeId) || this.numberValue(systemArg.typeId) || this.config.deviceInfo?.typeId,
|
||||||
|
isSocket,
|
||||||
|
features: {
|
||||||
|
light: !isSocket,
|
||||||
|
switch: isSocket,
|
||||||
|
brightness: !isSocket || typeof pilotArg?.dimming === 'number',
|
||||||
|
color: ['r', 'g', 'b'].every((keyArg) => typeof pilotArg?.[keyArg] === 'number') || this.textContains(moduleName, 'rgb'),
|
||||||
|
colorTemp: typeof pilotArg?.temp === 'number' || this.textContains(moduleName, 'tw'),
|
||||||
|
effect: typeof pilotArg?.sceneId === 'number' || typeof pilotArg?.schdPsetId === 'number',
|
||||||
|
fan: typeof pilotArg?.fanState === 'number',
|
||||||
|
power: typeof pilotArg?.pc === 'number',
|
||||||
|
occupancy: pilotArg?.src === 'pir',
|
||||||
|
...this.config.deviceInfo?.features,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendUdp<TResult>(commandArg: IWizUdpCommand): Promise<IWizUdpResponse<TResult>> {
|
||||||
|
const host = this.host();
|
||||||
|
if (!host) {
|
||||||
|
throw new Error(this.unsupportedLiveControlMessage());
|
||||||
|
}
|
||||||
|
const port = this.port();
|
||||||
|
const timeoutMs = this.timeoutMs();
|
||||||
|
const { createSocket } = await import('node:dgram');
|
||||||
|
const payload = Buffer.from(JSON.stringify(commandArg));
|
||||||
|
|
||||||
|
return new Promise<IWizUdpResponse<TResult>>((resolve, reject) => {
|
||||||
|
const socket = createSocket('udp4');
|
||||||
|
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const timer of timers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
socket.removeAllListeners();
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// The socket may already be closed after an early UDP error.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (errorArg: Error | undefined, responseArg?: IWizUdpResponse<TResult>) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (errorArg) {
|
||||||
|
reject(errorArg);
|
||||||
|
} else {
|
||||||
|
resolve(responseArg || {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.send(payload, port, host, (errorArg) => {
|
||||||
|
if (errorArg) {
|
||||||
|
finish(errorArg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('error', (errorArg) => finish(errorArg));
|
||||||
|
socket.on('message', (messageArg) => {
|
||||||
|
let response: IWizUdpResponse<TResult>;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(messageArg.toString('utf8')) as IWizUdpResponse<TResult>;
|
||||||
|
} catch (error) {
|
||||||
|
finish(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.method && response.method !== commandArg.method) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
finish(new Error(response.error.message || `WiZ UDP error ${response.error.code ?? 'unknown'}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit({ type: 'udp_response', data: response, timestamp: Date.now() });
|
||||||
|
finish(undefined, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
send();
|
||||||
|
timers.push(setTimeout(send, 750));
|
||||||
|
timers.push(setTimeout(send, 2250));
|
||||||
|
timers.push(setTimeout(() => finish(new Error(`Timed out waiting for WiZ ${commandArg.method} response from ${host}:${port}.`)), timeoutMs));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IWizEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: IWizClientCommand): IWizCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IWizCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private host(): string | undefined {
|
||||||
|
return this.config.host || this.config.manualEntries?.find((entryArg) => entryArg.host)?.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private port(): number {
|
||||||
|
const manualPort = this.config.manualEntries?.find((entryArg) => entryArg.host)?.port;
|
||||||
|
return this.config.port || manualPort || wizDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeoutMs(): number {
|
||||||
|
return typeof this.config.timeoutMs === 'number' && this.config.timeoutMs > 0 ? this.config.timeoutMs : 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedLiveControlMessage(): string {
|
||||||
|
return 'WiZ live UDP control requires a configured host. Snapshot-only WiZ configs are read-only unless commandExecutor is provided.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private textContainsSocket(...valuesArg: unknown[]): boolean {
|
||||||
|
return valuesArg.some((valueArg) => this.textContains(valueArg, 'socket') || this.textContains(valueArg, 'plug'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private textContains(valueArg: unknown, fragmentArg: string): boolean {
|
||||||
|
return typeof valueArg === 'string' && valueArg.toLowerCase().includes(fragmentArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IWizConfig, IWizDeviceInfo, IWizPilotState, IWizSnapshot } from './wiz.types.js';
|
||||||
|
import { wizDefaultPort } from './wiz.types.js';
|
||||||
|
|
||||||
|
export class WizConfigFlow implements IConfigFlow<IWizConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IWizConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
const defaults = this.defaultsFromCandidate(candidateArg);
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect WiZ',
|
||||||
|
description: 'Configure the WiZ device host. Local control uses UDP JSON on port 38899 by default.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host or IP address', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'UDP port (default 38899)', type: 'number' },
|
||||||
|
{ name: 'mac', label: 'MAC address', type: 'text' },
|
||||||
|
{ name: 'name', label: 'Device name', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || defaults.host;
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'Invalid WiZ config', error: 'WiZ setup requires a host or IP address.' };
|
||||||
|
}
|
||||||
|
const port = this.numberValue(valuesArg.port) || defaults.port || wizDefaultPort;
|
||||||
|
const mac = this.stringValue(valuesArg.mac) || defaults.mac;
|
||||||
|
const name = this.stringValue(valuesArg.name) || defaults.name;
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'WiZ configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
mac,
|
||||||
|
name,
|
||||||
|
deviceInfo: {
|
||||||
|
...defaults.deviceInfo,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
mac: mac || defaults.deviceInfo?.mac,
|
||||||
|
name: name || defaults.deviceInfo?.name,
|
||||||
|
},
|
||||||
|
pilot: defaults.pilot,
|
||||||
|
snapshot: defaults.snapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { host?: string; port?: number; mac?: string; name?: string; deviceInfo?: IWizDeviceInfo; pilot?: IWizPilotState; snapshot?: IWizSnapshot } {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const deviceInfo = this.isRecord(metadata.deviceInfo) ? metadata.deviceInfo as unknown as IWizDeviceInfo : undefined;
|
||||||
|
const pilot = this.isRecord(metadata.pilot) ? metadata.pilot as unknown as IWizPilotState : undefined;
|
||||||
|
const snapshot = this.isRecord(metadata.snapshot) ? metadata.snapshot as unknown as IWizSnapshot : undefined;
|
||||||
|
return {
|
||||||
|
host: candidateArg.host,
|
||||||
|
port: candidateArg.port || wizDefaultPort,
|
||||||
|
mac: candidateArg.macAddress,
|
||||||
|
name: candidateArg.name,
|
||||||
|
deviceInfo,
|
||||||
|
pilot,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const parsed = Number(valueArg);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,80 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { WizClient } from './wiz.classes.client.js';
|
||||||
|
import { WizConfigFlow } from './wiz.classes.configflow.js';
|
||||||
|
import { createWizDiscoveryDescriptor } from './wiz.discovery.js';
|
||||||
|
import { WizMapper } from './wiz.mapper.js';
|
||||||
|
import type { IWizConfig } from './wiz.types.js';
|
||||||
|
|
||||||
export class HomeAssistantWizIntegration extends DescriptorOnlyIntegration {
|
export class WizIntegration extends BaseIntegration<IWizConfig> {
|
||||||
constructor() {
|
public readonly domain = 'wiz';
|
||||||
super({
|
public readonly displayName = 'WiZ';
|
||||||
domain: "wiz",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "WiZ",
|
public readonly discoveryDescriptor = createWizDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new WizConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/wiz",
|
upstreamPath: 'homeassistant/components/wiz',
|
||||||
"upstreamDomain": "wiz",
|
upstreamDomain: 'wiz',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['pywizlight==0.6.3'],
|
||||||
"pywizlight==0.6.3"
|
dependencies: ['network'],
|
||||||
|
afterDependencies: [] as string[],
|
||||||
|
codeowners: ['@sbidy', '@arturpragacz'],
|
||||||
|
documentation: 'https://www.home-assistant.io/integrations/wiz',
|
||||||
|
protocolDocumentation: 'https://docs.pro.wizconnected.com/#introduction',
|
||||||
|
dhcp: [
|
||||||
|
{ registeredDevices: true },
|
||||||
|
{ macaddress: 'A8BB50*' },
|
||||||
|
{ macaddress: 'D8A011*' },
|
||||||
|
{ macaddress: '444F8E*' },
|
||||||
|
{ macaddress: '6C2990*' },
|
||||||
|
{ hostname: 'wiz_*' },
|
||||||
],
|
],
|
||||||
"dependencies": [
|
};
|
||||||
"network"
|
|
||||||
],
|
public async setup(configArg: IWizConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"afterDependencies": [],
|
void contextArg;
|
||||||
"codeowners": [
|
return new WizRuntime(new WizClient(configArg));
|
||||||
"@sbidy",
|
}
|
||||||
"@arturpragacz"
|
|
||||||
]
|
public async destroy(): Promise<void> {}
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
export class HomeAssistantWizIntegration extends WizIntegration {}
|
||||||
|
|
||||||
|
class WizRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'wiz';
|
||||||
|
|
||||||
|
constructor(private readonly client: WizClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return WizMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return WizMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(WizMapper.toIntegrationEvent(eventArg)));
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = WizMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `WiZ service ${requestArg.domain}.${requestArg.service} has no native setPilot mapping for the target.` };
|
||||||
|
}
|
||||||
|
const result = await this.client.sendCommand(command);
|
||||||
|
return { success: result.success, error: result.error, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type {
|
||||||
|
IDiscoveryCandidate,
|
||||||
|
IDiscoveryContext,
|
||||||
|
IDiscoveryMatch,
|
||||||
|
IDiscoveryMatcher,
|
||||||
|
IDiscoveryProbe,
|
||||||
|
IDiscoveryProbeResult,
|
||||||
|
IDiscoveryValidator,
|
||||||
|
} from '../../core/types.js';
|
||||||
|
import type { IWizManualEntry, IWizMdnsRecord, IWizUdpDiscoveryRecord, IWizUdpResponse } from './wiz.types.js';
|
||||||
|
import { wizDefaultPort } from './wiz.types.js';
|
||||||
|
|
||||||
|
const wizMacPrefixes = ['a8bb50', 'd8a011', '444f8e', '6c2990'];
|
||||||
|
const wizUdpMethods = new Set(['getPilot', 'setPilot', 'syncPilot', 'syncSystemConfig', 'getSystemConfig', 'registration']);
|
||||||
|
|
||||||
|
export class WizUdpDiscoveryProbe implements IDiscoveryProbe {
|
||||||
|
public id = 'wiz-udp-discovery-probe';
|
||||||
|
public source = 'custom' as const;
|
||||||
|
public description = 'Discover WiZ devices by UDP registration broadcast on port 38899.';
|
||||||
|
|
||||||
|
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
||||||
|
if (contextArg.abortSignal?.aborted) {
|
||||||
|
return { candidates: [] };
|
||||||
|
}
|
||||||
|
return { candidates: await this.discover(1200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
|
||||||
|
const { createSocket } = await import('node:dgram');
|
||||||
|
const message = Buffer.from(JSON.stringify({
|
||||||
|
method: 'registration',
|
||||||
|
params: {
|
||||||
|
phoneMac: 'AAAAAAAAAAAA',
|
||||||
|
register: false,
|
||||||
|
phoneIp: '1.2.3.4',
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const matcher = new WizUdpMatcher();
|
||||||
|
const candidates: IDiscoveryCandidate[] = [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = createSocket({ type: 'udp4', reuseAddr: true });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// The discovery socket may already be closed after an OS error.
|
||||||
|
}
|
||||||
|
resolve(candidates);
|
||||||
|
}, timeoutMsArg);
|
||||||
|
|
||||||
|
socket.on('message', async (dataArg, remoteArg) => {
|
||||||
|
let response: IWizUdpResponse<Record<string, unknown>> | undefined;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(dataArg.toString('utf8')) as IWizUdpResponse<Record<string, unknown>>;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = await matcher.matches({ host: remoteArg.address, port: remoteArg.port, response });
|
||||||
|
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
|
||||||
|
candidates.push(match.candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore discovery socket close races.
|
||||||
|
}
|
||||||
|
resolve(candidates);
|
||||||
|
});
|
||||||
|
socket.bind(() => {
|
||||||
|
socket.setBroadcast(true);
|
||||||
|
socket.send(message, wizDefaultPort, '255.255.255.255');
|
||||||
|
setTimeout(() => socket.send(message, wizDefaultPort, '255.255.255.255'), 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WizMdnsMatcher implements IDiscoveryMatcher<IWizMdnsRecord> {
|
||||||
|
public id = 'wiz-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize WiZ mDNS or hostname advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IWizMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const mac = this.normalizeMac(this.txt(txt, 'mac') || this.txt(txt, 'macaddress') || this.txt(txt, 'mac_address'));
|
||||||
|
const name = this.name(recordArg, txt);
|
||||||
|
const text = [type, recordArg.name, recordArg.hostname, host, name, this.txt(txt, 'manufacturer'), this.txt(txt, 'model')]
|
||||||
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
const matched = Boolean(mac && this.isWizMac(mac)) || /(^|[._-])wiz([._-]|$)/i.test(text) || text.includes('wizconnected');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a WiZ advertisement.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: mac ? 'certain' : host ? 'high' : 'medium',
|
||||||
|
reason: mac ? 'mDNS record contains a WiZ MAC address.' : 'mDNS record contains WiZ hostname or TXT metadata.',
|
||||||
|
normalizedDeviceId: mac || recordArg.name || host,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
id: mac || recordArg.name || host,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || wizDefaultPort,
|
||||||
|
name: name || 'WiZ',
|
||||||
|
manufacturer: 'WiZ',
|
||||||
|
model: this.txt(txt, 'model') || this.txt(txt, 'moduleName') || 'WiZ device',
|
||||||
|
macAddress: mac,
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
||||||
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private name(recordArg: IWizMdnsRecord, txtArg: Record<string, string | undefined>): string | undefined {
|
||||||
|
return this.txt(txtArg, 'name') || this.txt(txtArg, 'friendly_name') || recordArg.name?.split('._', 1)[0] || recordArg.hostname?.replace(/\.local\.?$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWizMac(valueArg: string): boolean {
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return wizMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WizUdpMatcher implements IDiscoveryMatcher<IWizUdpDiscoveryRecord> {
|
||||||
|
public id = 'wiz-udp-match';
|
||||||
|
public source = 'custom' as const;
|
||||||
|
public description = 'Recognize WiZ UDP JSON discovery responses.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IWizUdpDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const response = recordArg.response;
|
||||||
|
const result = response?.result || recordArg.result || {};
|
||||||
|
const method = response?.method || recordArg.method;
|
||||||
|
const host = recordArg.host || recordArg.address || recordArg.ip || recordArg.ip_address;
|
||||||
|
const mac = this.normalizeMac(recordArg.mac || recordArg.mac_address || this.stringValue(result.mac));
|
||||||
|
const matched = Boolean(mac) || Boolean(method && wizUdpMethods.has(method)) || recordArg.metadata?.wiz === true;
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'UDP record is not a WiZ JSON response.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: mac && host ? 'certain' : mac || host ? 'high' : 'medium',
|
||||||
|
reason: mac ? 'UDP response contains a WiZ MAC address.' : 'UDP response method matches WiZ JSON protocol.',
|
||||||
|
normalizedDeviceId: mac || recordArg.name || host,
|
||||||
|
candidate: {
|
||||||
|
source: 'custom',
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
id: mac || recordArg.name || host,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || wizDefaultPort,
|
||||||
|
name: recordArg.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'),
|
||||||
|
manufacturer: 'WiZ',
|
||||||
|
model: 'WiZ UDP JSON device',
|
||||||
|
macAddress: mac,
|
||||||
|
metadata: {
|
||||||
|
...recordArg.metadata,
|
||||||
|
discoveryProtocol: 'udp',
|
||||||
|
method,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shortMac(valueArg?: string): string {
|
||||||
|
return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WizManualMatcher implements IDiscoveryMatcher<IWizManualEntry> {
|
||||||
|
public id = 'wiz-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual WiZ setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IWizManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const mac = this.normalizeMac(inputArg.mac || inputArg.macAddress || inputArg.deviceInfo?.mac);
|
||||||
|
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, inputArg.deviceInfo?.manufacturer, inputArg.deviceInfo?.model, inputArg.deviceInfo?.moduleName]
|
||||||
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || mac || inputArg.metadata?.wiz || text.includes('wiz'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain WiZ setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host && mac ? 'certain' : inputArg.host || mac ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start WiZ setup.',
|
||||||
|
normalizedDeviceId: mac || inputArg.id || inputArg.host,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
id: inputArg.id || mac || inputArg.host,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || wizDefaultPort,
|
||||||
|
name: inputArg.name || inputArg.deviceInfo?.name || (mac ? `WiZ ${this.shortMac(mac)}` : 'WiZ'),
|
||||||
|
manufacturer: inputArg.manufacturer || inputArg.deviceInfo?.manufacturer || 'WiZ',
|
||||||
|
model: inputArg.model || inputArg.deviceInfo?.model || inputArg.deviceInfo?.moduleName || 'WiZ device',
|
||||||
|
macAddress: mac,
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
discoveryProtocol: 'manual',
|
||||||
|
deviceInfo: inputArg.deviceInfo,
|
||||||
|
pilot: inputArg.pilot,
|
||||||
|
snapshot: inputArg.snapshot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private shortMac(valueArg?: string): string {
|
||||||
|
return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WizCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'wiz-candidate-validator';
|
||||||
|
public description = 'Validate WiZ candidates from mDNS, UDP, DHCP, and manual setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const mac = this.normalizeMac(candidateArg.macAddress);
|
||||||
|
const compactMac = (mac || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
const name = (candidateArg.name || '').toLowerCase();
|
||||||
|
const model = (candidateArg.model || '').toLowerCase();
|
||||||
|
const manufacturer = (candidateArg.manufacturer || '').toLowerCase();
|
||||||
|
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : '';
|
||||||
|
const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
|
||||||
|
const macMatched = wizMacPrefixes.some((prefixArg) => compactMac.startsWith(prefixArg));
|
||||||
|
const hostMatched = Boolean(candidateArg.host && /^wiz[_-]/i.test(candidateArg.host));
|
||||||
|
const textMatched = name.includes('wiz') || model.includes('wiz') || manufacturer.includes('wiz') || mdnsType.includes('wiz');
|
||||||
|
const matched = candidateArg.integrationDomain === 'wiz'
|
||||||
|
|| macMatched
|
||||||
|
|| hostMatched
|
||||||
|
|| textMatched
|
||||||
|
|| candidateArg.port === wizDefaultPort
|
||||||
|
|| metadata.wiz === true
|
||||||
|
|| discoveryProtocol === 'udp'
|
||||||
|
|| discoveryProtocol === 'manual';
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && (candidateArg.integrationDomain === 'wiz' || macMatched) && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has WiZ metadata or local UDP port information.' : 'Candidate is not WiZ.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: mac || candidateArg.id || candidateArg.host,
|
||||||
|
metadata: matched ? { macMatched, hostMatched, discoveryProtocol } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWizDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'wiz', displayName: 'WiZ' })
|
||||||
|
.addProbe(new WizUdpDiscoveryProbe())
|
||||||
|
.addMatcher(new WizMdnsMatcher())
|
||||||
|
.addMatcher(new WizUdpMatcher())
|
||||||
|
.addMatcher(new WizManualMatcher())
|
||||||
|
.addValidator(new WizCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type {
|
||||||
|
IWizButtonRecord,
|
||||||
|
IWizClientCommand,
|
||||||
|
IWizConfig,
|
||||||
|
IWizDeviceFeatures,
|
||||||
|
IWizDeviceInfo,
|
||||||
|
IWizEntityRecord,
|
||||||
|
IWizEvent,
|
||||||
|
IWizManualEntry,
|
||||||
|
IWizPilotPatch,
|
||||||
|
IWizPilotState,
|
||||||
|
IWizSensorRecord,
|
||||||
|
IWizSnapshot,
|
||||||
|
IWizSnapshotDevice,
|
||||||
|
} from './wiz.types.js';
|
||||||
|
import { wizDefaultPort } from './wiz.types.js';
|
||||||
|
|
||||||
|
const wizScenes: Array<{ id: number; name: string }> = [
|
||||||
|
{ id: 35, name: 'Alarm' },
|
||||||
|
{ id: 10, name: 'Bedtime' },
|
||||||
|
{ id: 29, name: 'Candlelight' },
|
||||||
|
{ id: 27, name: 'Christmas' },
|
||||||
|
{ id: 6, name: 'Cozy' },
|
||||||
|
{ id: 13, name: 'Cool white' },
|
||||||
|
{ id: 26, name: 'Club' },
|
||||||
|
{ id: 12, name: 'Daylight' },
|
||||||
|
{ id: 33, name: 'Diwali' },
|
||||||
|
{ id: 23, name: 'Deep dive' },
|
||||||
|
{ id: 22, name: 'Fall' },
|
||||||
|
{ id: 5, name: 'Fireplace' },
|
||||||
|
{ id: 7, name: 'Forest' },
|
||||||
|
{ id: 15, name: 'Focus' },
|
||||||
|
{ id: 30, name: 'Golden white' },
|
||||||
|
{ id: 28, name: 'Halloween' },
|
||||||
|
{ id: 24, name: 'Jungle' },
|
||||||
|
{ id: 25, name: 'Mojito' },
|
||||||
|
{ id: 14, name: 'Night light' },
|
||||||
|
{ id: 1, name: 'Ocean' },
|
||||||
|
{ id: 4, name: 'Party' },
|
||||||
|
{ id: 31, name: 'Pulse' },
|
||||||
|
{ id: 8, name: 'Pastel colors' },
|
||||||
|
{ id: 19, name: 'Plantgrowth' },
|
||||||
|
{ id: 2, name: 'Romance' },
|
||||||
|
{ id: 16, name: 'Relax' },
|
||||||
|
{ id: 36, name: 'Snowy sky' },
|
||||||
|
{ id: 3, name: 'Sunset' },
|
||||||
|
{ id: 20, name: 'Spring' },
|
||||||
|
{ id: 21, name: 'Summer' },
|
||||||
|
{ id: 32, name: 'Steampunk' },
|
||||||
|
{ id: 17, name: 'True colors' },
|
||||||
|
{ id: 18, name: 'TV time' },
|
||||||
|
{ id: 34, name: 'White' },
|
||||||
|
{ id: 9, name: 'Wake-up' },
|
||||||
|
{ id: 11, name: 'Warm white' },
|
||||||
|
{ id: 1000, name: 'Rhythm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wizSceneNamesById = new Map(wizScenes.map((sceneArg) => [sceneArg.id, sceneArg.name]));
|
||||||
|
const wizSceneIdsByName = new Map(wizScenes.map((sceneArg) => [sceneArg.name.toLowerCase(), sceneArg.id]));
|
||||||
|
|
||||||
|
const wizButtonSources: Record<string, string> = {
|
||||||
|
wfa1: 'on',
|
||||||
|
wfa2: 'off',
|
||||||
|
wfa3: 'night',
|
||||||
|
wfa8: 'decrease_brightness',
|
||||||
|
wfa9: 'increase_brightness',
|
||||||
|
wfa16: '1',
|
||||||
|
wfa17: '2',
|
||||||
|
wfa18: '3',
|
||||||
|
wfa19: '4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pilotPatchKeys = new Set(['state', 'sceneId', 'temp', 'dimming', 'r', 'g', 'b', 'c', 'w', 'speed', 'ratio', 'fanState', 'fanMode', 'fanSpeed', 'fanRevrs']);
|
||||||
|
|
||||||
|
export class WizMapper {
|
||||||
|
public static readonly sceneNames = wizScenes.map((sceneArg) => sceneArg.name);
|
||||||
|
|
||||||
|
public static toSnapshot(configArg: IWizConfig, connectedArg?: boolean, eventsArg: IWizEvent[] = []): IWizSnapshot {
|
||||||
|
const source = configArg.snapshot;
|
||||||
|
const devices: IWizSnapshotDevice[] = [
|
||||||
|
...(source?.devices || []),
|
||||||
|
...(configArg.devices || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of configArg.manualEntries || []) {
|
||||||
|
if (entry.snapshot) {
|
||||||
|
devices.push(...entry.snapshot.devices);
|
||||||
|
} else {
|
||||||
|
devices.push(this.deviceFromManualEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devices.length && (configArg.host || configArg.mac || configArg.name || configArg.deviceInfo || configArg.pilot)) {
|
||||||
|
devices.push({
|
||||||
|
host: configArg.host,
|
||||||
|
port: configArg.port || wizDefaultPort,
|
||||||
|
mac: configArg.mac || configArg.deviceInfo?.mac || configArg.pilot?.mac,
|
||||||
|
name: configArg.name || configArg.deviceInfo?.name,
|
||||||
|
deviceInfo: configArg.deviceInfo,
|
||||||
|
pilot: configArg.pilot,
|
||||||
|
available: connectedArg ?? Boolean(configArg.pilot || source?.connected),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: connectedArg ?? source?.connected ?? false,
|
||||||
|
host: configArg.host || source?.host,
|
||||||
|
port: configArg.port || source?.port || wizDefaultPort,
|
||||||
|
devices,
|
||||||
|
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDevices(snapshotArg: IWizSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
return snapshotArg.devices.map((deviceArg) => this.toDevice(deviceArg, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IWizSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const usedIds = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const device of snapshotArg.devices) {
|
||||||
|
const info = this.deviceInfo(device);
|
||||||
|
const pilot = device.pilot || {};
|
||||||
|
const features = this.features(device);
|
||||||
|
const deviceId = this.deviceId(device);
|
||||||
|
const baseName = this.deviceName(device);
|
||||||
|
const isSocket = this.isSocket(device);
|
||||||
|
const hasLight = !isSocket && features.light !== false;
|
||||||
|
|
||||||
|
if (hasLight) {
|
||||||
|
entities.push(this.entity('light', baseName, deviceId, this.uniqueId('light', device), pilot.state ? 'on' : 'off', usedIds, {
|
||||||
|
...this.baseAttributes(device),
|
||||||
|
brightness: pilot.dimming,
|
||||||
|
brightness255: this.percentToByte(pilot.dimming),
|
||||||
|
colorTemperatureKelvin: pilot.temp,
|
||||||
|
rgbColor: this.rgb(pilot),
|
||||||
|
sceneId: this.sceneId(pilot),
|
||||||
|
effect: this.sceneName(pilot),
|
||||||
|
effectList: this.sceneNames,
|
||||||
|
writable: true,
|
||||||
|
}, device.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSocket || features.switch) {
|
||||||
|
entities.push(this.entity('switch', baseName, deviceId, this.uniqueId('switch', device), pilot.state ? 'on' : 'off', usedIds, {
|
||||||
|
...this.baseAttributes(device),
|
||||||
|
writable: true,
|
||||||
|
}, device.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (features.fan || pilot.fanState !== undefined) {
|
||||||
|
entities.push(this.entity('fan', `${baseName} Fan`, deviceId, this.uniqueId('fan', device), pilot.fanState ? 'on' : 'off', usedIds, {
|
||||||
|
...this.baseAttributes(device),
|
||||||
|
percentage: this.fanPercentage(device, pilot),
|
||||||
|
fanMode: pilot.fanMode,
|
||||||
|
fanSpeed: pilot.fanSpeed,
|
||||||
|
fanReverse: pilot.fanRevrs,
|
||||||
|
writable: true,
|
||||||
|
}, device.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof pilot.rssi === 'number') {
|
||||||
|
entities.push(this.sensorEntity(device, { key: 'rssi', name: `${baseName} RSSI`, value: pilot.rssi, unit: 'dBm', deviceClass: 'signal_strength' }, usedIds));
|
||||||
|
}
|
||||||
|
if (typeof pilot.pc === 'number' || features.power) {
|
||||||
|
entities.push(this.sensorEntity(device, { key: 'power', name: `${baseName} Power`, value: typeof pilot.pc === 'number' ? pilot.pc / 1000 : undefined, unit: 'W', deviceClass: 'power' }, usedIds));
|
||||||
|
}
|
||||||
|
if (features.occupancy || pilot.src === 'pir') {
|
||||||
|
entities.push(this.sensorEntity(device, { key: 'occupancy', name: `${baseName} Occupancy`, platform: 'binary_sensor', value: pilot.src === 'pir' ? pilot.state : undefined, deviceClass: 'occupancy' }, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sensor of device.sensors || []) {
|
||||||
|
entities.push(this.sensorEntity(device, sensor, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (features.effect || pilot.speed !== undefined) {
|
||||||
|
entities.push(this.numberEntity(device, 'effect_speed', `${baseName} Effect Speed`, pilot.speed, usedIds, { min: 10, max: 200, step: 1, wizPilotKey: 'speed' }));
|
||||||
|
}
|
||||||
|
if (features.dualHead || pilot.ratio !== undefined) {
|
||||||
|
entities.push(this.numberEntity(device, 'dual_head_ratio', `${baseName} Dual Head Ratio`, pilot.ratio, usedIds, { min: 0, max: 100, step: 1, wizPilotKey: 'ratio' }));
|
||||||
|
}
|
||||||
|
if (features.effect || this.sceneId(pilot) !== undefined) {
|
||||||
|
entities.push(this.entity('select', `${baseName} Effect`, deviceId, `${this.uniqueId('select', device)}_effect`, this.sceneName(pilot) || 'None', usedIds, {
|
||||||
|
...this.baseAttributes(device),
|
||||||
|
options: this.sceneNames,
|
||||||
|
wizPilotKey: 'sceneId',
|
||||||
|
writable: true,
|
||||||
|
}, device.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const button of this.buttons(device)) {
|
||||||
|
entities.push(this.buttonEntity(device, button, usedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of device.entities || []) {
|
||||||
|
entities.push(this.explicitEntity(device, entity, usedIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IWizEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IWizSnapshot, requestArg: IServiceCallRequest): IWizClientCommand | undefined {
|
||||||
|
if (requestArg.domain === 'wiz' && requestArg.service === 'set_pilot' && this.isRecord(requestArg.data?.payload)) {
|
||||||
|
const targetEntity = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
return {
|
||||||
|
type: 'setPilot',
|
||||||
|
service: requestArg.service,
|
||||||
|
deviceId: targetEntity?.deviceId || requestArg.target.deviceId,
|
||||||
|
entityId: targetEntity?.id || requestArg.target.entityId,
|
||||||
|
payload: requestArg.data.payload as IWizPilotPatch,
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
if (!target) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const device = snapshotArg.devices.find((deviceArg) => this.deviceId(deviceArg) === target.deviceId);
|
||||||
|
const payload = this.payloadForService(target, requestArg, device);
|
||||||
|
if (!payload || !Object.keys(payload).length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'setPilot',
|
||||||
|
service: requestArg.service,
|
||||||
|
deviceId: target.deviceId,
|
||||||
|
entityId: target.id,
|
||||||
|
payload,
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static sceneIdFromName(valueArg: string): number | undefined {
|
||||||
|
return wizSceneIdsByName.get(valueArg.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static toDevice(deviceArg: IWizSnapshotDevice, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
const info = this.deviceInfo(deviceArg);
|
||||||
|
const pilot = deviceArg.pilot || {};
|
||||||
|
const featuresInfo = this.features(deviceArg);
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg },
|
||||||
|
];
|
||||||
|
const isSocket = this.isSocket(deviceArg);
|
||||||
|
const hasLight = !isSocket && featuresInfo.light !== false;
|
||||||
|
|
||||||
|
if (hasLight) {
|
||||||
|
features.push({ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'on', pilot.state ?? false, updatedAtArg);
|
||||||
|
if (featuresInfo.brightness !== false || typeof pilot.dimming === 'number') {
|
||||||
|
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' });
|
||||||
|
this.pushDeviceState(state, 'brightness', pilot.dimming, updatedAtArg);
|
||||||
|
}
|
||||||
|
if (featuresInfo.colorTemp || typeof pilot.temp === 'number') {
|
||||||
|
features.push({ id: 'color_temperature', capability: 'light', name: 'Color Temperature', readable: true, writable: true, unit: 'K' });
|
||||||
|
this.pushDeviceState(state, 'color_temperature', pilot.temp, updatedAtArg);
|
||||||
|
}
|
||||||
|
if (featuresInfo.color || this.rgb(pilot)) {
|
||||||
|
features.push({ id: 'rgb', capability: 'light', name: 'RGB Color', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'rgb', this.rgbRecord(pilot), updatedAtArg);
|
||||||
|
}
|
||||||
|
if (featuresInfo.effect || this.sceneId(pilot) !== undefined) {
|
||||||
|
features.push({ id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'effect', this.sceneName(pilot) || null, updatedAtArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSocket || featuresInfo.switch) {
|
||||||
|
features.push({ id: 'switch', capability: 'switch', name: 'Power', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'switch', pilot.state ?? false, updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (featuresInfo.fan || pilot.fanState !== undefined) {
|
||||||
|
features.push({ id: 'fan', capability: 'fan', name: 'Fan', readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, 'fan', Boolean(pilot.fanState), updatedAtArg);
|
||||||
|
features.push({ id: 'fan_speed', capability: 'fan', name: 'Fan Speed', readable: true, writable: true, unit: '%' });
|
||||||
|
this.pushDeviceState(state, 'fan_speed', this.fanPercentage(deviceArg, pilot), updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof pilot.rssi === 'number') {
|
||||||
|
features.push({ id: 'rssi', capability: 'sensor', name: 'RSSI', readable: true, writable: false, unit: 'dBm' });
|
||||||
|
this.pushDeviceState(state, 'rssi', pilot.rssi, updatedAtArg);
|
||||||
|
}
|
||||||
|
if (typeof pilot.pc === 'number' || featuresInfo.power) {
|
||||||
|
features.push({ id: 'power', capability: 'energy', name: 'Power', readable: true, writable: false, unit: 'W' });
|
||||||
|
this.pushDeviceState(state, 'power', typeof pilot.pc === 'number' ? pilot.pc / 1000 : undefined, updatedAtArg);
|
||||||
|
}
|
||||||
|
if (featuresInfo.occupancy || pilot.src === 'pir') {
|
||||||
|
features.push({ id: 'occupancy', capability: 'sensor', name: 'Occupancy', readable: true, writable: false });
|
||||||
|
this.pushDeviceState(state, 'occupancy', pilot.src === 'pir' ? Boolean(pilot.state) : undefined, updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sensor of deviceArg.sensors || []) {
|
||||||
|
features.push({ id: sensor.key, capability: 'sensor', name: sensor.name || this.title(sensor.key), readable: true, writable: Boolean(sensor.writable), unit: sensor.unit });
|
||||||
|
this.pushDeviceState(state, sensor.key, this.deviceStateValue(sensor.value), updatedAtArg);
|
||||||
|
}
|
||||||
|
for (const button of this.buttons(deviceArg)) {
|
||||||
|
features.push({ id: `button_${button.key}`, capability: 'switch', name: button.name || this.title(button.key), readable: true, writable: true });
|
||||||
|
this.pushDeviceState(state, `button_${button.key}`, String(button.value ?? button.lastPressedAt ?? 'idle'), updatedAtArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.deviceId(deviceArg),
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
name: this.deviceName(deviceArg),
|
||||||
|
protocol: 'unknown',
|
||||||
|
manufacturer: info.manufacturer || 'WiZ',
|
||||||
|
model: info.model || info.moduleName || String(info.bulbType || 'WiZ device'),
|
||||||
|
online: deviceArg.available !== false,
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
...deviceArg.metadata,
|
||||||
|
protocol: 'wiz-udp-json',
|
||||||
|
host: deviceArg.host || info.host,
|
||||||
|
port: deviceArg.port || info.port || wizDefaultPort,
|
||||||
|
mac: this.mac(deviceArg),
|
||||||
|
moduleName: info.moduleName,
|
||||||
|
fwVersion: info.fwVersion,
|
||||||
|
typeId: info.typeId,
|
||||||
|
features: featuresInfo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static payloadForService(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest, deviceArg?: IWizSnapshotDevice): IWizPilotPatch | undefined {
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
return entityArg.platform === 'fan' || requestArg.domain === 'fan' ? { fanState: 0 } : { state: false };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
const payload: IWizPilotPatch = entityArg.platform === 'fan' || requestArg.domain === 'fan' ? { fanState: 1 } : { state: true };
|
||||||
|
this.applyServiceData(payload, requestArg, entityArg, deviceArg);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_percentage' || requestArg.service === 'set_brightness') {
|
||||||
|
const percentage = this.percentageFromData(requestArg.data, requestArg.service);
|
||||||
|
if (percentage === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (entityArg.platform === 'fan' || requestArg.domain === 'fan') {
|
||||||
|
return percentage <= 0 ? { fanState: 0 } : { fanState: 1, fanSpeed: this.percentageToFanSpeed(deviceArg, percentage) };
|
||||||
|
}
|
||||||
|
return percentage <= 0 ? { state: false } : { state: true, dimming: this.clamp(Math.round(percentage), 10, 100) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
return this.setValuePayload(entityArg, requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'select_option' || requestArg.service === 'select_effect') {
|
||||||
|
const option = this.stringFromData(requestArg.data, ['option', 'effect', 'value']);
|
||||||
|
const sceneId = typeof option === 'string' ? this.sceneIdFromName(option) : undefined;
|
||||||
|
return sceneId === undefined ? undefined : { state: true, sceneId };
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'fan') {
|
||||||
|
if (requestArg.service === 'set_direction') {
|
||||||
|
const direction = this.stringFromData(requestArg.data, ['direction']);
|
||||||
|
return direction ? { fanRevrs: direction === 'reverse' ? 1 : 0 } : undefined;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_preset_mode') {
|
||||||
|
const presetMode = this.stringFromData(requestArg.data, ['preset_mode', 'presetMode']);
|
||||||
|
return presetMode === 'breeze' ? { fanMode: 2 } : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static applyServiceData(payloadArg: IWizPilotPatch, requestArg: IServiceCallRequest, entityArg: IIntegrationEntity, deviceArg?: IWizSnapshotDevice): void {
|
||||||
|
const brightness = this.percentageFromData(requestArg.data, 'turn_on');
|
||||||
|
if (brightness !== undefined && entityArg.platform !== 'fan' && requestArg.domain !== 'fan') {
|
||||||
|
payloadArg.dimming = this.clamp(Math.round(brightness), 10, 100);
|
||||||
|
}
|
||||||
|
if (brightness !== undefined && (entityArg.platform === 'fan' || requestArg.domain === 'fan')) {
|
||||||
|
payloadArg.fanSpeed = this.percentageToFanSpeed(deviceArg, brightness);
|
||||||
|
}
|
||||||
|
const kelvin = this.kelvinFromData(requestArg.data);
|
||||||
|
if (kelvin !== undefined) {
|
||||||
|
payloadArg.temp = kelvin;
|
||||||
|
}
|
||||||
|
const rgb = this.rgbFromData(requestArg.data, 'rgb_color') || this.rgbFromData(requestArg.data, 'rgb');
|
||||||
|
if (rgb) {
|
||||||
|
payloadArg.r = rgb[0];
|
||||||
|
payloadArg.g = rgb[1];
|
||||||
|
payloadArg.b = rgb[2];
|
||||||
|
}
|
||||||
|
const rgbw = this.rgbFromData(requestArg.data, 'rgbw_color');
|
||||||
|
if (rgbw) {
|
||||||
|
payloadArg.r = rgbw[0];
|
||||||
|
payloadArg.g = rgbw[1];
|
||||||
|
payloadArg.b = rgbw[2];
|
||||||
|
payloadArg.w = rgbw[3];
|
||||||
|
}
|
||||||
|
const rgbww = this.rgbFromData(requestArg.data, 'rgbww_color');
|
||||||
|
if (rgbww) {
|
||||||
|
payloadArg.r = rgbww[0];
|
||||||
|
payloadArg.g = rgbww[1];
|
||||||
|
payloadArg.b = rgbww[2];
|
||||||
|
payloadArg.c = rgbww[3];
|
||||||
|
payloadArg.w = rgbww[4];
|
||||||
|
}
|
||||||
|
const effect = this.stringFromData(requestArg.data, ['effect', 'option']);
|
||||||
|
const sceneId = effect ? this.sceneIdFromName(effect) : this.numberFromData(requestArg.data, ['sceneId', 'scene_id']);
|
||||||
|
if (sceneId !== undefined) {
|
||||||
|
payloadArg.sceneId = sceneId;
|
||||||
|
}
|
||||||
|
const speed = this.numberFromData(requestArg.data, ['speed']);
|
||||||
|
if (speed !== undefined) {
|
||||||
|
payloadArg.speed = this.clamp(Math.round(speed), 10, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static setValuePayload(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): IWizPilotPatch | undefined {
|
||||||
|
const value = this.valueFromData(requestArg.data, ['value', 'state']);
|
||||||
|
const field = this.stringFromData(requestArg.data, ['field', 'attribute', 'key']) || (typeof entityArg.attributes?.wizPilotKey === 'string' ? entityArg.attributes.wizPilotKey : undefined);
|
||||||
|
if (value === undefined || !field || !pilotPatchKeys.has(field)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (field === 'speed') {
|
||||||
|
return typeof value === 'number' ? { speed: this.clamp(Math.round(value), 10, 200) } : undefined;
|
||||||
|
}
|
||||||
|
if (field === 'ratio') {
|
||||||
|
return typeof value === 'number' ? { ratio: this.clamp(Math.round(value), 0, 100) } : undefined;
|
||||||
|
}
|
||||||
|
if (field === 'sceneId' && typeof value === 'string') {
|
||||||
|
const sceneId = this.sceneIdFromName(value);
|
||||||
|
return sceneId === undefined ? undefined : { state: true, sceneId };
|
||||||
|
}
|
||||||
|
return { [field]: value } as IWizPilotPatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetEntity(snapshotArg: IWizSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||||
|
}
|
||||||
|
if (requestArg.target.deviceId) {
|
||||||
|
return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain)
|
||||||
|
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && Boolean(entityArg.attributes?.writable))
|
||||||
|
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId);
|
||||||
|
}
|
||||||
|
return entities.find((entityArg) => entityArg.platform === requestArg.domain && Boolean(entityArg.attributes?.writable));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceFromManualEntry(entryArg: IWizManualEntry): IWizSnapshotDevice {
|
||||||
|
return {
|
||||||
|
id: entryArg.id,
|
||||||
|
host: entryArg.host,
|
||||||
|
port: entryArg.port || wizDefaultPort,
|
||||||
|
mac: entryArg.mac || entryArg.macAddress || entryArg.deviceInfo?.mac,
|
||||||
|
name: entryArg.name || entryArg.deviceInfo?.name,
|
||||||
|
deviceInfo: {
|
||||||
|
...entryArg.deviceInfo,
|
||||||
|
host: entryArg.host,
|
||||||
|
port: entryArg.port || wizDefaultPort,
|
||||||
|
mac: entryArg.mac || entryArg.macAddress || entryArg.deviceInfo?.mac,
|
||||||
|
name: entryArg.name || entryArg.deviceInfo?.name,
|
||||||
|
manufacturer: entryArg.manufacturer || entryArg.deviceInfo?.manufacturer,
|
||||||
|
model: entryArg.model || entryArg.deviceInfo?.model,
|
||||||
|
},
|
||||||
|
pilot: entryArg.pilot,
|
||||||
|
available: Boolean(entryArg.pilot),
|
||||||
|
metadata: entryArg.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static explicitEntity(deviceArg: IWizSnapshotDevice, entityArg: IWizEntityRecord, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
const platform = this.corePlatform(entityArg.platform);
|
||||||
|
const name = entityArg.name || this.deviceName(deviceArg);
|
||||||
|
return this.entity(platform, name, this.deviceId(deviceArg), entityArg.uniqueId || `${this.uniqueId(platform, deviceArg)}_${entityArg.key || this.slug(name)}`, this.entityState(entityArg.state, platform), usedIdsArg, {
|
||||||
|
...this.baseAttributes(deviceArg),
|
||||||
|
...entityArg.attributes,
|
||||||
|
wizPilotKey: entityArg.key,
|
||||||
|
writable: entityArg.writable,
|
||||||
|
}, entityArg.available !== false && deviceArg.available !== false, entityArg.entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorEntity(deviceArg: IWizSnapshotDevice, sensorArg: IWizSensorRecord, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
const platform = sensorArg.platform || 'sensor';
|
||||||
|
const state = platform === 'binary_sensor' ? sensorArg.value ? 'on' : 'off' : sensorArg.value ?? 'unknown';
|
||||||
|
return this.entity(platform, sensorArg.name || `${this.deviceName(deviceArg)} ${this.title(sensorArg.key)}`, this.deviceId(deviceArg), `${this.uniqueId(platform, deviceArg)}_${this.slug(sensorArg.key)}`, state, usedIdsArg, {
|
||||||
|
...this.baseAttributes(deviceArg),
|
||||||
|
wizSensorKey: sensorArg.key,
|
||||||
|
wizPilotKey: sensorArg.writable ? sensorArg.key : undefined,
|
||||||
|
deviceClass: sensorArg.deviceClass,
|
||||||
|
unit: sensorArg.unit,
|
||||||
|
writable: sensorArg.writable,
|
||||||
|
}, sensorArg.available !== false && deviceArg.available !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberEntity(deviceArg: IWizSnapshotDevice, keyArg: string, nameArg: string, valueArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>): IIntegrationEntity {
|
||||||
|
return this.entity('number', nameArg, this.deviceId(deviceArg), `${this.uniqueId('number', deviceArg)}_${this.slug(keyArg)}`, valueArg ?? 'unknown', usedIdsArg, {
|
||||||
|
...this.baseAttributes(deviceArg),
|
||||||
|
...attributesArg,
|
||||||
|
writable: true,
|
||||||
|
}, deviceArg.available !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buttonEntity(deviceArg: IWizSnapshotDevice, buttonArg: IWizButtonRecord, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||||
|
const name = buttonArg.name || `${this.deviceName(deviceArg)} ${this.title(buttonArg.key)}`;
|
||||||
|
return this.entity('button', name, this.deviceId(deviceArg), `${this.uniqueId('button', deviceArg)}_${this.slug(buttonArg.key)}`, buttonArg.value ?? 'idle', usedIdsArg, {
|
||||||
|
...this.baseAttributes(deviceArg),
|
||||||
|
wizButtonKey: buttonArg.key,
|
||||||
|
lastPressedAt: buttonArg.lastPressedAt,
|
||||||
|
writable: true,
|
||||||
|
}, buttonArg.available !== false && deviceArg.available !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean, explicitEntityIdArg?: string): IIntegrationEntity {
|
||||||
|
return {
|
||||||
|
id: explicitEntityIdArg || this.entityId(platformArg, nameArg, usedIdsArg),
|
||||||
|
uniqueId: uniqueIdArg,
|
||||||
|
integrationDomain: 'wiz',
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: platformArg,
|
||||||
|
name: nameArg,
|
||||||
|
state: stateArg,
|
||||||
|
attributes: attributesArg,
|
||||||
|
available: availableArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buttons(deviceArg: IWizSnapshotDevice): IWizButtonRecord[] {
|
||||||
|
const buttons = [...(deviceArg.buttons || [])];
|
||||||
|
const source = typeof deviceArg.pilot?.src === 'string' ? deviceArg.pilot.src : undefined;
|
||||||
|
if (source && wizButtonSources[source]) {
|
||||||
|
buttons.push({ key: wizButtonSources[source], name: `${this.deviceName(deviceArg)} Button ${this.title(wizButtonSources[source])}`, value: source });
|
||||||
|
}
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceInfo(deviceArg: IWizSnapshotDevice): IWizDeviceInfo {
|
||||||
|
return {
|
||||||
|
...deviceArg.deviceInfo,
|
||||||
|
id: deviceArg.deviceInfo?.id || deviceArg.id,
|
||||||
|
host: deviceArg.host || deviceArg.deviceInfo?.host,
|
||||||
|
port: deviceArg.port || deviceArg.deviceInfo?.port || wizDefaultPort,
|
||||||
|
mac: deviceArg.mac || deviceArg.deviceInfo?.mac || deviceArg.pilot?.mac,
|
||||||
|
name: deviceArg.name || deviceArg.deviceInfo?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static features(deviceArg: IWizSnapshotDevice): IWizDeviceFeatures {
|
||||||
|
const info = this.deviceInfo(deviceArg);
|
||||||
|
const pilot = deviceArg.pilot || {};
|
||||||
|
const lowerType = String(info.bulbType || info.model || info.moduleName || '').toLowerCase();
|
||||||
|
const isSocket = this.isSocket(deviceArg);
|
||||||
|
return {
|
||||||
|
light: !isSocket,
|
||||||
|
switch: isSocket,
|
||||||
|
brightness: typeof pilot.dimming === 'number' || !isSocket,
|
||||||
|
color: ['r', 'g', 'b'].every((keyArg) => typeof pilot[keyArg] === 'number') || lowerType.includes('rgb'),
|
||||||
|
colorTemp: typeof pilot.temp === 'number' || lowerType.includes('tw') || lowerType.includes('tunable'),
|
||||||
|
effect: typeof pilot.sceneId === 'number' || typeof pilot.schdPsetId === 'number' || info.features?.effect,
|
||||||
|
fan: typeof pilot.fanState === 'number' || info.features?.fan,
|
||||||
|
power: typeof pilot.pc === 'number' || info.powerMonitoring || info.features?.power,
|
||||||
|
occupancy: pilot.src === 'pir' || info.features?.occupancy,
|
||||||
|
button: Boolean(deviceArg.buttons?.length) || Boolean(pilot.src && wizButtonSources[String(pilot.src)]) || info.features?.button,
|
||||||
|
dualHead: typeof pilot.ratio === 'number' || info.features?.dualHead,
|
||||||
|
...info.features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isSocket(deviceArg: IWizSnapshotDevice): boolean {
|
||||||
|
const info = this.deviceInfo(deviceArg);
|
||||||
|
const text = [info.bulbType, info.model, info.moduleName, info.typeId].filter((valueArg) => valueArg !== undefined).join(' ').toLowerCase();
|
||||||
|
return info.isSocket === true || info.features?.switch === true || text.includes('socket') || text.includes('plug');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceId(deviceArg: IWizSnapshotDevice): string {
|
||||||
|
return `wiz.device.${this.slug(deviceArg.id || this.mac(deviceArg) || deviceArg.host || this.deviceName(deviceArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueId(platformArg: string, deviceArg: IWizSnapshotDevice): string {
|
||||||
|
return `wiz_${platformArg}_${this.slug(this.mac(deviceArg) || deviceArg.id || deviceArg.host || this.deviceName(deviceArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(deviceArg: IWizSnapshotDevice): string {
|
||||||
|
const info = this.deviceInfo(deviceArg);
|
||||||
|
return info.name || deviceArg.name || (this.mac(deviceArg) ? `WiZ ${this.shortMac(this.mac(deviceArg))}` : 'WiZ device');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mac(deviceArg: IWizSnapshotDevice): string | undefined {
|
||||||
|
return this.normalizeMac(deviceArg.mac || deviceArg.deviceInfo?.mac || deviceArg.pilot?.mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static baseAttributes(deviceArg: IWizSnapshotDevice): Record<string, unknown> {
|
||||||
|
const info = this.deviceInfo(deviceArg);
|
||||||
|
return {
|
||||||
|
wizDeviceId: this.deviceId(deviceArg),
|
||||||
|
wizHost: info.host,
|
||||||
|
wizPort: info.port || wizDefaultPort,
|
||||||
|
wizMac: this.mac(deviceArg),
|
||||||
|
moduleName: info.moduleName,
|
||||||
|
fwVersion: info.fwVersion,
|
||||||
|
model: info.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sceneId(pilotArg: IWizPilotState): number | undefined {
|
||||||
|
if (typeof pilotArg.schdPsetId === 'number') {
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
return typeof pilotArg.sceneId === 'number' && pilotArg.sceneId > 0 ? pilotArg.sceneId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sceneName(pilotArg: IWizPilotState): string | undefined {
|
||||||
|
const sceneId = this.sceneId(pilotArg);
|
||||||
|
return sceneId === undefined ? undefined : wizSceneNamesById.get(sceneId) || `Scene ${sceneId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fanPercentage(deviceArg: IWizSnapshotDevice, pilotArg: IWizPilotState): number | undefined {
|
||||||
|
if (typeof pilotArg.fanSpeed !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const max = this.deviceInfo(deviceArg).fanSpeedRange || 6;
|
||||||
|
return this.clamp(Math.round(pilotArg.fanSpeed / max * 100), 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentageToFanSpeed(deviceArg: IWizSnapshotDevice | undefined, percentageArg: number): number {
|
||||||
|
const max = deviceArg ? this.deviceInfo(deviceArg).fanSpeedRange || 6 : 6;
|
||||||
|
return this.clamp(Math.ceil(this.clamp(percentageArg, 0, 100) / 100 * max), 1, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentToByte(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' ? this.clamp(Math.round(valueArg / 100 * 255), 0, 255) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static percentageFromData(dataArg: Record<string, unknown> | undefined, serviceArg: string): number | undefined {
|
||||||
|
const pct = this.numberFromData(dataArg, ['percentage', 'brightness_pct']);
|
||||||
|
if (pct !== undefined) {
|
||||||
|
return this.clamp(pct, 0, 100);
|
||||||
|
}
|
||||||
|
const brightness = this.numberFromData(dataArg, serviceArg === 'set_brightness' ? ['brightness', 'value'] : ['brightness']);
|
||||||
|
return brightness === undefined ? undefined : this.clamp(Math.round(brightness / 255 * 100), 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static kelvinFromData(dataArg: Record<string, unknown> | undefined): number | undefined {
|
||||||
|
const direct = this.numberFromData(dataArg, ['color_temp_kelvin', 'kelvin', 'temp', 'color_temperature']);
|
||||||
|
if (direct !== undefined) {
|
||||||
|
return Math.round(direct);
|
||||||
|
}
|
||||||
|
const mired = this.numberFromData(dataArg, ['color_temp', 'color_temp_mired']);
|
||||||
|
return mired && mired > 0 ? Math.round(1000000 / mired) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rgb(pilotArg: IWizPilotState): number[] | undefined {
|
||||||
|
return typeof pilotArg.r === 'number' && typeof pilotArg.g === 'number' && typeof pilotArg.b === 'number'
|
||||||
|
? [pilotArg.r, pilotArg.g, pilotArg.b]
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rgbRecord(pilotArg: IWizPilotState): Record<string, unknown> | undefined {
|
||||||
|
const rgb = this.rgb(pilotArg);
|
||||||
|
return rgb ? { r: rgb[0], g: rgb[1], b: rgb[2], c: pilotArg.c, w: pilotArg.w } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rgbFromData(dataArg: Record<string, unknown> | undefined, keyArg: string): number[] | undefined {
|
||||||
|
const value = dataArg?.[keyArg];
|
||||||
|
if (!Array.isArray(value) || value.length < 3) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const numbers = value.map((valueArg) => typeof valueArg === 'number' && Number.isFinite(valueArg) ? this.clamp(Math.round(valueArg), 0, 255) : undefined);
|
||||||
|
return numbers.every((valueArg) => valueArg !== undefined) ? numbers as number[] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static valueFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): unknown {
|
||||||
|
for (const key of keysArg) {
|
||||||
|
if (dataArg && key in dataArg) {
|
||||||
|
return dataArg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): number | undefined {
|
||||||
|
const value = this.valueFromData(dataArg, keysArg);
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): string | undefined {
|
||||||
|
const value = this.valueFromData(dataArg, keysArg);
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static corePlatform(platformArg: TEntityPlatform | string): TEntityPlatform {
|
||||||
|
const platform = platformArg.toLowerCase();
|
||||||
|
const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update'];
|
||||||
|
return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityState(valueArg: unknown, platformArg: TEntityPlatform): unknown {
|
||||||
|
if (platformArg === 'light' || platformArg === 'switch' || platformArg === 'fan' || platformArg === 'binary_sensor') {
|
||||||
|
return typeof valueArg === 'boolean' ? valueArg ? 'on' : 'off' : valueArg ?? 'unknown';
|
||||||
|
}
|
||||||
|
return valueArg ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static pushDeviceState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? null : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map<string, number>): string {
|
||||||
|
const base = `${platformArg}.${this.slug(nameArg)}`;
|
||||||
|
const count = usedIdsArg.get(base) || 0;
|
||||||
|
usedIdsArg.set(base, count + 1);
|
||||||
|
return count ? `${base}_${count + 1}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static shortMac(valueArg?: string): string {
|
||||||
|
return (valueArg || '').replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static title(valueArg: string): string {
|
||||||
|
return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'wiz';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,265 @@
|
|||||||
export interface IHomeAssistantWizConfig {
|
import type { IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for wiz.
|
|
||||||
|
export const wizDefaultPort = 38899;
|
||||||
|
|
||||||
|
export type TWizBulbType = 'rgb' | 'tw' | 'dw' | 'socket' | 'fan' | 'unknown' | string;
|
||||||
|
export type TWizDiscoveryProtocol = 'mdns' | 'udp' | 'manual';
|
||||||
|
|
||||||
|
export interface IWizConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
mac?: string;
|
||||||
|
name?: string;
|
||||||
|
deviceInfo?: IWizDeviceInfo;
|
||||||
|
pilot?: IWizPilotState;
|
||||||
|
snapshot?: IWizSnapshot;
|
||||||
|
devices?: IWizSnapshotDevice[];
|
||||||
|
manualEntries?: IWizManualEntry[];
|
||||||
|
events?: IWizEvent[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
commandExecutor?: TWizCommandExecutor;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantWizConfig extends IWizConfig {}
|
||||||
|
|
||||||
|
export interface IWizDeviceFeatures {
|
||||||
|
light?: boolean;
|
||||||
|
switch?: boolean;
|
||||||
|
brightness?: boolean;
|
||||||
|
color?: boolean;
|
||||||
|
colorTemp?: boolean;
|
||||||
|
effect?: boolean;
|
||||||
|
fan?: boolean;
|
||||||
|
fanReverse?: boolean;
|
||||||
|
fanBreezeMode?: boolean;
|
||||||
|
power?: boolean;
|
||||||
|
occupancy?: boolean;
|
||||||
|
button?: boolean;
|
||||||
|
dualHead?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizKelvinRange {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizDeviceInfo {
|
||||||
|
id?: string;
|
||||||
|
mac?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
moduleName?: string;
|
||||||
|
fwVersion?: string;
|
||||||
|
typeId?: number | string;
|
||||||
|
bulbType?: TWizBulbType;
|
||||||
|
isSocket?: boolean;
|
||||||
|
whiteChannels?: number;
|
||||||
|
kelvinRange?: IWizKelvinRange;
|
||||||
|
fanSpeedRange?: number;
|
||||||
|
powerMonitoring?: boolean;
|
||||||
|
features?: IWizDeviceFeatures;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizPilotState {
|
||||||
|
mac?: string;
|
||||||
|
rssi?: number;
|
||||||
|
src?: string;
|
||||||
|
state?: boolean;
|
||||||
|
sceneId?: number;
|
||||||
|
schdPsetId?: number;
|
||||||
|
temp?: number;
|
||||||
|
dimming?: number;
|
||||||
|
r?: number;
|
||||||
|
g?: number;
|
||||||
|
b?: number;
|
||||||
|
c?: number;
|
||||||
|
w?: number;
|
||||||
|
speed?: number;
|
||||||
|
ratio?: number;
|
||||||
|
pc?: number;
|
||||||
|
fanState?: number;
|
||||||
|
fanMode?: number;
|
||||||
|
fanSpeed?: number;
|
||||||
|
fanRevrs?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizPilotPatch {
|
||||||
|
state?: boolean;
|
||||||
|
sceneId?: number;
|
||||||
|
temp?: number;
|
||||||
|
dimming?: number;
|
||||||
|
r?: number;
|
||||||
|
g?: number;
|
||||||
|
b?: number;
|
||||||
|
c?: number;
|
||||||
|
w?: number;
|
||||||
|
speed?: number;
|
||||||
|
ratio?: number;
|
||||||
|
fanState?: number;
|
||||||
|
fanMode?: number;
|
||||||
|
fanSpeed?: number;
|
||||||
|
fanRevrs?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizSensorRecord {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
platform?: 'sensor' | 'binary_sensor';
|
||||||
|
value?: unknown;
|
||||||
|
unit?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
writable?: boolean;
|
||||||
|
available?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizButtonRecord {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
value?: unknown;
|
||||||
|
lastPressedAt?: string | number;
|
||||||
|
available?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizEntityRecord {
|
||||||
|
platform: TEntityPlatform;
|
||||||
|
key?: string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
name?: string;
|
||||||
|
state?: unknown;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
available?: boolean;
|
||||||
|
writable?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizSnapshotDevice {
|
||||||
|
id?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
mac?: string;
|
||||||
|
name?: string;
|
||||||
|
available?: boolean;
|
||||||
|
deviceInfo?: IWizDeviceInfo;
|
||||||
|
pilot?: IWizPilotState;
|
||||||
|
sensors?: IWizSensorRecord[];
|
||||||
|
buttons?: IWizButtonRecord[];
|
||||||
|
entities?: IWizEntityRecord[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizSnapshot {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
devices: IWizSnapshotDevice[];
|
||||||
|
events: IWizEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizEvent {
|
||||||
|
type: 'pilot' | 'command_mapped' | 'command_failed' | 'udp_response' | 'error' | string;
|
||||||
|
timestamp?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
command?: IWizClientCommand;
|
||||||
|
data?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizClientCommand {
|
||||||
|
type: string;
|
||||||
|
service: string;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
payload: IWizPilotPatch;
|
||||||
|
target?: IServiceCallRequest['target'];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TWizCommandExecutor = (
|
||||||
|
commandArg: IWizClientCommand
|
||||||
|
) => Promise<IWizCommandResult | unknown> | IWizCommandResult | unknown;
|
||||||
|
|
||||||
|
export interface IWizUdpCommand<TParams extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
|
method: string;
|
||||||
|
params?: TParams;
|
||||||
|
id?: number | string;
|
||||||
|
env?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizUdpError {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizUdpResponse<TResult = unknown> {
|
||||||
|
method?: string;
|
||||||
|
env?: string;
|
||||||
|
id?: number | string;
|
||||||
|
result?: TResult;
|
||||||
|
params?: TResult;
|
||||||
|
error?: IWizUdpError;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizUdpDiscoveryRecord {
|
||||||
|
host?: string;
|
||||||
|
address?: string;
|
||||||
|
ip?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
port?: number;
|
||||||
|
mac?: string;
|
||||||
|
mac_address?: string;
|
||||||
|
name?: string;
|
||||||
|
method?: string;
|
||||||
|
result?: Record<string, unknown>;
|
||||||
|
response?: IWizUdpResponse<Record<string, unknown>>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWizManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
mac?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
deviceInfo?: IWizDeviceInfo;
|
||||||
|
pilot?: IWizPilotState;
|
||||||
|
snapshot?: IWizSnapshot;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './xiaomi_miio.classes.integration.js';
|
export * from './xiaomi_miio.classes.integration.js';
|
||||||
|
export * from './xiaomi_miio.classes.client.js';
|
||||||
|
export * from './xiaomi_miio.classes.configflow.js';
|
||||||
|
export * from './xiaomi_miio.discovery.js';
|
||||||
|
export * from './xiaomi_miio.mapper.js';
|
||||||
export * from './xiaomi_miio.types.js';
|
export * from './xiaomi_miio.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { IXiaomiMiioClientCommand, IXiaomiMiioCommandResult, IXiaomiMiioConfig, IXiaomiMiioEvent, IXiaomiMiioSnapshot } from './xiaomi_miio.types.js';
|
||||||
|
import { XiaomiMiioMapper } from './xiaomi_miio.mapper.js';
|
||||||
|
|
||||||
|
type TXiaomiMiioEventHandler = (eventArg: IXiaomiMiioEvent) => void;
|
||||||
|
|
||||||
|
export class XiaomiMiioClient {
|
||||||
|
private readonly events: IXiaomiMiioEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TXiaomiMiioEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IXiaomiMiioConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IXiaomiMiioSnapshot> {
|
||||||
|
return XiaomiMiioMapper.toSnapshot(this.config, undefined, this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TXiaomiMiioEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IXiaomiMiioClientCommand): Promise<IXiaomiMiioCommandResult> {
|
||||||
|
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, uniqueId: commandArg.uniqueId, timestamp: Date.now() });
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
const result = await this.config.commandExecutor(commandArg);
|
||||||
|
return this.commandResult(result, commandArg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Xiaomi Miio live UDP writes for ${this.config.host || 'configured host'} require encrypted miIO packet framing and validated token handling. This dependency-free TypeScript port maps commands but does not send encrypted local writes without a commandExecutor.`,
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendLocalCommand(methodArg: string, paramsArg: unknown[] = []): Promise<IXiaomiMiioCommandResult> {
|
||||||
|
return this.sendCommand({
|
||||||
|
type: 'local.miio.command',
|
||||||
|
service: 'raw_command',
|
||||||
|
method: methodArg,
|
||||||
|
params: paramsArg,
|
||||||
|
payload: { method: methodArg, params: paramsArg },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IXiaomiMiioEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: IXiaomiMiioClientCommand): IXiaomiMiioCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IXiaomiMiioCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import { normalizeXiaomiMiioToken } from './xiaomi_miio.discovery.js';
|
||||||
|
import type { IXiaomiMiioConfig, IXiaomiMiioSnapshot } from './xiaomi_miio.types.js';
|
||||||
|
|
||||||
|
const defaultMiioPort = 54321;
|
||||||
|
|
||||||
|
export class XiaomiMiioConfigFlow implements IConfigFlow<IXiaomiMiioConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IXiaomiMiioConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
const defaults = this.defaultsFromCandidate(candidateArg);
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Xiaomi Home device',
|
||||||
|
description: 'Provide the local host, 32-character Miio token, model/name, and optional snapshot JSON. Live encrypted UDP probing is not performed by this TypeScript port.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'token', label: 'Miio token', type: 'password', required: true },
|
||||||
|
{ name: 'model', label: 'Model', type: 'text', required: true },
|
||||||
|
{ name: 'name', label: 'Name', type: 'text' },
|
||||||
|
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || defaults.host;
|
||||||
|
const token = normalizeXiaomiMiioToken(valuesArg.token) || defaults.token;
|
||||||
|
const model = this.stringValue(valuesArg.model) || defaults.model;
|
||||||
|
const name = this.stringValue(valuesArg.name) || defaults.name || model;
|
||||||
|
const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'Invalid Xiaomi Miio host', error: 'Host is required.' };
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return { kind: 'error', title: 'Invalid Xiaomi Miio token', error: 'Token must be 32 hexadecimal characters.' };
|
||||||
|
}
|
||||||
|
if (!model) {
|
||||||
|
return { kind: 'error', title: 'Invalid Xiaomi Miio model', error: 'Model is required.' };
|
||||||
|
}
|
||||||
|
if (snapshot === false) {
|
||||||
|
return { kind: 'error', title: 'Invalid Xiaomi Miio snapshot', error: 'Snapshot JSON must be a JSON object.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Xiaomi Home device configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port: defaults.port || defaultMiioPort,
|
||||||
|
token,
|
||||||
|
model,
|
||||||
|
name: name || model,
|
||||||
|
macAddress: defaults.macAddress,
|
||||||
|
deviceId: defaults.deviceId,
|
||||||
|
snapshot: snapshot || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { host?: string; port?: number; token?: string; model?: string; name?: string; macAddress?: string; deviceId?: string; snapshot?: IXiaomiMiioSnapshot } {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
return {
|
||||||
|
host: candidateArg.host,
|
||||||
|
port: candidateArg.port,
|
||||||
|
token: normalizeXiaomiMiioToken(metadata.token),
|
||||||
|
model: candidateArg.model || this.stringValue(metadata.model),
|
||||||
|
name: candidateArg.name || this.stringValue(metadata.name),
|
||||||
|
macAddress: candidateArg.macAddress || this.stringValue(metadata.macAddress),
|
||||||
|
deviceId: candidateArg.id || this.stringValue(metadata.deviceId),
|
||||||
|
snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotValue(valueArg: unknown, fallbackArg?: IXiaomiMiioSnapshot): IXiaomiMiioSnapshot | undefined | false {
|
||||||
|
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||||
|
return fallbackArg;
|
||||||
|
}
|
||||||
|
if (this.isSnapshot(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(valueArg) as unknown;
|
||||||
|
return this.isRecord(parsed) ? parsed as unknown as IXiaomiMiioSnapshot : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSnapshot(valueArg: unknown): valueArg is IXiaomiMiioSnapshot {
|
||||||
|
return this.isRecord(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,77 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { XiaomiMiioClient } from './xiaomi_miio.classes.client.js';
|
||||||
|
import { XiaomiMiioConfigFlow } from './xiaomi_miio.classes.configflow.js';
|
||||||
|
import { createXiaomiMiioDiscoveryDescriptor } from './xiaomi_miio.discovery.js';
|
||||||
|
import { XiaomiMiioMapper } from './xiaomi_miio.mapper.js';
|
||||||
|
import type { IXiaomiMiioConfig } from './xiaomi_miio.types.js';
|
||||||
|
|
||||||
export class HomeAssistantXiaomiMiioIntegration extends DescriptorOnlyIntegration {
|
export class XiaomiMiioIntegration extends BaseIntegration<IXiaomiMiioConfig> {
|
||||||
constructor() {
|
public readonly domain = 'xiaomi_miio';
|
||||||
super({
|
public readonly displayName = 'Xiaomi Home';
|
||||||
domain: "xiaomi_miio",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Xiaomi Home",
|
public readonly discoveryDescriptor = createXiaomiMiioDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new XiaomiMiioConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/xiaomi_miio",
|
upstreamPath: 'homeassistant/components/xiaomi_miio',
|
||||||
"upstreamDomain": "xiaomi_miio",
|
upstreamDomain: 'xiaomi_miio',
|
||||||
"integrationType": "hub",
|
documentation: 'https://www.home-assistant.io/integrations/xiaomi_miio',
|
||||||
"iotClass": "local_polling",
|
integrationType: 'hub',
|
||||||
"requirements": [
|
iotClass: 'local_polling',
|
||||||
"construct==2.10.68",
|
requirements: ['construct==2.10.68', 'micloud==0.5', 'python-miio==0.5.12'],
|
||||||
"micloud==0.5",
|
zeroconf: ['_miio._udp.local.'],
|
||||||
"python-miio==0.5.12"
|
codeowners: ['@rytilahti', '@syssi', '@starkillerOG'],
|
||||||
],
|
};
|
||||||
"dependencies": [],
|
|
||||||
"afterDependencies": [],
|
public async setup(configArg: IXiaomiMiioConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"codeowners": [
|
void contextArg;
|
||||||
"@rytilahti",
|
return new XiaomiMiioRuntime(new XiaomiMiioClient(configArg));
|
||||||
"@syssi",
|
}
|
||||||
"@starkillerOG"
|
|
||||||
]
|
public async destroy(): Promise<void> {}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantXiaomiMiioIntegration extends XiaomiMiioIntegration {}
|
||||||
|
|
||||||
|
class XiaomiMiioRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'xiaomi_miio';
|
||||||
|
|
||||||
|
constructor(private readonly client: XiaomiMiioClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return XiaomiMiioMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return XiaomiMiioMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: eventArg.type === 'availability_changed' ? 'availability_changed' : eventArg.type === 'device_removed' ? 'device_removed' : 'state_changed',
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const command = XiaomiMiioMapper.commandForService(await this.client.getSnapshot(), requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `Unsupported Xiaomi Miio service: ${requestArg.domain}.${requestArg.service}` };
|
||||||
|
}
|
||||||
|
return this.client.sendCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IXiaomiMiioDhcpRecord, IXiaomiMiioManualEntry, IXiaomiMiioMdnsRecord } from './xiaomi_miio.types.js';
|
||||||
|
|
||||||
|
const defaultMiioPort = 54321;
|
||||||
|
const miioMdnsType = '_miio._udp.local';
|
||||||
|
const xiaomiTextHints = ['xiaomi', 'miio', 'miot', 'mijia', 'roborock', 'rockrobo', 'zhimi', 'chuangmi', 'lumi', 'philips.light', 'viomi', 'dreame', 'deerma', 'dmaker'];
|
||||||
|
|
||||||
|
export const normalizeXiaomiMiioToken = (valueArg: unknown): string | undefined => {
|
||||||
|
if (typeof valueArg !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const token = valueArg.trim().toLowerCase();
|
||||||
|
return /^[0-9a-f]{32}$/.test(token) ? token : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class XiaomiMiioMdnsMatcher implements IDiscoveryMatcher<IXiaomiMiioMdnsRecord> {
|
||||||
|
public id = 'xiaomi-miio-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Xiaomi Miio mDNS records advertised as _miio._udp.local.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IXiaomiMiioMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type || recordArg.serviceType);
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const name = recordArg.name || recordArg.hostname || '';
|
||||||
|
const model = this.txt(txt, 'model') || this.modelFromName(name);
|
||||||
|
const macAddress = this.formatMac(this.txt(txt, 'mac') || this.macFromPoch(this.txt(txt, 'poch')));
|
||||||
|
const did = this.txt(txt, 'did') || this.txt(txt, 'id');
|
||||||
|
const matched = type === miioMdnsType || Boolean(model && name.toLowerCase().includes('miio'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Xiaomi Miio advertisement.' };
|
||||||
|
}
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const id = macAddress || did || host || name;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: host && (macAddress || model) ? 'certain' : host ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches Xiaomi Miio zeroconf metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || defaultMiioPort,
|
||||||
|
name: model ? `${model} ${host || ''}`.trim() : name,
|
||||||
|
manufacturer: 'Xiaomi',
|
||||||
|
model,
|
||||||
|
macAddress,
|
||||||
|
metadata: {
|
||||||
|
xiaomiMiio: true,
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type || recordArg.serviceType,
|
||||||
|
txt,
|
||||||
|
did,
|
||||||
|
model,
|
||||||
|
macAddress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: { model, macAddress, did },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
||||||
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private macFromPoch(valueArg?: string): string | undefined {
|
||||||
|
const match = /mac=([0-9a-f:]+)/i.exec(valueArg || '');
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private modelFromName(valueArg: string): string | undefined {
|
||||||
|
const raw = valueArg.split('._')[0].replace(/_miio.*$/i, '').replace(/\.local\.?$/i, '').trim();
|
||||||
|
if (!raw || raw.startsWith('_')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return raw.replace(/-/g, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMac(valueArg?: string): string | undefined {
|
||||||
|
const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaomiMiioDhcpMatcher implements IDiscoveryMatcher<IXiaomiMiioDhcpRecord> {
|
||||||
|
public id = 'xiaomi-miio-dhcp-match';
|
||||||
|
public source = 'dhcp' as const;
|
||||||
|
public description = 'Recognize Xiaomi Miio devices from DHCP hostname, manufacturer, model, or metadata hints.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IXiaomiMiioDhcpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const host = recordArg.host || recordArg.ipAddress || recordArg.address;
|
||||||
|
const hostname = recordArg.hostname || recordArg.hostName;
|
||||||
|
const metadata = recordArg.metadata || {};
|
||||||
|
const text = [hostname, recordArg.manufacturer, recordArg.model, recordArg.vendorClassIdentifier, metadata.model, metadata.manufacturer]
|
||||||
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
const token = normalizeXiaomiMiioToken(metadata.token);
|
||||||
|
const matched = Boolean(recordArg.integrationDomain === 'xiaomi_miio' || metadata.xiaomiMiio || token || xiaomiTextHints.some((hintArg) => text.includes(hintArg)));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'DHCP record does not contain Xiaomi Miio metadata.' };
|
||||||
|
}
|
||||||
|
const macAddress = this.formatMac(recordArg.macAddress || recordArg.mac || this.stringValue(metadata.macAddress));
|
||||||
|
const id = macAddress || recordArg.deviceId as string | undefined || hostname || host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: host && token ? 'certain' : host && macAddress ? 'high' : host ? 'medium' : 'low',
|
||||||
|
reason: 'DHCP record contains Xiaomi Miio host or vendor metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'dhcp',
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port: defaultMiioPort,
|
||||||
|
name: hostname || recordArg.model || 'Xiaomi Miio device',
|
||||||
|
manufacturer: recordArg.manufacturer || 'Xiaomi',
|
||||||
|
model: recordArg.model || this.stringValue(metadata.model),
|
||||||
|
macAddress,
|
||||||
|
metadata: { ...metadata, xiaomiMiio: true, token, macAddress },
|
||||||
|
},
|
||||||
|
metadata: { tokenConfigured: Boolean(token), macAddress },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMac(valueArg?: string): string | undefined {
|
||||||
|
const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaomiMiioManualMatcher implements IDiscoveryMatcher<IXiaomiMiioManualEntry> {
|
||||||
|
public id = 'xiaomi-miio-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Xiaomi Miio host and token setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IXiaomiMiioManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const host = inputArg.host;
|
||||||
|
const token = normalizeXiaomiMiioToken(inputArg.token || inputArg.metadata?.token);
|
||||||
|
if (!host || !token) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual Xiaomi Miio setup requires host and a 32-character hexadecimal token.' };
|
||||||
|
}
|
||||||
|
const id = inputArg.deviceId || inputArg.id || inputArg.macAddress || host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.model ? 'certain' : 'high',
|
||||||
|
reason: 'Manual entry contains Xiaomi Miio host and token.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port: inputArg.port || defaultMiioPort,
|
||||||
|
name: inputArg.name || inputArg.model || 'Xiaomi Miio device',
|
||||||
|
manufacturer: 'Xiaomi',
|
||||||
|
model: inputArg.model,
|
||||||
|
macAddress: inputArg.macAddress,
|
||||||
|
metadata: { ...inputArg.metadata, xiaomiMiio: true, token, snapshot: inputArg.snapshot },
|
||||||
|
},
|
||||||
|
metadata: { tokenConfigured: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaomiMiioCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'xiaomi-miio-candidate-validator';
|
||||||
|
public description = 'Validate Xiaomi Miio discovery candidates and manual token shape.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const token = normalizeXiaomiMiioToken(metadata.token);
|
||||||
|
if (candidateArg.source === 'manual' && (!candidateArg.host || !token)) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual Xiaomi Miio candidates require host and a valid 32-character hexadecimal token.' };
|
||||||
|
}
|
||||||
|
if (metadata.token !== undefined && !token) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Xiaomi Miio token metadata is present but invalid.' };
|
||||||
|
}
|
||||||
|
const text = [candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.model, metadata.manufacturer]
|
||||||
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
const matched = Boolean(candidateArg.integrationDomain === 'xiaomi_miio' || metadata.xiaomiMiio || token || xiaomiTextHints.some((hintArg) => text.includes(hintArg)));
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host && token ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Xiaomi Miio metadata.' : 'Candidate is not Xiaomi Miio.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host,
|
||||||
|
metadata: matched ? { tokenConfigured: Boolean(token), host: candidateArg.host } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createXiaomiMiioDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'xiaomi_miio', displayName: 'Xiaomi Home' })
|
||||||
|
.addMatcher(new XiaomiMiioMdnsMatcher())
|
||||||
|
.addMatcher(new XiaomiMiioDhcpMatcher())
|
||||||
|
.addMatcher(new XiaomiMiioManualMatcher())
|
||||||
|
.addValidator(new XiaomiMiioCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type { IXiaomiMiioClientCommand, IXiaomiMiioConfig, IXiaomiMiioDeviceState, IXiaomiMiioEntityDescriptor, IXiaomiMiioEvent, IXiaomiMiioProperty, IXiaomiMiioSnapshot, IXiaomiMiioStateRecord, TXiaomiMiioDeviceKind } from './xiaomi_miio.types.js';
|
||||||
|
|
||||||
|
const defaultMiioPort = 54321;
|
||||||
|
const sensorUnits: Record<string, string> = {
|
||||||
|
temperature: 'C',
|
||||||
|
humidity: '%',
|
||||||
|
target_humidity: '%',
|
||||||
|
water_level: '%',
|
||||||
|
battery: '%',
|
||||||
|
battery_level: '%',
|
||||||
|
pm25: 'ug/m3',
|
||||||
|
pm25_2: 'ug/m3',
|
||||||
|
pm10_density: 'ug/m3',
|
||||||
|
tvoc: 'ug/m3',
|
||||||
|
co2: 'ppm',
|
||||||
|
load_power: 'W',
|
||||||
|
motor_speed: 'rpm',
|
||||||
|
motor2_speed: 'rpm',
|
||||||
|
actual_speed: 'rpm',
|
||||||
|
favorite_speed: 'rpm',
|
||||||
|
clean_area: 'm2',
|
||||||
|
area: 'm2',
|
||||||
|
clean_time: 's',
|
||||||
|
duration: 's',
|
||||||
|
filter_life_remaining: '%',
|
||||||
|
filter_hours_used: 'h',
|
||||||
|
filter_left_time: 'd',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryControlKeys = new Set(['is_on', 'on', 'power', 'brightness', 'color_temperature', 'rgb', 'hs_color', 'position', 'current_position', 'target_position', 'state', 'status', 'mode']);
|
||||||
|
const writableKeys = new Set(['is_on', 'on', 'power', 'brightness', 'color_temperature', 'rgb', 'hs_color', 'percentage', 'speed', 'fan_level', 'favorite_level', 'favorite_rpm', 'motor_speed', 'target_humidity', 'mode', 'position', 'target_position', 'oscillate', 'child_lock', 'buzzer', 'led', 'display', 'dry']);
|
||||||
|
const numberControlKeys = new Set(['fan_level', 'favorite_level', 'favorite_rpm', 'motor_speed', 'target_humidity', 'delay_off_countdown', 'led_brightness', 'led_brightness_level', 'volume', 'angle']);
|
||||||
|
const sensorKeys = new Set(['temperature', 'humidity', 'target_humidity', 'water_level', 'battery', 'battery_level', 'pm25', 'pm25_2', 'pm10_density', 'aqi', 'air_quality', 'tvoc', 'co2', 'load_power', 'motor_speed', 'motor2_speed', 'actual_speed', 'control_speed', 'favorite_speed', 'filter_life_remaining', 'filter_hours_used', 'filter_left_time', 'purify_volume', 'illuminance', 'illuminance_lux', 'clean_area', 'clean_time', 'duration', 'error']);
|
||||||
|
const topLevelStateKeys = [...primaryControlKeys, ...sensorKeys, 'state_code', 'fan_speed', 'fanspeed', 'oscillate', 'oscillating', 'preset_mode', 'current_humidity'];
|
||||||
|
|
||||||
|
export class XiaomiMiioMapper {
|
||||||
|
public static toSnapshot(configArg: IXiaomiMiioConfig, connectedArg?: boolean, eventsArg: IXiaomiMiioEvent[] = []): IXiaomiMiioSnapshot {
|
||||||
|
const source = configArg.snapshot;
|
||||||
|
const primaryDevice = this.primaryDevice(configArg, source);
|
||||||
|
const devices = this.uniqueDevices([
|
||||||
|
...(source?.devices || []),
|
||||||
|
...(source?.device ? [source.device] : []),
|
||||||
|
...(configArg.devices || []),
|
||||||
|
...(primaryDevice ? [primaryDevice] : []),
|
||||||
|
]);
|
||||||
|
const connected = connectedArg ?? source?.connected ?? Boolean(source || configArg.devices?.length || configArg.state || configArg.properties?.length);
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
configured: Boolean(configArg.host && configArg.token),
|
||||||
|
host: configArg.host || source?.host,
|
||||||
|
port: configArg.port || source?.port || defaultMiioPort,
|
||||||
|
tokenConfigured: Boolean(configArg.token || source?.tokenConfigured),
|
||||||
|
device: primaryDevice || source?.device || devices[0],
|
||||||
|
devices,
|
||||||
|
entities: [...(source?.entities || []), ...(configArg.entities || [])],
|
||||||
|
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||||
|
transport: {
|
||||||
|
protocol: source?.transport?.protocol || (source ? 'snapshot' : 'manual'),
|
||||||
|
host: configArg.host || source?.transport?.host || source?.host,
|
||||||
|
port: configArg.port || source?.transport?.port || source?.port || defaultMiioPort,
|
||||||
|
tokenConfigured: Boolean(configArg.token || source?.transport?.tokenConfigured || source?.tokenConfigured),
|
||||||
|
encryptedUdpImplemented: false,
|
||||||
|
},
|
||||||
|
metadata: source?.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDevices(snapshotArg: IXiaomiMiioSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const devices = new Map<string, plugins.shxInterfaces.data.IDeviceDefinition>();
|
||||||
|
for (const device of this.allDevices(snapshotArg)) {
|
||||||
|
const updatedAt = device.updatedAt || new Date().toISOString();
|
||||||
|
const deviceId = this.deviceId(device);
|
||||||
|
const properties = this.propertiesForDevice(device);
|
||||||
|
const features = this.uniqueFeatures(properties.map((propertyArg) => this.featureForProperty(propertyArg, device)));
|
||||||
|
const state = properties.map((propertyArg) => ({
|
||||||
|
featureId: this.slug(propertyArg.key),
|
||||||
|
value: this.deviceStateValue(propertyArg.value ?? this.normalizedState(device)[propertyArg.key] ?? null),
|
||||||
|
updatedAt,
|
||||||
|
}));
|
||||||
|
if (!features.length) {
|
||||||
|
features.push({ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false });
|
||||||
|
state.push({ featureId: 'availability', value: device.available === false ? 'offline' : 'online', updatedAt });
|
||||||
|
}
|
||||||
|
devices.set(deviceId, {
|
||||||
|
id: deviceId,
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
name: this.deviceName(device),
|
||||||
|
protocol: 'unknown',
|
||||||
|
manufacturer: device.manufacturer || 'Xiaomi',
|
||||||
|
model: device.model,
|
||||||
|
online: device.available !== false && device.online !== false && (snapshotArg.connected || Boolean(properties.length || device.host)),
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
miioProtocol: 'miio-udp',
|
||||||
|
host: device.host || snapshotArg.host,
|
||||||
|
port: device.port || snapshotArg.port || defaultMiioPort,
|
||||||
|
macAddress: device.macAddress,
|
||||||
|
did: device.did,
|
||||||
|
firmwareVersion: device.firmwareVersion,
|
||||||
|
hardwareVersion: device.hardwareVersion,
|
||||||
|
kind: this.inferDeviceKind(device),
|
||||||
|
tokenConfigured: Boolean(device.tokenConfigured || snapshotArg.tokenConfigured),
|
||||||
|
encryptedUdpImplemented: false,
|
||||||
|
...device.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of this.allEntityDescriptors(snapshotArg)) {
|
||||||
|
const deviceId = this.entityDeviceId(snapshotArg, entity);
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const featureId = this.slug(entity.propertyKey || entity.property_key || entity.uniqueId || entity.unique_id || entity.id || entity.entityId || entity.name || 'entity');
|
||||||
|
if (!device.features.some((featureArg) => featureArg.id === featureId)) {
|
||||||
|
device.features.push({
|
||||||
|
id: featureId,
|
||||||
|
capability: this.capabilityForPlatform(this.corePlatform(entity.platform || entity.kind || 'sensor')),
|
||||||
|
name: entity.name || entity.entityId || entity.id || 'Xiaomi Miio entity',
|
||||||
|
readable: true,
|
||||||
|
writable: entity.writable === true,
|
||||||
|
unit: entity.unit,
|
||||||
|
});
|
||||||
|
device.state.push({ featureId, value: this.deviceStateValue(entity.state ?? entity.value ?? null), updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...devices.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IXiaomiMiioSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const addEntity = (entityArg: IIntegrationEntity | undefined) => {
|
||||||
|
if (!entityArg || seen.has(entityArg.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(entityArg.id);
|
||||||
|
entities.push(entityArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const descriptor of this.allEntityDescriptors(snapshotArg)) {
|
||||||
|
addEntity(this.entityFromDescriptor(snapshotArg, descriptor));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const device of this.allDevices(snapshotArg)) {
|
||||||
|
addEntity(this.primaryEntityForDevice(device));
|
||||||
|
for (const property of this.propertiesForDevice(device)) {
|
||||||
|
addEntity(this.entityForProperty(device, property));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest): IXiaomiMiioClientCommand | undefined {
|
||||||
|
const targetEntity = this.findTargetEntity(snapshotArg, requestArg);
|
||||||
|
const targetDevice = this.findTargetDevice(snapshotArg, requestArg, targetEntity);
|
||||||
|
const kind = this.kindForCommand(requestArg, targetEntity, targetDevice);
|
||||||
|
if (['start', 'stop', 'pause', 'return_home'].includes(requestArg.service) && (kind === 'vacuum' || requestArg.domain === 'vacuum')) {
|
||||||
|
return this.command(requestArg, targetEntity, targetDevice, kind, requestArg.service, [], { action: requestArg.service });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') {
|
||||||
|
const value = requestArg.service === 'turn_on';
|
||||||
|
return this.command(requestArg, targetEntity, targetDevice, kind, requestArg.service, [], { power: value });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
const propertyKey = this.stringValue(requestArg.data?.propertyKey || requestArg.data?.property_key || requestArg.data?.property || targetEntity?.attributes?.propertyKey);
|
||||||
|
if (requestArg.data?.value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.command(requestArg, targetEntity, targetDevice, kind, 'set_value', [requestArg.data.value], { propertyKey, value: requestArg.data.value });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static primaryDevice(configArg: IXiaomiMiioConfig, sourceArg?: IXiaomiMiioSnapshot): IXiaomiMiioDeviceState | undefined {
|
||||||
|
if (!configArg.host && !configArg.model && !configArg.name && !configArg.state && !configArg.properties?.length && !configArg.device) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const base = configArg.device || sourceArg?.device || {};
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
id: base.id || configArg.deviceId || configArg.macAddress || configArg.host || configArg.model || 'configured',
|
||||||
|
host: configArg.host || base.host,
|
||||||
|
port: configArg.port || base.port || defaultMiioPort,
|
||||||
|
model: configArg.model || base.model,
|
||||||
|
name: configArg.name || base.name || configArg.model || 'Xiaomi Miio device',
|
||||||
|
macAddress: configArg.macAddress || base.macAddress,
|
||||||
|
kind: configArg.kind || base.kind,
|
||||||
|
tokenConfigured: Boolean(configArg.token || base.tokenConfigured),
|
||||||
|
state: { ...this.asRecord(base.state), ...this.asRecord(configArg.state) },
|
||||||
|
properties: [...(base.properties || []), ...(configArg.properties || [])],
|
||||||
|
entities: [...(base.entities || []), ...(configArg.entities || [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static allDevices(snapshotArg: IXiaomiMiioSnapshot): IXiaomiMiioDeviceState[] {
|
||||||
|
const flattened: IXiaomiMiioDeviceState[] = [];
|
||||||
|
const visit = (deviceArg: IXiaomiMiioDeviceState) => {
|
||||||
|
flattened.push(deviceArg);
|
||||||
|
for (const child of deviceArg.children || []) {
|
||||||
|
visit(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const device of snapshotArg.devices.length ? snapshotArg.devices : snapshotArg.device ? [snapshotArg.device] : []) {
|
||||||
|
visit(device);
|
||||||
|
}
|
||||||
|
return this.uniqueDevices(flattened);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static allEntityDescriptors(snapshotArg: IXiaomiMiioSnapshot): IXiaomiMiioEntityDescriptor[] {
|
||||||
|
const entities = [...snapshotArg.entities];
|
||||||
|
for (const device of this.allDevices(snapshotArg)) {
|
||||||
|
for (const entity of device.entities || []) {
|
||||||
|
entities.push({ ...entity, deviceId: entity.deviceId || entity.device_id || this.deviceId(device), kind: entity.kind || this.inferDeviceKind(device) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueDevices(devicesArg: IXiaomiMiioDeviceState[]): IXiaomiMiioDeviceState[] {
|
||||||
|
const devices = new Map<string, IXiaomiMiioDeviceState>();
|
||||||
|
for (const device of devicesArg) {
|
||||||
|
devices.set(this.rawDeviceKey(device), { ...devices.get(this.rawDeviceKey(device)), ...device });
|
||||||
|
}
|
||||||
|
return [...devices.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueFeatures(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[]): plugins.shxInterfaces.data.IDeviceFeature[] {
|
||||||
|
const features = new Map<string, plugins.shxInterfaces.data.IDeviceFeature>();
|
||||||
|
for (const feature of featuresArg) {
|
||||||
|
features.set(feature.id, { ...features.get(feature.id), ...feature });
|
||||||
|
}
|
||||||
|
return [...features.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static propertiesForDevice(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioProperty[] {
|
||||||
|
const state = this.normalizedState(deviceArg);
|
||||||
|
const properties = [...(deviceArg.properties || [])];
|
||||||
|
const existing = new Set(properties.map((propertyArg) => propertyArg.key));
|
||||||
|
const kind = this.inferDeviceKind(deviceArg);
|
||||||
|
for (const [key, value] of Object.entries(state)) {
|
||||||
|
if (existing.has(key) || value === undefined || this.isRecord(value) && key !== 'error') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
properties.push({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
name: this.title(key),
|
||||||
|
unit: sensorUnits[key],
|
||||||
|
readable: true,
|
||||||
|
writable: writableKeys.has(key),
|
||||||
|
platform: this.platformForProperty(key, value, kind),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizedState(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioStateRecord {
|
||||||
|
const state: IXiaomiMiioStateRecord = { ...this.asRecord(deviceArg.state) };
|
||||||
|
for (const key of topLevelStateKeys) {
|
||||||
|
if (deviceArg[key] !== undefined && state[key] === undefined) {
|
||||||
|
state[key] = deviceArg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.isRecord(state.status)) {
|
||||||
|
for (const [key, value] of Object.entries(state.status)) {
|
||||||
|
if (state[key] === undefined) {
|
||||||
|
state[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.is_on === undefined && typeof state.power === 'string') {
|
||||||
|
state.is_on = ['on', 'true', '1'].includes(state.power.toLowerCase());
|
||||||
|
}
|
||||||
|
if (state.battery === undefined && state.battery_level !== undefined) {
|
||||||
|
state.battery = state.battery_level;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static primaryEntityForDevice(deviceArg: IXiaomiMiioDeviceState): IIntegrationEntity | undefined {
|
||||||
|
const kind = this.inferDeviceKind(deviceArg);
|
||||||
|
const state = this.normalizedState(deviceArg);
|
||||||
|
const domain = this.entityDomainForKind(kind);
|
||||||
|
if (!domain) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const name = this.deviceName(deviceArg);
|
||||||
|
const deviceId = this.deviceId(deviceArg);
|
||||||
|
return {
|
||||||
|
id: `${domain}.${this.slug(name)}`,
|
||||||
|
uniqueId: `xiaomi_miio_${this.slug(`${this.rawDeviceKey(deviceArg)}_${kind}`)}`,
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
deviceId,
|
||||||
|
platform: this.corePlatform(kind === 'humidifier' ? 'climate' : kind === 'vacuum' ? 'sensor' : domain),
|
||||||
|
name,
|
||||||
|
state: this.primaryEntityState(kind, state),
|
||||||
|
attributes: {
|
||||||
|
xiaomiMiioKind: kind,
|
||||||
|
xiaomiMiioPlatform: domain,
|
||||||
|
model: deviceArg.model,
|
||||||
|
battery: state.battery,
|
||||||
|
brightness: state.brightness,
|
||||||
|
colorTemperature: state.color_temperature,
|
||||||
|
percentage: state.percentage ?? state.speed ?? state.fan_level,
|
||||||
|
presetMode: state.preset_mode ?? state.mode,
|
||||||
|
oscillating: state.oscillating ?? state.oscillate,
|
||||||
|
currentHumidity: state.current_humidity ?? state.humidity,
|
||||||
|
targetHumidity: state.target_humidity,
|
||||||
|
currentPosition: state.current_position ?? state.position,
|
||||||
|
fanSpeed: state.fan_speed ?? state.fanspeed,
|
||||||
|
error: state.error,
|
||||||
|
},
|
||||||
|
available: deviceArg.available !== false && deviceArg.online !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityForProperty(deviceArg: IXiaomiMiioDeviceState, propertyArg: IXiaomiMiioProperty): IIntegrationEntity | undefined {
|
||||||
|
const kind = this.inferDeviceKind(deviceArg);
|
||||||
|
const key = propertyArg.key;
|
||||||
|
const platform = this.corePlatform(propertyArg.platform || this.platformForProperty(key, propertyArg.value, kind));
|
||||||
|
if (primaryControlKeys.has(key) && platform !== 'number') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!sensorKeys.has(key) && !propertyArg.unit && propertyArg.writable !== true && platform === 'sensor') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const name = `${this.deviceName(deviceArg)} ${propertyArg.name || this.title(key)}`;
|
||||||
|
return {
|
||||||
|
id: `${platform}.${this.slug(name)}`,
|
||||||
|
uniqueId: `xiaomi_miio_${this.slug(`${this.rawDeviceKey(deviceArg)}_${key}`)}`,
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
deviceId: this.deviceId(deviceArg),
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
state: this.entityStateValue(propertyArg.value),
|
||||||
|
attributes: {
|
||||||
|
xiaomiMiioKind: kind,
|
||||||
|
propertyKey: key,
|
||||||
|
deviceClass: propertyArg.deviceClass,
|
||||||
|
unit: propertyArg.unit,
|
||||||
|
writable: propertyArg.writable === true,
|
||||||
|
},
|
||||||
|
available: deviceArg.available !== false && deviceArg.online !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityFromDescriptor(snapshotArg: IXiaomiMiioSnapshot, entityArg: IXiaomiMiioEntityDescriptor): IIntegrationEntity {
|
||||||
|
const platform = this.corePlatform(entityArg.platform || entityArg.kind || 'sensor');
|
||||||
|
const domain = this.entityDomainForExplicit(entityArg.platform || entityArg.kind || platform);
|
||||||
|
const name = entityArg.name || entityArg.entityId || entityArg.id || 'Xiaomi Miio entity';
|
||||||
|
return {
|
||||||
|
id: entityArg.entityId || entityArg.id || `${domain}.${this.slug(name)}`,
|
||||||
|
uniqueId: entityArg.uniqueId || entityArg.unique_id || `xiaomi_miio_${this.slug(entityArg.id || entityArg.entityId || name)}`,
|
||||||
|
integrationDomain: 'xiaomi_miio',
|
||||||
|
deviceId: this.entityDeviceId(snapshotArg, entityArg),
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
state: this.entityStateValue(entityArg.state ?? entityArg.value),
|
||||||
|
attributes: {
|
||||||
|
...entityArg.attributes,
|
||||||
|
xiaomiMiioKind: entityArg.kind,
|
||||||
|
xiaomiMiioPlatform: entityArg.platform,
|
||||||
|
propertyKey: entityArg.propertyKey || entityArg.property_key,
|
||||||
|
deviceClass: entityArg.deviceClass || entityArg.device_class,
|
||||||
|
unit: entityArg.unit,
|
||||||
|
writable: entityArg.writable === true,
|
||||||
|
},
|
||||||
|
available: entityArg.available !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetEntity(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||||
|
}
|
||||||
|
if (requestArg.target.deviceId) {
|
||||||
|
return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'vacuum') {
|
||||||
|
return entities.find((entityArg) => entityArg.id.startsWith('vacuum.'));
|
||||||
|
}
|
||||||
|
return entities.find((entityArg) => entityArg.id.startsWith(`${requestArg.domain}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetDevice(snapshotArg: IXiaomiMiioSnapshot, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IXiaomiMiioDeviceState | undefined {
|
||||||
|
const devices = this.allDevices(snapshotArg);
|
||||||
|
const deviceId = requestArg.target.deviceId || entityArg?.deviceId;
|
||||||
|
if (deviceId) {
|
||||||
|
return devices.find((deviceArg) => this.deviceId(deviceArg) === deviceId || this.rawDeviceKey(deviceArg) === deviceId);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'vacuum') {
|
||||||
|
return devices.find((deviceArg) => this.inferDeviceKind(deviceArg) === 'vacuum');
|
||||||
|
}
|
||||||
|
return devices[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static kindForCommand(requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity, deviceArg?: IXiaomiMiioDeviceState): TXiaomiMiioDeviceKind {
|
||||||
|
const attrKind = this.stringValue(entityArg?.attributes?.xiaomiMiioKind);
|
||||||
|
if (attrKind) {
|
||||||
|
return attrKind;
|
||||||
|
}
|
||||||
|
if (deviceArg) {
|
||||||
|
return this.inferDeviceKind(deviceArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'vacuum') {
|
||||||
|
return 'vacuum';
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'light' || requestArg.domain === 'switch' || requestArg.domain === 'fan' || requestArg.domain === 'cover') {
|
||||||
|
return requestArg.domain;
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'humidifier' || requestArg.domain === 'climate') {
|
||||||
|
return 'humidifier';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static command(requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined, deviceArg: IXiaomiMiioDeviceState | undefined, kindArg: TXiaomiMiioDeviceKind, methodArg: string, paramsArg: unknown[], payloadArg: Record<string, unknown>): IXiaomiMiioClientCommand {
|
||||||
|
return {
|
||||||
|
type: 'service.command',
|
||||||
|
service: requestArg.service,
|
||||||
|
method: methodArg,
|
||||||
|
params: paramsArg,
|
||||||
|
platform: entityArg?.attributes?.xiaomiMiioPlatform as string | undefined || requestArg.domain,
|
||||||
|
kind: kindArg,
|
||||||
|
deviceId: entityArg?.deviceId || (deviceArg ? this.deviceId(deviceArg) : requestArg.target.deviceId),
|
||||||
|
entityId: entityArg?.id || requestArg.target.entityId,
|
||||||
|
uniqueId: entityArg?.uniqueId,
|
||||||
|
propertyKey: this.stringValue(payloadArg.propertyKey || entityArg?.attributes?.propertyKey),
|
||||||
|
value: payloadArg.value,
|
||||||
|
target: requestArg.target,
|
||||||
|
payload: { ...payloadArg, data: requestArg.data || {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static featureForProperty(propertyArg: IXiaomiMiioProperty, deviceArg: IXiaomiMiioDeviceState): plugins.shxInterfaces.data.IDeviceFeature {
|
||||||
|
const kind = this.inferDeviceKind(deviceArg);
|
||||||
|
return {
|
||||||
|
id: this.slug(propertyArg.key),
|
||||||
|
capability: this.capabilityForPlatform(propertyArg.platform || this.platformForProperty(propertyArg.key, propertyArg.value, kind)),
|
||||||
|
name: propertyArg.name || this.title(propertyArg.key),
|
||||||
|
readable: propertyArg.readable !== false,
|
||||||
|
writable: propertyArg.writable === true || writableKeys.has(propertyArg.key),
|
||||||
|
unit: propertyArg.unit || sensorUnits[propertyArg.key],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static platformForProperty(keyArg: string, valueArg: unknown, kindArg: TXiaomiMiioDeviceKind): TEntityPlatform {
|
||||||
|
if (keyArg === 'brightness' || keyArg === 'color_temperature' || keyArg === 'rgb' || kindArg === 'light' && ['is_on', 'on', 'power'].includes(keyArg)) {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
if (keyArg === 'position' || keyArg === 'current_position' || keyArg === 'target_position' || kindArg === 'cover') {
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number' && numberControlKeys.has(keyArg)) {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
if (kindArg === 'fan' && ['is_on', 'on', 'power', 'speed', 'percentage', 'fan_level', 'oscillate', 'oscillating'].includes(keyArg)) {
|
||||||
|
return 'fan';
|
||||||
|
}
|
||||||
|
if (kindArg === 'humidifier' && ['is_on', 'on', 'power', 'target_humidity', 'mode'].includes(keyArg)) {
|
||||||
|
return typeof valueArg === 'number' && keyArg === 'target_humidity' ? 'number' : 'climate';
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number' && writableKeys.has(keyArg) && !sensorKeys.has(keyArg)) {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'boolean' && !['is_on', 'on', 'power'].includes(keyArg)) {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
if (kindArg === 'switch' && ['is_on', 'on', 'power'].includes(keyArg)) {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static inferDeviceKind(deviceArg: IXiaomiMiioDeviceState): TXiaomiMiioDeviceKind {
|
||||||
|
const explicit = this.stringValue(deviceArg.kind || deviceArg.type || deviceArg.platform)?.toLowerCase();
|
||||||
|
if (explicit && explicit !== 'device') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const model = (deviceArg.model || '').toLowerCase();
|
||||||
|
const state = this.normalizedStateShallow(deviceArg);
|
||||||
|
if (model.includes('vacuum') || model.startsWith('roborock.') || model.startsWith('rockrobo.') || state.state_code !== undefined && (state.clean_area !== undefined || state.battery !== undefined)) {
|
||||||
|
return 'vacuum';
|
||||||
|
}
|
||||||
|
if (model.includes('humidifier') || model.includes('deerma.humidifier') || state.target_humidity !== undefined) {
|
||||||
|
return 'humidifier';
|
||||||
|
}
|
||||||
|
if (model.includes('airpurifier') || model.includes('airfresh') || model.includes('.fan.') || state.fan_level !== undefined || state.oscillate !== undefined || state.motor_speed !== undefined) {
|
||||||
|
return 'fan';
|
||||||
|
}
|
||||||
|
if (model.includes('light') || state.brightness !== undefined || state.color_temperature !== undefined || state.rgb !== undefined) {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
if (model.includes('plug') || model.includes('powerstrip') || model.includes('switch')) {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
if (model.includes('curtain') || model.includes('cover') || state.position !== undefined || state.current_position !== undefined) {
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
if (model.includes('gateway')) {
|
||||||
|
return 'gateway';
|
||||||
|
}
|
||||||
|
if (model.includes('airmonitor') || state.aqi !== undefined || state.pm25 !== undefined) {
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
if (state.is_on !== undefined || state.power !== undefined) {
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizedStateShallow(deviceArg: IXiaomiMiioDeviceState): IXiaomiMiioStateRecord {
|
||||||
|
const state: IXiaomiMiioStateRecord = { ...this.asRecord(deviceArg.state) };
|
||||||
|
for (const key of topLevelStateKeys) {
|
||||||
|
if (deviceArg[key] !== undefined && state[key] === undefined) {
|
||||||
|
state[key] = deviceArg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.isRecord(state.status)) {
|
||||||
|
for (const [key, value] of Object.entries(state.status)) {
|
||||||
|
if (state[key] === undefined) {
|
||||||
|
state[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static primaryEntityState(kindArg: TXiaomiMiioDeviceKind, stateArg: IXiaomiMiioStateRecord): unknown {
|
||||||
|
if (kindArg === 'vacuum') {
|
||||||
|
return this.vacuumActivity(stateArg);
|
||||||
|
}
|
||||||
|
if (kindArg === 'cover') {
|
||||||
|
const position = this.numberValue(stateArg.current_position ?? stateArg.position);
|
||||||
|
if (position !== undefined) {
|
||||||
|
return position <= 0 ? 'closed' : 'open';
|
||||||
|
}
|
||||||
|
return stateArg.state ?? 'unknown';
|
||||||
|
}
|
||||||
|
if (kindArg === 'sensor') {
|
||||||
|
return stateArg.aqi ?? stateArg.pm25 ?? stateArg.temperature ?? stateArg.humidity ?? 'unknown';
|
||||||
|
}
|
||||||
|
return this.powerState(stateArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static vacuumActivity(stateArg: IXiaomiMiioStateRecord): string {
|
||||||
|
const stateCode = this.numberValue(stateArg.state_code);
|
||||||
|
if (stateCode !== undefined) {
|
||||||
|
if ([5, 7, 11, 16, 17, 18].includes(stateCode)) {
|
||||||
|
return 'cleaning';
|
||||||
|
}
|
||||||
|
if ([6, 15, 26].includes(stateCode)) {
|
||||||
|
return 'returning';
|
||||||
|
}
|
||||||
|
if ([8, 22, 23, 100].includes(stateCode)) {
|
||||||
|
return 'docked';
|
||||||
|
}
|
||||||
|
if (stateCode === 10) {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
if ([9, 12, 101].includes(stateCode)) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
return this.stringValue(stateArg.state || stateArg.status) || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static powerState(stateArg: IXiaomiMiioStateRecord): string {
|
||||||
|
const value = stateArg.is_on ?? stateArg.on ?? stateArg.power;
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (['on', 'true', '1'].includes(normalized)) {
|
||||||
|
return 'on';
|
||||||
|
}
|
||||||
|
if (['off', 'false', '0'].includes(normalized)) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityDeviceId(snapshotArg: IXiaomiMiioSnapshot, entityArg: IXiaomiMiioEntityDescriptor): string {
|
||||||
|
const explicit = entityArg.deviceId || entityArg.device_id;
|
||||||
|
if (explicit) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const device = this.allDevices(snapshotArg).find((deviceArg) => entityArg.deviceId === this.deviceId(deviceArg) || entityArg.device_id === this.deviceId(deviceArg));
|
||||||
|
return device ? this.deviceId(device) : this.deviceId(snapshotArg.device || snapshotArg.devices[0] || { id: 'configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceId(deviceArg: IXiaomiMiioDeviceState): string {
|
||||||
|
return `xiaomi_miio.device.${this.slug(this.rawDeviceKey(deviceArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rawDeviceKey(deviceArg: IXiaomiMiioDeviceState): string {
|
||||||
|
return String(deviceArg.id || deviceArg.did || deviceArg.macAddress || deviceArg.host || deviceArg.model || deviceArg.name || 'configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(deviceArg: IXiaomiMiioDeviceState): string {
|
||||||
|
return deviceArg.name || deviceArg.model || deviceArg.host || 'Xiaomi Miio device';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityDomainForKind(kindArg: TXiaomiMiioDeviceKind): string | undefined {
|
||||||
|
if (kindArg === 'vacuum') {
|
||||||
|
return 'vacuum';
|
||||||
|
}
|
||||||
|
if (kindArg === 'humidifier') {
|
||||||
|
return 'humidifier';
|
||||||
|
}
|
||||||
|
if (kindArg === 'light' || kindArg === 'switch' || kindArg === 'fan' || kindArg === 'cover') {
|
||||||
|
return kindArg;
|
||||||
|
}
|
||||||
|
if (kindArg === 'sensor' || kindArg === 'gateway') {
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityDomainForExplicit(platformArg: unknown): string {
|
||||||
|
const platform = String(platformArg || 'sensor');
|
||||||
|
if (platform === 'vacuum' || platform === 'humidifier') {
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
return this.corePlatform(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static corePlatform(platformArg: unknown): TEntityPlatform {
|
||||||
|
const platform = String(platformArg || 'sensor').toLowerCase();
|
||||||
|
if (platform === 'vacuum') {
|
||||||
|
return 'sensor';
|
||||||
|
}
|
||||||
|
if (platform === 'humidifier') {
|
||||||
|
return 'climate';
|
||||||
|
}
|
||||||
|
const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update'];
|
||||||
|
return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static capabilityForPlatform(platformArg: unknown): plugins.shxInterfaces.data.TDeviceCapability {
|
||||||
|
const platform = String(platformArg || 'sensor').toLowerCase();
|
||||||
|
if (platform === 'light') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
if (platform === 'cover') {
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
if (platform === 'fan' || platform === 'vacuum') {
|
||||||
|
return 'fan';
|
||||||
|
}
|
||||||
|
if (platform === 'climate' || platform === 'humidifier') {
|
||||||
|
return 'climate';
|
||||||
|
}
|
||||||
|
return platform === 'switch' || platform === 'number' || platform === 'select' || platform === 'button' ? 'switch' : 'sensor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entityStateValue(valueArg: unknown): unknown {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return Array.isArray(valueArg) ? JSON.stringify(valueArg) : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static asRecord(valueArg: unknown): Record<string, unknown> {
|
||||||
|
return this.isRecord(valueArg) ? valueArg : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static title(valueArg: string): string {
|
||||||
|
return valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return String(valueArg).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'xiaomi_miio';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,208 @@
|
|||||||
export interface IHomeAssistantXiaomiMiioConfig {
|
import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for xiaomi_miio.
|
|
||||||
|
export type TXiaomiMiioDeviceKind =
|
||||||
|
| 'vacuum'
|
||||||
|
| 'fan'
|
||||||
|
| 'light'
|
||||||
|
| 'switch'
|
||||||
|
| 'sensor'
|
||||||
|
| 'cover'
|
||||||
|
| 'humidifier'
|
||||||
|
| 'gateway'
|
||||||
|
| 'unknown'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export type TXiaomiMiioEntityPlatform = TEntityPlatform | 'vacuum' | 'humidifier' | string;
|
||||||
|
|
||||||
|
export interface IXiaomiMiioConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
token?: string;
|
||||||
|
model?: string;
|
||||||
|
name?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
kind?: TXiaomiMiioDeviceKind;
|
||||||
|
device?: IXiaomiMiioDeviceState;
|
||||||
|
devices?: IXiaomiMiioDeviceState[];
|
||||||
|
entities?: IXiaomiMiioEntityDescriptor[];
|
||||||
|
state?: IXiaomiMiioStateRecord;
|
||||||
|
properties?: IXiaomiMiioProperty[];
|
||||||
|
snapshot?: IXiaomiMiioSnapshot;
|
||||||
|
events?: IXiaomiMiioEvent[];
|
||||||
|
commandExecutor?: TXiaomiMiioCommandExecutor;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantXiaomiMiioConfig extends IXiaomiMiioConfig {}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioDeviceInfo {
|
||||||
|
id?: string;
|
||||||
|
did?: string | number;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
macAddress?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
hardwareVersion?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
kind?: TXiaomiMiioDeviceKind;
|
||||||
|
tokenConfigured?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioDeviceState extends IXiaomiMiioDeviceInfo {
|
||||||
|
available?: boolean;
|
||||||
|
online?: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
state?: IXiaomiMiioStateRecord;
|
||||||
|
properties?: IXiaomiMiioProperty[];
|
||||||
|
entities?: IXiaomiMiioEntityDescriptor[];
|
||||||
|
children?: IXiaomiMiioDeviceState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IXiaomiMiioStateRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface IXiaomiMiioProperty {
|
||||||
|
key: string;
|
||||||
|
value?: unknown;
|
||||||
|
name?: string;
|
||||||
|
unit?: string;
|
||||||
|
readable?: boolean;
|
||||||
|
writable?: boolean;
|
||||||
|
platform?: TXiaomiMiioEntityPlatform;
|
||||||
|
kind?: TXiaomiMiioDeviceKind;
|
||||||
|
deviceClass?: string;
|
||||||
|
category?: string;
|
||||||
|
siid?: number;
|
||||||
|
piid?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioEntityDescriptor {
|
||||||
|
id?: string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
unique_id?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
device_id?: string;
|
||||||
|
platform?: TXiaomiMiioEntityPlatform;
|
||||||
|
kind?: TXiaomiMiioDeviceKind;
|
||||||
|
name?: string;
|
||||||
|
state?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
propertyKey?: string;
|
||||||
|
property_key?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
available?: boolean;
|
||||||
|
writable?: boolean;
|
||||||
|
unit?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
device_class?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioSnapshot {
|
||||||
|
connected: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
tokenConfigured?: boolean;
|
||||||
|
device?: IXiaomiMiioDeviceState;
|
||||||
|
devices: IXiaomiMiioDeviceState[];
|
||||||
|
entities: IXiaomiMiioEntityDescriptor[];
|
||||||
|
events: IXiaomiMiioEvent[];
|
||||||
|
transport?: IXiaomiMiioTransportInfo;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioTransportInfo {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol: 'miio-udp' | 'snapshot' | 'manual' | string;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
encryptedUdpImplemented: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
command?: IXiaomiMiioClientCommand;
|
||||||
|
data?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioClientCommand {
|
||||||
|
type: string;
|
||||||
|
service: string;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown[] | Record<string, unknown>;
|
||||||
|
platform?: TXiaomiMiioEntityPlatform;
|
||||||
|
kind?: TXiaomiMiioDeviceKind;
|
||||||
|
deviceId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
propertyKey?: string;
|
||||||
|
value?: unknown;
|
||||||
|
target?: {
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
};
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioCommandResult extends IServiceCallResult {}
|
||||||
|
|
||||||
|
export type TXiaomiMiioCommandExecutor = (
|
||||||
|
commandArg: IXiaomiMiioClientCommand
|
||||||
|
) => Promise<IXiaomiMiioCommandResult | unknown> | IXiaomiMiioCommandResult | unknown;
|
||||||
|
|
||||||
|
export interface IXiaomiMiioMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
serviceType?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioDhcpRecord {
|
||||||
|
host?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
address?: string;
|
||||||
|
hostname?: string;
|
||||||
|
hostName?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
mac?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
vendorClassIdentifier?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IXiaomiMiioManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
token?: string;
|
||||||
|
model?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
snapshot?: IXiaomiMiioSnapshot;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './yeelight.classes.client.js';
|
||||||
|
export * from './yeelight.classes.configflow.js';
|
||||||
export * from './yeelight.classes.integration.js';
|
export * from './yeelight.classes.integration.js';
|
||||||
|
export * from './yeelight.discovery.js';
|
||||||
|
export * from './yeelight.mapper.js';
|
||||||
export * from './yeelight.types.js';
|
export * from './yeelight.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IYeelightBulbInfo,
|
||||||
|
IYeelightBulbProperties,
|
||||||
|
IYeelightCommand,
|
||||||
|
IYeelightCommandResponse,
|
||||||
|
IYeelightCommandResult,
|
||||||
|
IYeelightConfig,
|
||||||
|
IYeelightEvent,
|
||||||
|
IYeelightLightStatePatch,
|
||||||
|
IYeelightManualDeviceConfig,
|
||||||
|
IYeelightSnapshot,
|
||||||
|
TYeelightEffect,
|
||||||
|
} from './yeelight.types.js';
|
||||||
|
|
||||||
|
type TYeelightEventHandler = (eventArg: IYeelightEvent) => void;
|
||||||
|
|
||||||
|
const defaultPort = 55443;
|
||||||
|
const defaultEffect: TYeelightEffect = 'smooth';
|
||||||
|
const defaultTransition = 350;
|
||||||
|
const defaultPropertyRequest = [
|
||||||
|
'power',
|
||||||
|
'main_power',
|
||||||
|
'bright',
|
||||||
|
'ct',
|
||||||
|
'rgb',
|
||||||
|
'hue',
|
||||||
|
'sat',
|
||||||
|
'color_mode',
|
||||||
|
'flowing',
|
||||||
|
'delayoff',
|
||||||
|
'music_on',
|
||||||
|
'name',
|
||||||
|
'bg_power',
|
||||||
|
'bg_flowing',
|
||||||
|
'bg_ct',
|
||||||
|
'bg_bright',
|
||||||
|
'bg_hue',
|
||||||
|
'bg_sat',
|
||||||
|
'bg_rgb',
|
||||||
|
'bg_lmode',
|
||||||
|
'nl_br',
|
||||||
|
'active_mode',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class YeelightClient {
|
||||||
|
private commandId = 1;
|
||||||
|
private cachedSnapshot?: IYeelightSnapshot;
|
||||||
|
private readonly events: IYeelightEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TYeelightEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IYeelightConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IYeelightSnapshot> {
|
||||||
|
if (this.cachedSnapshot) {
|
||||||
|
return this.withEvents(this.cachedSnapshot);
|
||||||
|
}
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.cachedSnapshot = this.withEvents({ ...this.config.snapshot, source: this.config.snapshot.source || 'snapshot' });
|
||||||
|
return this.cachedSnapshot;
|
||||||
|
}
|
||||||
|
const manualBulbs = this.manualBulbs();
|
||||||
|
if (manualBulbs.length) {
|
||||||
|
this.cachedSnapshot = this.withEvents({ connected: false, bulbs: manualBulbs, events: [], source: 'manual', updatedAt: new Date().toISOString() });
|
||||||
|
return this.cachedSnapshot;
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Yeelight snapshot requires config.snapshot, config.bulb/config.bulbs/config.devices, or a host for local TCP get_prop.');
|
||||||
|
}
|
||||||
|
const properties = await this.getProperties(defaultPropertyRequest);
|
||||||
|
this.cachedSnapshot = this.withEvents({
|
||||||
|
connected: true,
|
||||||
|
bulbs: [this.bulbFromConfig(properties)],
|
||||||
|
events: [],
|
||||||
|
source: 'tcp',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return this.cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TYeelightEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProperties(propertiesArg: string[] = defaultPropertyRequest): Promise<IYeelightBulbProperties> {
|
||||||
|
const response = await this.sendCommand({ method: 'get_prop', params: propertiesArg });
|
||||||
|
const result = response.result || [];
|
||||||
|
const properties: IYeelightBulbProperties = {};
|
||||||
|
for (const [index, property] of propertiesArg.entries()) {
|
||||||
|
const value = result[index];
|
||||||
|
properties[property] = value === '' || value === undefined ? null : String(value);
|
||||||
|
}
|
||||||
|
this.emit({ type: 'properties', host: this.config.host, data: properties, timestamp: Date.now() });
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setLightState(patchArg: IYeelightLightStatePatch): Promise<void> {
|
||||||
|
const effect = patchArg.effect || this.config.effect || defaultEffect;
|
||||||
|
const transition = this.transitionValue(patchArg.transition);
|
||||||
|
if (patchArg.on !== undefined) {
|
||||||
|
await this.setPower(patchArg.on, effect, transition);
|
||||||
|
}
|
||||||
|
if (patchArg.colorTempKelvin !== undefined) {
|
||||||
|
await this.setColorTemp(patchArg.colorTempKelvin, effect, transition);
|
||||||
|
}
|
||||||
|
if (patchArg.rgbColor !== undefined) {
|
||||||
|
await this.setRgb(patchArg.rgbColor, effect, transition);
|
||||||
|
}
|
||||||
|
const brightness = patchArg.brightness ?? patchArg.percentage;
|
||||||
|
if (brightness !== undefined) {
|
||||||
|
await this.setBrightness(brightness, effect, transition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPower(onArg: boolean, effectArg = defaultEffect, durationArg = defaultTransition): Promise<void> {
|
||||||
|
await this.sendCommand({ method: 'set_power', params: [onArg ? 'on' : 'off', effectArg, durationArg] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setBrightness(brightnessArg: number, effectArg = defaultEffect, durationArg = defaultTransition): Promise<void> {
|
||||||
|
await this.sendCommand({ method: 'set_bright', params: [this.clamp(Math.round(brightnessArg), 1, 100), effectArg, durationArg] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setColorTemp(kelvinArg: number, effectArg = defaultEffect, durationArg = defaultTransition): Promise<void> {
|
||||||
|
await this.sendCommand({ method: 'set_ct_abx', params: [this.clamp(Math.round(kelvinArg), 1700, 6500), effectArg, durationArg] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRgb(rgbArg: [number, number, number], effectArg = defaultEffect, durationArg = defaultTransition): Promise<void> {
|
||||||
|
const [red, green, blue] = rgbArg.map((valueArg) => this.clamp(Math.round(valueArg), 0, 255));
|
||||||
|
await this.sendCommand({ method: 'set_rgb', params: [red * 65536 + green * 256 + blue, effectArg, durationArg] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IYeelightCommand): Promise<IYeelightCommandResponse> {
|
||||||
|
const command = {
|
||||||
|
...commandArg,
|
||||||
|
id: commandArg.id ?? this.commandId++,
|
||||||
|
host: commandArg.host || this.config.host,
|
||||||
|
port: commandArg.port || this.config.port || defaultPort,
|
||||||
|
params: commandArg.params || [],
|
||||||
|
};
|
||||||
|
this.emit({ type: 'command', host: command.host, command, timestamp: Date.now() });
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
return this.commandExecutorResponse(await this.config.commandExecutor(command), command);
|
||||||
|
}
|
||||||
|
if (!command.host) {
|
||||||
|
throw new Error('Yeelight live TCP commands require config.host. Snapshot/manual configs are read-only without commandExecutor.');
|
||||||
|
}
|
||||||
|
return this.sendTcpCommand({ ...command, host: command.host });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private manualBulbs(): IYeelightBulbInfo[] {
|
||||||
|
const bulbs: IYeelightBulbInfo[] = [];
|
||||||
|
if (this.config.bulb) {
|
||||||
|
bulbs.push(this.config.bulb);
|
||||||
|
}
|
||||||
|
if (this.config.bulbs) {
|
||||||
|
bulbs.push(...this.config.bulbs);
|
||||||
|
}
|
||||||
|
if (this.config.devices) {
|
||||||
|
for (const [host, deviceConfig] of Object.entries(this.config.devices)) {
|
||||||
|
bulbs.push(this.bulbFromManualDevice(host, deviceConfig));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bulbs.length && this.config.host && (this.config.name || this.config.model || this.config.id)) {
|
||||||
|
bulbs.push(this.bulbFromConfig());
|
||||||
|
}
|
||||||
|
return bulbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bulbFromManualDevice(hostArg: string, configArg: IYeelightManualDeviceConfig): IYeelightBulbInfo {
|
||||||
|
return {
|
||||||
|
id: configArg.id,
|
||||||
|
host: hostArg,
|
||||||
|
port: configArg.port || defaultPort,
|
||||||
|
name: configArg.name,
|
||||||
|
model: configArg.model || configArg.detectedModel || configArg.detected_model,
|
||||||
|
capabilities: configArg.capabilities,
|
||||||
|
properties: configArg.properties,
|
||||||
|
support: configArg.capabilities?.support?.split(' ').filter(Boolean),
|
||||||
|
available: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bulbFromConfig(propertiesArg?: IYeelightBulbProperties): IYeelightBulbInfo {
|
||||||
|
return {
|
||||||
|
id: this.config.id,
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || defaultPort,
|
||||||
|
name: this.config.name || propertiesArg?.name || undefined,
|
||||||
|
model: this.config.model || this.config.detectedModel || this.config.detected_model,
|
||||||
|
properties: propertiesArg,
|
||||||
|
available: Boolean(propertiesArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendTcpCommand(commandArg: Required<Pick<IYeelightCommand, 'id' | 'method' | 'params' | 'host' | 'port'>>): Promise<IYeelightCommandResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let buffer = '';
|
||||||
|
const socket = plugins.net.createConnection({ host: commandArg.host, port: commandArg.port });
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finish(new Error(`Yeelight TCP command ${commandArg.method} timed out for ${commandArg.host}:${commandArg.port}.`));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const finish = (errorArg?: Error, responseArg?: IYeelightCommandResponse) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
if (errorArg) {
|
||||||
|
this.emit({ type: 'error', host: commandArg.host, command: commandArg, error: errorArg.message, timestamp: Date.now() });
|
||||||
|
reject(errorArg);
|
||||||
|
} else {
|
||||||
|
resolve(responseArg || { id: commandArg.id, result: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.write(`${JSON.stringify({ id: commandArg.id, method: commandArg.method, params: commandArg.params })}\r\n`);
|
||||||
|
});
|
||||||
|
socket.on('data', (chunkArg) => {
|
||||||
|
buffer += chunkArg.toString('utf8');
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const response = this.parseResponseLine(line);
|
||||||
|
if (!response) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.method === 'props') {
|
||||||
|
this.emit({ type: 'properties', host: commandArg.host, data: response.params, timestamp: Date.now() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
finish(new Error(`Yeelight command ${commandArg.method} failed: ${JSON.stringify(response.error)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(undefined, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', (errorArg) => finish(errorArg));
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (!settled) {
|
||||||
|
const response = this.parseResponseLine(buffer);
|
||||||
|
if (response && !response.error) {
|
||||||
|
finish(undefined, response);
|
||||||
|
} else {
|
||||||
|
finish(new Error(`Yeelight TCP connection closed before ${commandArg.method} response.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseResponseLine(lineArg: string): IYeelightCommandResponse | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(lineArg) as IYeelightCommandResponse;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandExecutorResponse(resultArg: unknown, commandArg: IYeelightCommand): IYeelightCommandResponse {
|
||||||
|
if (this.isCommandResponse(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
if (!resultArg.success) {
|
||||||
|
throw new Error(resultArg.error || `Yeelight command ${commandArg.method} failed.`);
|
||||||
|
}
|
||||||
|
return { id: commandArg.id, result: [resultArg.data ?? 'ok'] };
|
||||||
|
}
|
||||||
|
return { id: commandArg.id, result: [resultArg ?? 'ok'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private withEvents(snapshotArg: IYeelightSnapshot): IYeelightSnapshot {
|
||||||
|
return { ...snapshotArg, events: [...(snapshotArg.events || []), ...this.events] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IYeelightEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionValue(valueArg?: number): number {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return Math.round(valueArg >= 0 && valueArg < 100 ? valueArg * 1000 : valueArg);
|
||||||
|
}
|
||||||
|
if (typeof this.config.transition === 'number' && Number.isFinite(this.config.transition)) {
|
||||||
|
return this.config.transition;
|
||||||
|
}
|
||||||
|
return defaultTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||||
|
return Math.min(maxArg, Math.max(minArg, valueArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResponse(valueArg: unknown): valueArg is IYeelightCommandResponse {
|
||||||
|
return this.isRecord(valueArg) && ('result' in valueArg || 'error' in valueArg || 'method' in valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IYeelightCommandResult {
|
||||||
|
return this.isRecord(valueArg) && typeof valueArg.success === 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IYeelightConfig } from './yeelight.types.js';
|
||||||
|
|
||||||
|
const defaultPort = 55443;
|
||||||
|
|
||||||
|
export class YeelightConfigFlow implements IConfigFlow<IYeelightConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IYeelightConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Yeelight',
|
||||||
|
description: 'Configure local LAN control for a Yeelight bulb. LAN control must be enabled on the bulb for live commands.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number' },
|
||||||
|
{ name: 'model', label: 'Model', type: 'text' },
|
||||||
|
{ name: 'name', label: 'Name', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'Missing Yeelight host', error: 'Host is required for Yeelight local LAN control.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Yeelight configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port: this.numberValue(valuesArg.port) || candidateArg.port || defaultPort,
|
||||||
|
model: this.stringValue(valuesArg.model) || candidateArg.model,
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
id: candidateArg.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,131 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
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 { YeelightClient } from './yeelight.classes.client.js';
|
||||||
|
import { YeelightConfigFlow } from './yeelight.classes.configflow.js';
|
||||||
|
import { createYeelightDiscoveryDescriptor } from './yeelight.discovery.js';
|
||||||
|
import { YeelightMapper } from './yeelight.mapper.js';
|
||||||
|
import type { IYeelightConfig, IYeelightLightStatePatch } from './yeelight.types.js';
|
||||||
|
|
||||||
export class HomeAssistantYeelightIntegration extends DescriptorOnlyIntegration {
|
export class YeelightIntegration extends BaseIntegration<IYeelightConfig> {
|
||||||
constructor() {
|
public readonly domain = 'yeelight';
|
||||||
super({
|
public readonly displayName = 'Yeelight';
|
||||||
domain: "yeelight",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Yeelight",
|
public readonly discoveryDescriptor = createYeelightDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new YeelightConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/yeelight",
|
upstreamPath: 'homeassistant/components/yeelight',
|
||||||
"upstreamDomain": "yeelight",
|
upstreamDomain: 'yeelight',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
documentation: 'https://www.home-assistant.io/integrations/yeelight',
|
||||||
"yeelight==0.7.16",
|
requirements: ['yeelight==0.7.16', 'async-upnp-client==0.46.2'],
|
||||||
"async-upnp-client==0.46.2"
|
dependencies: ['network'],
|
||||||
],
|
afterDependencies: ['ssdp'],
|
||||||
"dependencies": [
|
codeowners: ['@zewelor', '@shenxn', '@starkillerOG', '@alexyao2015'],
|
||||||
"network"
|
};
|
||||||
],
|
|
||||||
"afterDependencies": [
|
public async setup(configArg: IYeelightConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"ssdp"
|
void contextArg;
|
||||||
],
|
return new YeelightRuntime(new YeelightClient(configArg));
|
||||||
"codeowners": [
|
}
|
||||||
"@zewelor",
|
|
||||||
"@shenxn",
|
public async destroy(): Promise<void> {}
|
||||||
"@starkillerOG",
|
}
|
||||||
"@alexyao2015"
|
|
||||||
]
|
export class HomeAssistantYeelightIntegration extends YeelightIntegration {}
|
||||||
},
|
|
||||||
});
|
class YeelightRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'yeelight';
|
||||||
|
|
||||||
|
constructor(private readonly client: YeelightClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return YeelightMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return YeelightMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: ReturnType<typeof YeelightMapper.toIntegrationEvent>) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(YeelightMapper.toIntegrationEvent(eventArg)));
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (!['light', 'number'].includes(requestArg.domain)) {
|
||||||
|
return { success: false, error: `Unsupported Yeelight service domain: ${requestArg.domain}` };
|
||||||
|
}
|
||||||
|
const patch = this.patchForService(requestArg);
|
||||||
|
if (!patch) {
|
||||||
|
return { success: false, error: `Unsupported Yeelight service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
await this.client.setLightState(patch);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchForService(requestArg: IServiceCallRequest): IYeelightLightStatePatch | undefined {
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
return { on: true, ...this.patchFromData(requestArg.data) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
return { on: false, transition: this.numberValue(requestArg.data?.transition) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_brightness' || requestArg.service === 'set_percentage') {
|
||||||
|
const value = this.numberValue(requestArg.data?.brightness ?? requestArg.data?.percentage ?? requestArg.data?.value);
|
||||||
|
return value === undefined ? undefined : { percentage: value, transition: this.numberValue(requestArg.data?.transition) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_value') {
|
||||||
|
const property = String(requestArg.data?.property || requestArg.data?.attribute || '').toLowerCase();
|
||||||
|
const value = requestArg.data?.value;
|
||||||
|
if (property === 'rgb' || property === 'rgb_color') {
|
||||||
|
const rgb = this.rgbValue(value);
|
||||||
|
return rgb ? { rgbColor: rgb, transition: this.numberValue(requestArg.data?.transition) } : undefined;
|
||||||
|
}
|
||||||
|
if (property === 'ct' || property === 'color_temp' || property === 'color_temp_kelvin' || property === 'kelvin') {
|
||||||
|
const kelvin = this.numberValue(value);
|
||||||
|
return kelvin === undefined ? undefined : { colorTempKelvin: kelvin, transition: this.numberValue(requestArg.data?.transition) };
|
||||||
|
}
|
||||||
|
const numeric = this.numberValue(value);
|
||||||
|
return numeric === undefined ? undefined : { percentage: numeric, transition: this.numberValue(requestArg.data?.transition) };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchFromData(dataArg?: Record<string, unknown>): IYeelightLightStatePatch {
|
||||||
|
const rgb = this.rgbValue(dataArg?.rgbColor ?? dataArg?.rgb_color);
|
||||||
|
return {
|
||||||
|
brightness: this.numberValue(dataArg?.brightness),
|
||||||
|
percentage: this.numberValue(dataArg?.percentage),
|
||||||
|
colorTempKelvin: this.numberValue(dataArg?.colorTempKelvin ?? dataArg?.color_temp_kelvin ?? dataArg?.kelvin),
|
||||||
|
rgbColor: rgb,
|
||||||
|
transition: this.numberValue(dataArg?.transition),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rgbValue(valueArg: unknown): [number, number, number] | undefined {
|
||||||
|
if (!Array.isArray(valueArg) || valueArg.length < 3) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const red = this.numberValue(valueArg[0]);
|
||||||
|
const green = this.numberValue(valueArg[1]);
|
||||||
|
const blue = this.numberValue(valueArg[2]);
|
||||||
|
return red === undefined || green === undefined || blue === undefined ? undefined : [red, green, blue];
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IYeelightManualEntry, IYeelightMdnsRecord, IYeelightSsdpRecord } from './yeelight.types.js';
|
||||||
|
|
||||||
|
const defaultPort = 55443;
|
||||||
|
|
||||||
|
export class YeelightSsdpMatcher implements IDiscoveryMatcher<IYeelightSsdpRecord> {
|
||||||
|
public id = 'yeelight-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Yeelight local LAN SSDP wifi_bulb responses.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IYeelightSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const headers = this.normalizedHeaders(recordArg);
|
||||||
|
const location = headers.location;
|
||||||
|
const support = headers.support || '';
|
||||||
|
if (!headers.id || !location || !(headers.st === 'wifi_bulb' || support.includes('set_power') || headers.model)) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP response is not a Yeelight wifi_bulb advertisement.' };
|
||||||
|
}
|
||||||
|
const endpoint = this.parseLocation(location);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: headers.id && endpoint.host ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP response contains Yeelight LAN control capabilities.',
|
||||||
|
normalizedDeviceId: headers.id,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
id: headers.id,
|
||||||
|
host: endpoint.host,
|
||||||
|
port: endpoint.port || defaultPort,
|
||||||
|
name: headers.name,
|
||||||
|
manufacturer: 'Yeelight',
|
||||||
|
model: headers.model,
|
||||||
|
metadata: {
|
||||||
|
location,
|
||||||
|
support: support.split(' ').filter(Boolean),
|
||||||
|
firmwareVersion: headers.fw_ver,
|
||||||
|
capabilities: headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizedHeaders(recordArg: IYeelightSsdpRecord): Record<string, string | undefined> {
|
||||||
|
const source = { ...recordArg.headers, ...recordArg.ssdp_headers, ...recordArg } as Record<string, unknown>;
|
||||||
|
const normalized: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
normalized[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLocation(locationArg: string): { host?: string; port?: number } {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(locationArg);
|
||||||
|
return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
|
||||||
|
} catch {
|
||||||
|
const match = locationArg.match(/([0-9a-z.-]+):(\d+)/i);
|
||||||
|
return match ? { host: match[1], port: Number(match[2]) } : {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YeelightMdnsMatcher implements IDiscoveryMatcher<IYeelightMdnsRecord> {
|
||||||
|
public id = 'yeelight-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Yeelight zeroconf records from yeelink names and _miio._udp.local.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IYeelightMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = (recordArg.type || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
const name = (recordArg.name || recordArg.hostname || '').toLowerCase();
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const model = txt.model || txt.modelid || recordArg.txt?.md || undefined;
|
||||||
|
const matched = name.startsWith('yeelink-') || type === '_miio._udp.local' || Boolean(model?.startsWith('YL'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Yeelight advertisement.' };
|
||||||
|
}
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const deviceId = txt.id || txt.mac || txt.serial || recordArg.name || host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: host ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches Yeelight zeroconf metadata.',
|
||||||
|
normalizedDeviceId: deviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
id: deviceId,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || defaultPort,
|
||||||
|
name: recordArg.name,
|
||||||
|
manufacturer: 'Yeelight',
|
||||||
|
model,
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YeelightManualMatcher implements IDiscoveryMatcher<IYeelightManualEntry> {
|
||||||
|
public id = 'yeelight-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Yeelight setup entries by host or Yeelight metadata.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IYeelightManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const name = inputArg.name?.toLowerCase() || '';
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.snapshot || inputArg.capabilities || inputArg.metadata?.yeelight || model.includes('yeelight') || name.includes('yeelight') || manufacturer === 'yeelight');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Yeelight setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Yeelight setup.',
|
||||||
|
normalizedDeviceId: inputArg.id || inputArg.host,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
id: inputArg.id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || defaultPort,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: 'Yeelight',
|
||||||
|
model: inputArg.model,
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
properties: inputArg.properties,
|
||||||
|
capabilities: inputArg.capabilities,
|
||||||
|
snapshot: inputArg.snapshot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YeelightCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'yeelight-candidate-validator';
|
||||||
|
public description = 'Validate Yeelight candidates before starting local LAN setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const name = candidateArg.name?.toLowerCase() || '';
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const matched = candidateArg.integrationDomain === 'yeelight' || manufacturer === 'yeelight' || model.includes('yeelight') || name.includes('yeelight') || name.startsWith('yeelink-') || Boolean(metadata.capabilities || metadata.yeelight);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Yeelight metadata.' : 'Candidate is not Yeelight.',
|
||||||
|
candidate: matched ? { ...candidateArg, port: candidateArg.port || defaultPort } : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id || candidateArg.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createYeelightDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'yeelight', displayName: 'Yeelight' })
|
||||||
|
.addMatcher(new YeelightSsdpMatcher())
|
||||||
|
.addMatcher(new YeelightMdnsMatcher())
|
||||||
|
.addMatcher(new YeelightManualMatcher())
|
||||||
|
.addValidator(new YeelightCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js';
|
||||||
|
import type { IYeelightBulbInfo, IYeelightBulbProperties, IYeelightEvent, IYeelightSnapshot } from './yeelight.types.js';
|
||||||
|
|
||||||
|
const sensorProperties: Array<{ key: keyof IYeelightBulbProperties; name: string; unit?: string; binary?: boolean }> = [
|
||||||
|
{ key: 'ct', name: 'Color temperature', unit: 'K' },
|
||||||
|
{ key: 'rgb', name: 'RGB' },
|
||||||
|
{ key: 'hue', name: 'Hue' },
|
||||||
|
{ key: 'sat', name: 'Saturation', unit: '%' },
|
||||||
|
{ key: 'color_mode', name: 'Color mode' },
|
||||||
|
{ key: 'flowing', name: 'Color flow', binary: true },
|
||||||
|
{ key: 'active_mode', name: 'Active mode' },
|
||||||
|
{ key: 'nl_br', name: 'Nightlight brightness', unit: '%' },
|
||||||
|
{ key: 'bg_power', name: 'Ambient power', binary: true },
|
||||||
|
{ key: 'bg_bright', name: 'Ambient brightness', unit: '%' },
|
||||||
|
{ key: 'bg_ct', name: 'Ambient color temperature', unit: 'K' },
|
||||||
|
{ key: 'bg_rgb', name: 'Ambient RGB' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class YeelightMapper {
|
||||||
|
public static toDevices(snapshotArg: IYeelightSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
return snapshotArg.bulbs.map((bulbArg) => {
|
||||||
|
const state = this.bulbState(bulbArg);
|
||||||
|
const properties = bulbArg.properties || {};
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||||
|
];
|
||||||
|
const deviceState: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'power', value: state.on, updatedAt },
|
||||||
|
];
|
||||||
|
if (state.brightness !== undefined || this.supports(bulbArg, 'set_bright')) {
|
||||||
|
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' });
|
||||||
|
deviceState.push({ featureId: 'brightness', value: state.brightness ?? null, updatedAt });
|
||||||
|
}
|
||||||
|
if (state.colorTempKelvin !== undefined || this.supports(bulbArg, 'set_ct_abx')) {
|
||||||
|
features.push({ id: 'color_temperature', capability: 'light', name: 'Color temperature', readable: true, writable: true, unit: 'K' });
|
||||||
|
deviceState.push({ featureId: 'color_temperature', value: state.colorTempKelvin ?? null, updatedAt });
|
||||||
|
}
|
||||||
|
if (state.rgbColor !== undefined || this.supports(bulbArg, 'set_rgb')) {
|
||||||
|
features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true });
|
||||||
|
deviceState.push({ featureId: 'rgb', value: state.rgbColor ? state.rgbColor.join(',') : null, updatedAt });
|
||||||
|
}
|
||||||
|
for (const sensor of sensorProperties) {
|
||||||
|
const value = properties[sensor.key];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const featureId = String(sensor.key);
|
||||||
|
if (features.some((featureArg) => featureArg.id === featureId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
features.push({ id: featureId, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
|
||||||
|
deviceState.push({ featureId, value: this.deviceStateValue(sensor.binary ? this.binaryState(value) : this.propertyValue(value)), updatedAt });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: this.deviceId(bulbArg),
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
name: this.bulbName(bulbArg),
|
||||||
|
protocol: 'unknown',
|
||||||
|
manufacturer: 'Yeelight',
|
||||||
|
model: bulbArg.model || bulbArg.capabilities?.model,
|
||||||
|
online: bulbArg.available !== false && (snapshotArg.connected || Boolean(bulbArg.properties)),
|
||||||
|
features,
|
||||||
|
state: deviceState,
|
||||||
|
metadata: {
|
||||||
|
host: bulbArg.host,
|
||||||
|
port: bulbArg.port || 55443,
|
||||||
|
firmwareVersion: bulbArg.fwVersion || bulbArg.capabilities?.fw_ver,
|
||||||
|
support: bulbArg.support || bulbArg.capabilities?.support?.split(' ').filter(Boolean),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IYeelightSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
for (const bulb of snapshotArg.bulbs) {
|
||||||
|
const deviceId = this.deviceId(bulb);
|
||||||
|
const name = this.bulbName(bulb);
|
||||||
|
const slug = this.slug(name);
|
||||||
|
const state = this.bulbState(bulb);
|
||||||
|
entities.push({
|
||||||
|
id: `light.${slug}`,
|
||||||
|
uniqueId: `yeelight_${this.slug(bulb.id || bulb.host || name)}_light`,
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
deviceId,
|
||||||
|
platform: 'light',
|
||||||
|
name,
|
||||||
|
state: state.on ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
brightness: state.brightness,
|
||||||
|
colorTempKelvin: state.colorTempKelvin,
|
||||||
|
rgbColor: state.rgbColor,
|
||||||
|
hsColor: state.hsColor,
|
||||||
|
colorMode: state.colorMode,
|
||||||
|
flowing: state.flowing,
|
||||||
|
nightLight: state.nightLight,
|
||||||
|
host: bulb.host,
|
||||||
|
model: bulb.model || bulb.capabilities?.model,
|
||||||
|
},
|
||||||
|
available: bulb.available !== false,
|
||||||
|
});
|
||||||
|
for (const sensor of sensorProperties) {
|
||||||
|
const value = bulb.properties?.[sensor.key];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const platform = sensor.binary ? 'binary_sensor' : 'sensor';
|
||||||
|
const sensorSlug = this.slug(`${name} ${sensor.name}`);
|
||||||
|
entities.push({
|
||||||
|
id: `${platform}.${sensorSlug}`,
|
||||||
|
uniqueId: `yeelight_${this.slug(bulb.id || bulb.host || name)}_${String(sensor.key)}`,
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
name: `${name} ${sensor.name}`,
|
||||||
|
state: sensor.binary ? this.binaryState(value) ? 'on' : 'off' : this.propertyValue(value),
|
||||||
|
attributes: sensor.unit ? { unit: sensor.unit } : undefined,
|
||||||
|
available: bulb.available !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IYeelightEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: 'yeelight',
|
||||||
|
deviceId: eventArg.bulbId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bulbState(bulbArg: IYeelightBulbInfo) {
|
||||||
|
if (bulbArg.state) {
|
||||||
|
return bulbArg.state;
|
||||||
|
}
|
||||||
|
const properties = bulbArg.properties || {};
|
||||||
|
const power = properties.main_power ?? properties.power;
|
||||||
|
const brightness = this.numberValue(properties.bright);
|
||||||
|
return {
|
||||||
|
on: power === 'on',
|
||||||
|
brightness,
|
||||||
|
colorTempKelvin: this.numberValue(properties.ct),
|
||||||
|
rgbColor: this.rgbValue(properties.rgb),
|
||||||
|
hsColor: this.hsValue(properties.hue, properties.sat),
|
||||||
|
colorMode: properties.color_mode === undefined || properties.color_mode === null ? undefined : String(properties.color_mode),
|
||||||
|
flowing: this.binaryState(properties.flowing),
|
||||||
|
nightLight: properties.active_mode !== undefined ? String(properties.active_mode) === '1' : this.numberValue(properties.nl_br) ? this.numberValue(properties.nl_br)! > 0 : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static supports(bulbArg: IYeelightBulbInfo, commandArg: string): boolean {
|
||||||
|
const support = bulbArg.support || bulbArg.capabilities?.support?.split(' ').filter(Boolean) || [];
|
||||||
|
return support.includes(commandArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceId(bulbArg: IYeelightBulbInfo): string {
|
||||||
|
return `yeelight.bulb.${this.slug(bulbArg.id || bulbArg.host || this.bulbName(bulbArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bulbName(bulbArg: IYeelightBulbInfo): string {
|
||||||
|
return bulbArg.name || bulbArg.properties?.name || bulbArg.capabilities?.name || bulbArg.model || bulbArg.host || 'Yeelight Bulb';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rgbValue(valueArg: unknown): [number, number, number] | undefined {
|
||||||
|
const numeric = this.numberValue(valueArg);
|
||||||
|
if (numeric === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [(numeric >> 16) & 0xff, (numeric >> 8) & 0xff, numeric & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hsValue(hueArg: unknown, satArg: unknown): [number, number] | undefined {
|
||||||
|
const hue = this.numberValue(hueArg);
|
||||||
|
const sat = this.numberValue(satArg);
|
||||||
|
return hue === undefined || sat === undefined ? undefined : [hue, sat];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static binaryState(valueArg: unknown): boolean {
|
||||||
|
return valueArg === true || valueArg === 'on' || valueArg === '1' || valueArg === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static propertyValue(valueArg: unknown): string | number | boolean | null {
|
||||||
|
const numeric = this.numberValue(valueArg);
|
||||||
|
if (numeric !== undefined) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'boolean' || valueArg === null) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? null : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'yeelight';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,218 @@
|
|||||||
export interface IHomeAssistantYeelightConfig {
|
export type TYeelightPowerState = 'on' | 'off';
|
||||||
// TODO: replace with the TypeScript-native config for yeelight.
|
export type TYeelightEffect = 'smooth' | 'sudden';
|
||||||
|
export type TYeelightCommandMethod =
|
||||||
|
| 'get_prop'
|
||||||
|
| 'set_power'
|
||||||
|
| 'set_bright'
|
||||||
|
| 'set_ct_abx'
|
||||||
|
| 'set_rgb'
|
||||||
|
| 'set_hsv'
|
||||||
|
| 'set_scene'
|
||||||
|
| 'start_cf'
|
||||||
|
| 'stop_cf'
|
||||||
|
| 'set_default'
|
||||||
|
| 'set_name'
|
||||||
|
| 'toggle'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export interface IYeelightConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
detectedModel?: string;
|
||||||
|
detected_model?: string;
|
||||||
|
transition?: number;
|
||||||
|
effect?: TYeelightEffect;
|
||||||
|
snapshot?: IYeelightSnapshot;
|
||||||
|
bulb?: IYeelightBulbInfo;
|
||||||
|
bulbs?: IYeelightBulbInfo[];
|
||||||
|
devices?: Record<string, IYeelightManualDeviceConfig>;
|
||||||
|
commandExecutor?: TYeelightCommandExecutor;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantYeelightConfig extends IYeelightConfig {}
|
||||||
|
|
||||||
|
export interface IYeelightManualDeviceConfig {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
detectedModel?: string;
|
||||||
|
detected_model?: string;
|
||||||
|
port?: number;
|
||||||
|
properties?: IYeelightBulbProperties;
|
||||||
|
capabilities?: IYeelightCapabilities;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightCapabilities {
|
||||||
|
id?: string;
|
||||||
|
location?: string;
|
||||||
|
model?: string;
|
||||||
|
fw_ver?: string;
|
||||||
|
support?: string;
|
||||||
|
power?: string;
|
||||||
|
bright?: string;
|
||||||
|
color_mode?: string;
|
||||||
|
ct?: string;
|
||||||
|
rgb?: string;
|
||||||
|
hue?: string;
|
||||||
|
sat?: string;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightBulbProperties {
|
||||||
|
power?: string | null;
|
||||||
|
main_power?: string | null;
|
||||||
|
bright?: string | null;
|
||||||
|
ct?: string | null;
|
||||||
|
rgb?: string | null;
|
||||||
|
hue?: string | null;
|
||||||
|
sat?: string | null;
|
||||||
|
color_mode?: string | null;
|
||||||
|
flowing?: string | null;
|
||||||
|
delayoff?: string | null;
|
||||||
|
music_on?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
bg_power?: string | null;
|
||||||
|
bg_flowing?: string | null;
|
||||||
|
bg_ct?: string | null;
|
||||||
|
bg_bright?: string | null;
|
||||||
|
bg_hue?: string | null;
|
||||||
|
bg_sat?: string | null;
|
||||||
|
bg_rgb?: string | null;
|
||||||
|
bg_lmode?: string | null;
|
||||||
|
nl_br?: string | null;
|
||||||
|
active_mode?: string | null;
|
||||||
|
current_brightness?: string | null;
|
||||||
|
[key: string]: string | number | boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightBulbState {
|
||||||
|
on: boolean;
|
||||||
|
brightness?: number;
|
||||||
|
colorTempKelvin?: number;
|
||||||
|
rgbColor?: [number, number, number];
|
||||||
|
hsColor?: [number, number];
|
||||||
|
colorMode?: string;
|
||||||
|
flowing?: boolean;
|
||||||
|
nightLight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightBulbInfo {
|
||||||
|
id?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
fwVersion?: string;
|
||||||
|
support?: string[];
|
||||||
|
capabilities?: IYeelightCapabilities;
|
||||||
|
properties?: IYeelightBulbProperties;
|
||||||
|
state?: IYeelightBulbState;
|
||||||
|
available?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightSnapshot {
|
||||||
|
connected: boolean;
|
||||||
|
bulbs: IYeelightBulbInfo[];
|
||||||
|
events: IYeelightEvent[];
|
||||||
|
source?: 'snapshot' | 'manual' | 'tcp';
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightCommand {
|
||||||
|
id?: number;
|
||||||
|
method: TYeelightCommandMethod;
|
||||||
|
params?: unknown[];
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightCommandResponse {
|
||||||
|
id?: number;
|
||||||
|
result?: unknown[];
|
||||||
|
error?: unknown;
|
||||||
|
method?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightLightStatePatch {
|
||||||
|
on?: boolean;
|
||||||
|
brightness?: number;
|
||||||
|
percentage?: number;
|
||||||
|
colorTempKelvin?: number;
|
||||||
|
rgbColor?: [number, number, number];
|
||||||
|
transition?: number;
|
||||||
|
effect?: TYeelightEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TYeelightCommandExecutor = (
|
||||||
|
commandArg: IYeelightCommand
|
||||||
|
) => Promise<IYeelightCommandResponse | IYeelightCommandResult | unknown> | IYeelightCommandResponse | IYeelightCommandResult | unknown;
|
||||||
|
|
||||||
|
export interface IYeelightEvent {
|
||||||
|
type: 'properties' | 'command' | 'error';
|
||||||
|
timestamp?: number;
|
||||||
|
bulbId?: string;
|
||||||
|
host?: string;
|
||||||
|
entityId?: string;
|
||||||
|
command?: IYeelightCommand;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightSsdpRecord {
|
||||||
|
location?: string;
|
||||||
|
Location?: string;
|
||||||
|
id?: string;
|
||||||
|
Id?: string;
|
||||||
|
model?: string;
|
||||||
|
Model?: string;
|
||||||
|
support?: string;
|
||||||
|
Support?: string;
|
||||||
|
st?: string;
|
||||||
|
ST?: string;
|
||||||
|
usn?: string;
|
||||||
|
USN?: string;
|
||||||
|
server?: string;
|
||||||
|
Server?: string;
|
||||||
|
ssdp_headers?: Record<string, string | undefined>;
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IYeelightManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
properties?: IYeelightBulbProperties;
|
||||||
|
capabilities?: IYeelightCapabilities;
|
||||||
|
snapshot?: IYeelightSnapshot;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './zha.classes.integration.js';
|
export * from './zha.classes.integration.js';
|
||||||
|
export * from './zha.classes.client.js';
|
||||||
|
export * from './zha.classes.configflow.js';
|
||||||
|
export * from './zha.discovery.js';
|
||||||
|
export * from './zha.mapper.js';
|
||||||
export * from './zha.types.js';
|
export * from './zha.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { IZhaClientCommand, IZhaCommandResult, IZhaConfig, IZhaEvent, IZhaSnapshot } from './zha.types.js';
|
||||||
|
import { ZhaMapper } from './zha.mapper.js';
|
||||||
|
|
||||||
|
type TZhaEventHandler = (eventArg: IZhaEvent) => void;
|
||||||
|
|
||||||
|
export class ZhaClient {
|
||||||
|
private readonly events: IZhaEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TZhaEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IZhaConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IZhaSnapshot> {
|
||||||
|
return ZhaMapper.toSnapshot(this.config, undefined, this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TZhaEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IZhaClientCommand): Promise<IZhaCommandResult> {
|
||||||
|
this.emit({ type: 'command_mapped', command: commandArg, entityId: commandArg.entityId, uniqueId: commandArg.uniqueId, timestamp: Date.now() });
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
const result = await this.config.commandExecutor(commandArg);
|
||||||
|
return this.commandResult(result, commandArg);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'ZHA live Zigbee radio writes require the zha/zigpy radio stack and serial protocol support, which is not implemented in this dependency-free TypeScript port. The mapped command was not sent.',
|
||||||
|
data: { command: commandArg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectLive(): Promise<void> {
|
||||||
|
await new ZhaZigpyRadioConnection(this.config).connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IZhaEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: IZhaClientCommand): IZhaCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IZhaCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZhaZigpyRadioConnection {
|
||||||
|
constructor(private readonly config: IZhaConfig) {}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
throw new Error(this.unsupportedMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IZhaClientCommand): Promise<void> {
|
||||||
|
void commandArg;
|
||||||
|
throw new Error(this.unsupportedMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsupportedMessage(): string {
|
||||||
|
const radioPath = this.config.radio?.path || this.config.device?.path || this.config.usbPath || this.config.usb_path || 'configured radio';
|
||||||
|
return `ZHA live radio control for ${radioPath} requires zha/zigpy plus radio-specific serial framing. This TypeScript port intentionally does not guess those internals.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IZhaConfig, IZhaSnapshot, TZhaFlowControl, TZhaRadioType } from './zha.types.js';
|
||||||
|
|
||||||
|
const radioTypeOptions: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: 'Silicon Labs EZSP / EmberZNet', value: 'ezsp' },
|
||||||
|
{ label: 'TI Z-Stack (ZNP)', value: 'znp' },
|
||||||
|
{ label: 'deCONZ / ConBee', value: 'deconz' },
|
||||||
|
{ label: 'XBee', value: 'xbee' },
|
||||||
|
{ label: 'ZiGate', value: 'zigate' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class ZhaConfigFlow implements IConfigFlow<IZhaConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IZhaConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
const defaults = this.defaultsFromCandidate(candidateArg);
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Zigbee Home Automation',
|
||||||
|
description: 'Configure the ZHA radio path and optional snapshot data. Live Zigbee radio probing is not performed by this TypeScript port.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'radioPath', label: 'Radio path or socket URL', type: 'text', required: true },
|
||||||
|
{ name: 'radioType', label: 'Radio type', type: 'select', required: true, options: radioTypeOptions },
|
||||||
|
{ name: 'baudrate', label: 'Baudrate', type: 'number' },
|
||||||
|
{ name: 'flowControl', label: 'Flow control', type: 'select', options: [{ label: 'None', value: 'none' }, { label: 'Hardware', value: 'hardware' }, { label: 'Software', value: 'software' }] },
|
||||||
|
{ name: 'databasePath', label: 'Zigpy database path', type: 'text' },
|
||||||
|
{ name: 'enableQuirks', label: 'Enable ZHA quirks', type: 'boolean' },
|
||||||
|
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot);
|
||||||
|
if (snapshot === false) {
|
||||||
|
return { kind: 'error', title: 'Invalid ZHA snapshot', error: 'Snapshot JSON must be a JSON object.' };
|
||||||
|
}
|
||||||
|
const radioPath = this.stringValue(valuesArg.radioPath) || defaults.radioPath || '/dev/ttyUSB0';
|
||||||
|
const radioType = this.stringValue(valuesArg.radioType) || defaults.radioType || 'znp';
|
||||||
|
const baudrate = this.numberValue(valuesArg.baudrate) || defaults.baudrate || 115200;
|
||||||
|
const flowControl = this.flowControlValue(valuesArg.flowControl) ?? defaults.flowControl ?? 'none';
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'ZHA configured',
|
||||||
|
config: {
|
||||||
|
radio: {
|
||||||
|
path: radioPath,
|
||||||
|
radioType,
|
||||||
|
baudrate,
|
||||||
|
flowControl,
|
||||||
|
socketUrl: radioPath.startsWith('socket://') ? radioPath : undefined,
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
path: radioPath,
|
||||||
|
baudrate,
|
||||||
|
flow_control: flowControl === 'none' ? null : flowControl,
|
||||||
|
},
|
||||||
|
radioType,
|
||||||
|
radio_type: radioType,
|
||||||
|
databasePath: this.stringValue(valuesArg.databasePath) || defaults.databasePath,
|
||||||
|
enableQuirks: this.booleanValue(valuesArg.enableQuirks) ?? true,
|
||||||
|
snapshot: snapshot || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { radioPath?: string; radioType?: TZhaRadioType; baudrate?: number; flowControl?: TZhaFlowControl; databasePath?: string; snapshot?: IZhaSnapshot } {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
return {
|
||||||
|
radioPath: this.stringValue(metadata.radioPath) || this.stringValue(metadata.socketUrl) || this.stringValue(metadata.path) || (candidateArg.host ? `socket://${candidateArg.host}:${candidateArg.port || 6638}` : undefined),
|
||||||
|
radioType: this.stringValue(metadata.radioType),
|
||||||
|
baudrate: this.numberValue(metadata.baudrate),
|
||||||
|
flowControl: this.flowControlValue(metadata.flowControl),
|
||||||
|
databasePath: this.stringValue(metadata.databasePath),
|
||||||
|
snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotValue(valueArg: unknown, fallbackArg?: IZhaSnapshot): IZhaSnapshot | undefined | false {
|
||||||
|
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||||
|
return fallbackArg;
|
||||||
|
}
|
||||||
|
if (this.isSnapshot(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(valueArg) as unknown;
|
||||||
|
return this.isRecord(parsed) ? parsed as unknown as IZhaSnapshot : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||||
|
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flowControlValue(valueArg: unknown): TZhaFlowControl | undefined {
|
||||||
|
if (valueArg === null || valueArg === 'none' || valueArg === 'hardware' || valueArg === 'software') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSnapshot(valueArg: unknown): valueArg is IZhaSnapshot {
|
||||||
|
return this.isRecord(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,129 @@
|
|||||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||||
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||||
|
import { ZhaClient } from './zha.classes.client.js';
|
||||||
|
import { ZhaConfigFlow } from './zha.classes.configflow.js';
|
||||||
|
import { createZhaDiscoveryDescriptor } from './zha.discovery.js';
|
||||||
|
import { ZhaMapper } from './zha.mapper.js';
|
||||||
|
import type { IZhaClientCommand, IZhaConfig } from './zha.types.js';
|
||||||
|
|
||||||
export class HomeAssistantZhaIntegration extends DescriptorOnlyIntegration {
|
export class ZhaIntegration extends BaseIntegration<IZhaConfig> {
|
||||||
constructor() {
|
public readonly domain = 'zha';
|
||||||
super({
|
public readonly displayName = 'Zigbee Home Automation';
|
||||||
domain: "zha",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Zigbee Home Automation",
|
public readonly discoveryDescriptor = createZhaDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new ZhaConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/zha",
|
upstreamPath: 'homeassistant/components/zha',
|
||||||
"upstreamDomain": "zha",
|
upstreamDomain: 'zha',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
qualityScale: 'unknown',
|
||||||
"zha==1.3.0"
|
};
|
||||||
],
|
|
||||||
"dependencies": [
|
public async setup(configArg: IZhaConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"file_upload",
|
void contextArg;
|
||||||
"homeassistant_hardware"
|
return new ZhaRuntime(new ZhaClient(configArg));
|
||||||
],
|
}
|
||||||
"afterDependencies": [
|
|
||||||
"hassio",
|
public async destroy(): Promise<void> {}
|
||||||
"onboarding",
|
}
|
||||||
"usb"
|
|
||||||
],
|
export class HomeAssistantZhaIntegration extends ZhaIntegration {}
|
||||||
"codeowners": [
|
|
||||||
"@dmulcahey",
|
class ZhaRuntime implements IIntegrationRuntime {
|
||||||
"@adminiuga",
|
public domain = 'zha';
|
||||||
"@puddly",
|
|
||||||
"@TheJulianJES"
|
constructor(private readonly client: ZhaClient) {}
|
||||||
]
|
|
||||||
},
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return ZhaMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return ZhaMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => {
|
||||||
|
handlerArg({
|
||||||
|
type: eventArg.type === 'device_removed' ? 'device_removed' : eventArg.type === 'availability_changed' ? 'availability_changed' : 'state_changed',
|
||||||
|
integrationDomain: 'zha',
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const command = requestArg.domain === 'zha'
|
||||||
|
? this.commandFromService(requestArg)
|
||||||
|
: ZhaMapper.commandForService(await this.client.getSnapshot(), requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `Unsupported ZHA service: ${requestArg.domain}.${requestArg.service}` };
|
||||||
|
}
|
||||||
|
return this.client.sendCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandFromService(requestArg: IServiceCallRequest): IZhaClientCommand | undefined {
|
||||||
|
if (requestArg.service === 'issue_zigbee_cluster_command') {
|
||||||
|
return {
|
||||||
|
type: 'cluster.command',
|
||||||
|
service: requestArg.service,
|
||||||
|
ieee: this.stringValue(requestArg.data?.ieee),
|
||||||
|
endpointId: this.numberValue(requestArg.data?.endpoint_id ?? requestArg.data?.endpointId),
|
||||||
|
clusterId: this.numberValue(requestArg.data?.cluster_id ?? requestArg.data?.clusterId),
|
||||||
|
clusterType: this.stringValue(requestArg.data?.cluster_type ?? requestArg.data?.clusterType),
|
||||||
|
command: requestArg.data?.command as string | number | undefined,
|
||||||
|
args: Array.isArray(requestArg.data?.args) ? requestArg.data.args : undefined,
|
||||||
|
params: this.isRecord(requestArg.data?.params) ? requestArg.data.params : undefined,
|
||||||
|
payload: { ...requestArg.data },
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_zigbee_cluster_attribute') {
|
||||||
|
return {
|
||||||
|
type: 'cluster.write_attribute',
|
||||||
|
service: requestArg.service,
|
||||||
|
ieee: this.stringValue(requestArg.data?.ieee),
|
||||||
|
endpointId: this.numberValue(requestArg.data?.endpoint_id ?? requestArg.data?.endpointId),
|
||||||
|
clusterId: this.numberValue(requestArg.data?.cluster_id ?? requestArg.data?.clusterId),
|
||||||
|
clusterType: this.stringValue(requestArg.data?.cluster_type ?? requestArg.data?.clusterType),
|
||||||
|
attribute: requestArg.data?.attribute as string | number | undefined,
|
||||||
|
value: requestArg.data?.value,
|
||||||
|
payload: { ...requestArg.data },
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'permit') {
|
||||||
|
return {
|
||||||
|
type: 'network.permit',
|
||||||
|
service: requestArg.service,
|
||||||
|
payload: { duration: requestArg.data?.duration ?? 60 },
|
||||||
|
target: requestArg.target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IZhaManualEntry, IZhaMdnsRecord, IZhaUsbRecord, TZhaRadioType } from './zha.types.js';
|
||||||
|
|
||||||
|
interface IZhaKnownUsbDevice {
|
||||||
|
vid: string;
|
||||||
|
pid: string;
|
||||||
|
description?: string;
|
||||||
|
knownDevices?: string[];
|
||||||
|
radioType?: TZhaRadioType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zhaZeroconfTypes = new Set([
|
||||||
|
'_zigbee-coordinator._tcp.local',
|
||||||
|
'_zigate-zigbee-gateway._tcp.local',
|
||||||
|
'_zigstar_gw._tcp.local',
|
||||||
|
'_uzg-01._tcp.local',
|
||||||
|
'_slzb-06._tcp.local',
|
||||||
|
'_xzg._tcp.local',
|
||||||
|
'_czc._tcp.local',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const legacyZeroconfTypes = new Set(['_esphomelib._tcp.local']);
|
||||||
|
const defaultZigbeeCoordinatorPort = 6638;
|
||||||
|
|
||||||
|
const haManifestUsbDevices: IZhaKnownUsbDevice[] = [
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*2652*', knownDevices: ['slae.sh cc2652rb stick'], radioType: 'znp' },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*slzb-07*', knownDevices: ['smlight slzb-07'], radioType: 'znp' },
|
||||||
|
{ vid: '1a86', pid: '55d4', description: '*sonoff*plus*', knownDevices: ['sonoff zigbee dongle plus v2'], radioType: 'ezsp' },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*sonoff*plus*', knownDevices: ['sonoff zigbee dongle plus'], radioType: 'znp' },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*tubeszb*', knownDevices: ['TubesZB Coordinator'], radioType: 'znp' },
|
||||||
|
{ vid: '1a86', pid: '7523', description: '*tubeszb*', knownDevices: ['TubesZB Coordinator'], radioType: 'znp' },
|
||||||
|
{ vid: '1a86', pid: '7523', description: '*zigstar*', knownDevices: ['ZigStar Coordinators'], radioType: 'znp' },
|
||||||
|
{ vid: '1cf1', pid: '0030', description: '*conbee*', knownDevices: ['Conbee II'], radioType: 'deconz' },
|
||||||
|
{ vid: '0403', pid: '6015', description: '*conbee*', knownDevices: ['Conbee III'], radioType: 'deconz' },
|
||||||
|
{ vid: '10c4', pid: '8a2a', description: '*zigbee*', knownDevices: ['Nortek HUSBZB-1'], radioType: 'ezsp' },
|
||||||
|
{ vid: '0403', pid: '6015', description: '*zigate*', knownDevices: ['ZiGate+'], radioType: 'zigate' },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*zigate*', knownDevices: ['ZiGate'], radioType: 'zigate' },
|
||||||
|
{ vid: '10c4', pid: '8b34', description: '*bv 2010/10*', knownDevices: ['Bitron Video AV2010/10'] },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*sonoff*max*', knownDevices: ['SONOFF Dongle Max MG24'], radioType: 'ezsp' },
|
||||||
|
{ vid: '10c4', pid: 'ea60', description: '*sonoff*lite*mg21*', knownDevices: ['sonoff zigbee dongle lite mg21'], radioType: 'ezsp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commonZigbeeUsbIds = new Set(haManifestUsbDevices.map((entryArg) => `${entryArg.vid}:${entryArg.pid}`));
|
||||||
|
|
||||||
|
const zigbeeTextHints = [
|
||||||
|
'zigbee',
|
||||||
|
'conbee',
|
||||||
|
'sonoff',
|
||||||
|
'zigate',
|
||||||
|
'zigstar',
|
||||||
|
'tubeszb',
|
||||||
|
'slzb',
|
||||||
|
'cc2652',
|
||||||
|
'cc1352',
|
||||||
|
'efr32',
|
||||||
|
'ember',
|
||||||
|
'husbzb',
|
||||||
|
'skyconnect',
|
||||||
|
'zbt',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class ZhaUsbMatcher implements IDiscoveryMatcher<IZhaUsbRecord> {
|
||||||
|
public id = 'zha-usb-match';
|
||||||
|
public source = 'usb' as const;
|
||||||
|
public description = 'Recognize known USB Zigbee coordinators supported by ZHA.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IZhaUsbRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const vid = this.normalizeUsbId(recordArg.vid);
|
||||||
|
const pid = this.normalizeUsbId(recordArg.pid);
|
||||||
|
const usbId = `${vid}:${pid}`;
|
||||||
|
const path = recordArg.path || recordArg.device;
|
||||||
|
const serialNumber = recordArg.serialNumber || recordArg.serial_number;
|
||||||
|
const text = this.recordText(recordArg);
|
||||||
|
const haMatch = haManifestUsbDevices.find((entryArg) => entryArg.vid === vid && entryArg.pid === pid && this.matchesManifestEntry(entryArg, text));
|
||||||
|
const idMatch = commonZigbeeUsbIds.has(usbId);
|
||||||
|
const textMatch = zigbeeTextHints.some((hintArg) => text.includes(hintArg));
|
||||||
|
const matched = Boolean(haMatch || idMatch || textMatch);
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'USB device is not a known ZHA Zigbee coordinator.' };
|
||||||
|
}
|
||||||
|
const radioType = haMatch?.radioType || this.guessRadioType(text, usbId);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: haMatch ? 'certain' : idMatch ? 'high' : 'medium',
|
||||||
|
reason: haMatch ? 'USB record matches Home Assistant ZHA manifest USB metadata.' : 'USB record matches common Zigbee coordinator metadata.',
|
||||||
|
normalizedDeviceId: serialNumber || path || usbId,
|
||||||
|
candidate: {
|
||||||
|
source: 'usb',
|
||||||
|
integrationDomain: 'zha',
|
||||||
|
id: serialNumber || path || usbId,
|
||||||
|
name: recordArg.description || recordArg.product || 'Zigbee coordinator',
|
||||||
|
manufacturer: recordArg.manufacturer || this.manufacturerFromText(text),
|
||||||
|
model: recordArg.description || recordArg.product || haMatch?.knownDevices?.[0],
|
||||||
|
serialNumber,
|
||||||
|
metadata: {
|
||||||
|
vid: recordArg.vid,
|
||||||
|
pid: recordArg.pid,
|
||||||
|
path,
|
||||||
|
radioPath: path,
|
||||||
|
serialNumber,
|
||||||
|
radioType,
|
||||||
|
baudrate: 115200,
|
||||||
|
flowControl: 'none',
|
||||||
|
haUsb: haMatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeUsbId(valueArg?: string): string {
|
||||||
|
return (valueArg || '').replace(/^0x/i, '').toLowerCase().padStart(4, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordText(recordArg: IZhaUsbRecord): string {
|
||||||
|
return [recordArg.manufacturer, recordArg.description, recordArg.product, recordArg.serialNumber, recordArg.serial_number]
|
||||||
|
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesManifestEntry(entryArg: IZhaKnownUsbDevice, textArg: string): boolean {
|
||||||
|
if (!entryArg.description && !entryArg.knownDevices?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const descriptionMatch = entryArg.description ? this.wildcardMatch(entryArg.description, textArg) : false;
|
||||||
|
const knownDeviceMatch = (entryArg.knownDevices || []).some((deviceArg) => textArg.includes(deviceArg.toLowerCase()));
|
||||||
|
return descriptionMatch || knownDeviceMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private wildcardMatch(patternArg: string, valueArg: string): boolean {
|
||||||
|
const parts = patternArg.toLowerCase().split('*').filter(Boolean);
|
||||||
|
return parts.every((partArg) => valueArg.includes(partArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private guessRadioType(textArg: string, usbIdArg: string): TZhaRadioType | undefined {
|
||||||
|
if (textArg.includes('conbee')) {
|
||||||
|
return 'deconz';
|
||||||
|
}
|
||||||
|
if (textArg.includes('zigate')) {
|
||||||
|
return 'zigate';
|
||||||
|
}
|
||||||
|
if (textArg.includes('xbee')) {
|
||||||
|
return 'xbee';
|
||||||
|
}
|
||||||
|
if (textArg.includes('sonoff') && (textArg.includes('v2') || textArg.includes('mg21') || textArg.includes('mg24') || usbIdArg === '1a86:55d4')) {
|
||||||
|
return 'ezsp';
|
||||||
|
}
|
||||||
|
if (textArg.includes('efr32') || textArg.includes('ember') || textArg.includes('skyconnect') || textArg.includes('zbt')) {
|
||||||
|
return 'ezsp';
|
||||||
|
}
|
||||||
|
if (textArg.includes('cc2652') || textArg.includes('cc1352') || textArg.includes('zigstar') || textArg.includes('tubeszb') || textArg.includes('slzb')) {
|
||||||
|
return 'znp';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private manufacturerFromText(textArg: string): string {
|
||||||
|
if (textArg.includes('sonoff')) {
|
||||||
|
return 'SONOFF';
|
||||||
|
}
|
||||||
|
if (textArg.includes('conbee')) {
|
||||||
|
return 'dresden elektronik';
|
||||||
|
}
|
||||||
|
if (textArg.includes('zigate')) {
|
||||||
|
return 'ZiGate';
|
||||||
|
}
|
||||||
|
return 'Zigbee';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZhaMdnsMatcher implements IDiscoveryMatcher<IZhaMdnsRecord> {
|
||||||
|
public id = 'zha-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize network-attached Zigbee coordinators advertised for ZHA.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IZhaMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = this.normalizeType(recordArg.type);
|
||||||
|
const txt = recordArg.txt || recordArg.properties || {};
|
||||||
|
const text = [recordArg.name, recordArg.hostname, this.txt(txt, 'name'), this.txt(txt, 'radio_type')].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
const isNativeType = zhaZeroconfTypes.has(type);
|
||||||
|
const isLegacyType = legacyZeroconfTypes.has(type) && (text.includes('tube') || text.includes('zigbee'));
|
||||||
|
const matched = isNativeType || isLegacyType || Boolean(this.txt(txt, 'radio_type') && this.txt(txt, 'serial_number'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a ZHA Zigbee coordinator advertisement.' };
|
||||||
|
}
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const port = recordArg.port || defaultZigbeeCoordinatorPort;
|
||||||
|
const radioType = this.txt(txt, 'radio_type') || this.guessRadioType(text, type);
|
||||||
|
const serialNumber = this.txt(txt, 'serial_number') || recordArg.hostname?.replace(/\.local\.?$/i, '');
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: radioType && host ? 'certain' : host ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches ZHA network coordinator metadata.',
|
||||||
|
normalizedDeviceId: serialNumber || recordArg.name || host,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'zha',
|
||||||
|
id: serialNumber || recordArg.name || host,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name: this.deviceName(recordArg, txt) || 'Zigbee coordinator',
|
||||||
|
manufacturer: 'Zigbee',
|
||||||
|
model: 'Network Zigbee coordinator',
|
||||||
|
serialNumber,
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt,
|
||||||
|
radioType,
|
||||||
|
radioPath: host ? `socket://${host}:${port}` : undefined,
|
||||||
|
socketUrl: host ? `socket://${host}:${port}` : undefined,
|
||||||
|
baudrate: 115200,
|
||||||
|
flowControl: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txt(txtArg: Record<string, string | undefined>, keyArg: string): string | undefined {
|
||||||
|
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(valueArg?: string): string {
|
||||||
|
return (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceName(recordArg: IZhaMdnsRecord, txtArg: Record<string, string | undefined>): string | undefined {
|
||||||
|
return this.txt(txtArg, 'name') || recordArg.name?.split('._', 1)[0] || recordArg.hostname?.replace(/\.local\.?$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private guessRadioType(textArg: string, typeArg: string): TZhaRadioType | undefined {
|
||||||
|
if (textArg.includes('zigate') || typeArg.includes('zigate')) {
|
||||||
|
return 'zigate';
|
||||||
|
}
|
||||||
|
if (textArg.includes('efr32') || textArg.includes('ezsp')) {
|
||||||
|
return 'ezsp';
|
||||||
|
}
|
||||||
|
if (textArg.includes('deconz') || textArg.includes('conbee')) {
|
||||||
|
return 'deconz';
|
||||||
|
}
|
||||||
|
if (textArg.includes('znp') || textArg.includes('zigstar') || textArg.includes('slzb') || textArg.includes('uzg') || textArg.includes('xzg')) {
|
||||||
|
return 'znp';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZhaManualMatcher implements IDiscoveryMatcher<IZhaManualEntry> {
|
||||||
|
public id = 'zha-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual ZHA radio setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IZhaManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const radioPath = inputArg.radioPath || inputArg.devicePath || inputArg.usbPath || inputArg.path || inputArg.socketUrl || (inputArg.host ? `socket://${inputArg.host}:${inputArg.port || defaultZigbeeCoordinatorPort}` : undefined);
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(radioPath || inputArg.radioType || inputArg.metadata?.zha || inputArg.metadata?.zigbee || model.includes('zigbee') || manufacturer.includes('zigbee'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ZHA radio setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: radioPath && inputArg.radioType ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start ZHA radio setup.',
|
||||||
|
normalizedDeviceId: inputArg.id || radioPath,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'zha',
|
||||||
|
id: inputArg.id || radioPath,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port,
|
||||||
|
name: inputArg.name || 'Zigbee Home Automation',
|
||||||
|
manufacturer: inputArg.manufacturer || 'Zigbee',
|
||||||
|
model: inputArg.model || 'ZHA radio',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
radioPath,
|
||||||
|
socketUrl: radioPath?.startsWith('socket://') ? radioPath : undefined,
|
||||||
|
radioType: inputArg.radioType,
|
||||||
|
baudrate: inputArg.baudrate,
|
||||||
|
flowControl: inputArg.flowControl,
|
||||||
|
snapshot: inputArg.snapshot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZhaCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'zha-candidate-validator';
|
||||||
|
public description = 'Validate ZHA radio discovery candidates.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase().replace(/\.$/, '') : '';
|
||||||
|
const vid = typeof metadata.vid === 'string' ? metadata.vid.replace(/^0x/i, '').toLowerCase().padStart(4, '0') : '';
|
||||||
|
const pid = typeof metadata.pid === 'string' ? metadata.pid.replace(/^0x/i, '').toLowerCase().padStart(4, '0') : '';
|
||||||
|
const usbRecognized = Boolean(vid && pid && commonZigbeeUsbIds.has(`${vid}:${pid}`));
|
||||||
|
const hasRadioPath = typeof metadata.radioPath === 'string' || typeof metadata.socketUrl === 'string';
|
||||||
|
const textMatched = zigbeeTextHints.some((hintArg) => model.includes(hintArg) || manufacturer.includes(hintArg));
|
||||||
|
const matched = candidateArg.integrationDomain === 'zha' || usbRecognized || zhaZeroconfTypes.has(mdnsType) || Boolean(metadata.radioType || metadata.zha || metadata.zigbee || hasRadioPath && textMatched || textMatched);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.integrationDomain === 'zha' && (hasRadioPath || metadata.radioType) ? 'certain' : matched && (usbRecognized || hasRadioPath) ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has ZHA radio metadata.' : 'Candidate is not ZHA.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.serialNumber || candidateArg.id || candidateArg.host,
|
||||||
|
metadata: matched ? { usbRecognized, hasRadioPath, radioType: metadata.radioType } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createZhaDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'zha', displayName: 'Zigbee Home Automation' })
|
||||||
|
.addMatcher(new ZhaUsbMatcher())
|
||||||
|
.addMatcher(new ZhaMdnsMatcher())
|
||||||
|
.addMatcher(new ZhaManualMatcher())
|
||||||
|
.addValidator(new ZhaCandidateValidator());
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user