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 { 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 { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||
import { RokuIntegration } from './integrations/roku/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/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 { 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 { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||
import { IntegrationRegistry } from './core/index.js';
|
||||
|
||||
export const integrations = [
|
||||
new CastIntegration(),
|
||||
new DeconzIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new HomekitControllerIntegration(),
|
||||
new HueIntegration(),
|
||||
new MatterIntegration(),
|
||||
new MqttIntegration(),
|
||||
new NanoleafIntegration(),
|
||||
new RokuIntegration(),
|
||||
new ShellyIntegration(),
|
||||
new SonosIntegration(),
|
||||
new TradfriIntegration(),
|
||||
new WolfSmartsetIntegration(),
|
||||
new WizIntegration(),
|
||||
new XiaomiMiioIntegration(),
|
||||
new YeelightIntegration(),
|
||||
new ZhaIntegration(),
|
||||
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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "deconz",
|
||||
displayName: "deCONZ",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/deconz",
|
||||
"upstreamDomain": "deconz",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"pydeconz==120"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Kane610"
|
||||
]
|
||||
},
|
||||
});
|
||||
interface IDeconzServiceTarget {
|
||||
entity: IIntegrationEntity;
|
||||
resource: string;
|
||||
resourceId: string;
|
||||
stateKey?: string;
|
||||
configKey?: string;
|
||||
}
|
||||
|
||||
export class DeconzIntegration extends BaseIntegration<IDeconzConfig> {
|
||||
public readonly domain = 'deconz';
|
||||
public readonly displayName = 'deCONZ';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createDeconzDiscoveryDescriptor();
|
||||
public readonly configFlow = new DeconzConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/deconz',
|
||||
upstreamDomain: 'deconz',
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for deconz.
|
||||
export type TDeconzProtocol = 'http' | 'https';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.discovery.js';
|
||||
export * from './deconz.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "esphome",
|
||||
displayName: "ESPHome",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/esphome",
|
||||
"upstreamDomain": "esphome",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.21.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
|
||||
public readonly domain = 'esphome';
|
||||
public readonly displayName = 'ESPHome';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createEsphomeDiscoveryDescriptor();
|
||||
public readonly configFlow = new EsphomeConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/esphome',
|
||||
upstreamDomain: 'esphome',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'platinum',
|
||||
requirements: [
|
||||
'aioesphomeapi==44.21.0',
|
||||
'esphome-dashboard-api==1.3.0',
|
||||
'bleak-esphome==3.7.3',
|
||||
],
|
||||
"dependencies": [
|
||||
"assist_pipeline",
|
||||
"bluetooth",
|
||||
"intent",
|
||||
"ffmpeg",
|
||||
"http"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"hassio",
|
||||
"tag",
|
||||
"usb",
|
||||
"zeroconf"
|
||||
],
|
||||
"codeowners": [
|
||||
"@jesserockz",
|
||||
"@kbx81",
|
||||
"@bdraco"
|
||||
]
|
||||
},
|
||||
dependencies: ['assist_pipeline', 'bluetooth', 'intent', 'ffmpeg', 'http'],
|
||||
afterDependencies: ['hassio', 'tag', 'usb', 'zeroconf'],
|
||||
codeowners: ['@jesserockz', '@kbx81', '@bdraco'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/esphome',
|
||||
zeroconf: ['_esphomelib._tcp.local.'],
|
||||
mqtt: ['esphome/discover/#'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IEsphomeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new EsphomeRuntime(new EsphomeClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for esphome.
|
||||
import type { TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.discovery.js';
|
||||
export * from './esphome.mapper.js';
|
||||
export * from './esphome.types.js';
|
||||
|
||||
@@ -231,7 +231,6 @@ import { HomeAssistantDatetimeIntegration } from '../datetime/index.js';
|
||||
import { HomeAssistantDdwrtIntegration } from '../ddwrt/index.js';
|
||||
import { HomeAssistantDeakoIntegration } from '../deako/index.js';
|
||||
import { HomeAssistantDebugpyIntegration } from '../debugpy/index.js';
|
||||
import { HomeAssistantDeconzIntegration } from '../deconz/index.js';
|
||||
import { HomeAssistantDecoraWifiIntegration } from '../decora_wifi/index.js';
|
||||
import { HomeAssistantDecorquipIntegration } from '../decorquip/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 { HomeAssistantEsceaIntegration } from '../escea/index.js';
|
||||
import { HomeAssistantEseraOnewireIntegration } from '../esera_onewire/index.js';
|
||||
import { HomeAssistantEsphomeIntegration } from '../esphome/index.js';
|
||||
import { HomeAssistantEssentIntegration } from '../essent/index.js';
|
||||
import { HomeAssistantEtherscanIntegration } from '../etherscan/index.js';
|
||||
import { HomeAssistantEufyIntegration } from '../eufy/index.js';
|
||||
@@ -521,7 +519,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan
|
||||
import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js';
|
||||
import { HomeAssistantHomeeIntegration } from '../homee/index.js';
|
||||
import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
|
||||
import { HomeAssistantHomekitControllerIntegration } from '../homekit_controller/index.js';
|
||||
import { HomeAssistantHomematicIntegration } from '../homematic/index.js';
|
||||
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/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 { HomeAssistantMastodonIntegration } from '../mastodon/index.js';
|
||||
import { HomeAssistantMatrixIntegration } from '../matrix/index.js';
|
||||
import { HomeAssistantMatterIntegration } from '../matter/index.js';
|
||||
import { HomeAssistantMaxcubeIntegration } from '../maxcube/index.js';
|
||||
import { HomeAssistantMaytagIntegration } from '../maytag/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 { HomeAssistantNamIntegration } from '../nam/index.js';
|
||||
import { HomeAssistantNamecheapdnsIntegration } from '../namecheapdns/index.js';
|
||||
import { HomeAssistantNanoleafIntegration } from '../nanoleaf/index.js';
|
||||
import { HomeAssistantNaswebIntegration } from '../nasweb/index.js';
|
||||
import { HomeAssistantNationalGridUsIntegration } from '../national_grid_us/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 { HomeAssistantTraceIntegration } from '../trace/index.js';
|
||||
import { HomeAssistantTractiveIntegration } from '../tractive/index.js';
|
||||
import { HomeAssistantTradfriIntegration } from '../tradfri/index.js';
|
||||
import { HomeAssistantTrafikverketCameraIntegration } from '../trafikverket_camera/index.js';
|
||||
import { HomeAssistantTrafikverketFerryIntegration } from '../trafikverket_ferry/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 { HomeAssistantWirelesstagIntegration } from '../wirelesstag/index.js';
|
||||
import { HomeAssistantWithingsIntegration } from '../withings/index.js';
|
||||
import { HomeAssistantWizIntegration } from '../wiz/index.js';
|
||||
import { HomeAssistantWledIntegration } from '../wled/index.js';
|
||||
import { HomeAssistantWmsproIntegration } from '../wmspro/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 { HomeAssistantXiaomiAqaraIntegration } from '../xiaomi_aqara/index.js';
|
||||
import { HomeAssistantXiaomiBleIntegration } from '../xiaomi_ble/index.js';
|
||||
import { HomeAssistantXiaomiMiioIntegration } from '../xiaomi_miio/index.js';
|
||||
import { HomeAssistantXiaomiTvIntegration } from '../xiaomi_tv/index.js';
|
||||
import { HomeAssistantXmppIntegration } from '../xmpp/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 { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
|
||||
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
|
||||
import { HomeAssistantYeelightIntegration } from '../yeelight/index.js';
|
||||
import { HomeAssistantYeelightsunflowerIntegration } from '../yeelightsunflower/index.js';
|
||||
import { HomeAssistantYiIntegration } from '../yi/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 { HomeAssistantZestimateIntegration } from '../zestimate/index.js';
|
||||
import { HomeAssistantZeversolarIntegration } from '../zeversolar/index.js';
|
||||
import { HomeAssistantZhaIntegration } from '../zha/index.js';
|
||||
import { HomeAssistantZhongHongIntegration } from '../zhong_hong/index.js';
|
||||
import { HomeAssistantZiggoMediaboxXlIntegration } from '../ziggo_mediabox_xl/index.js';
|
||||
import { HomeAssistantZimiIntegration } from '../zimi/index.js';
|
||||
@@ -1684,7 +1674,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDatetimeIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDdwrtIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeakoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDebugpyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeconzIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecoraWifiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDecorquipIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDefaultConfigIntegration());
|
||||
@@ -1793,7 +1782,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantEpsonIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEq3btsmartIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsceaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEseraOnewireIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEsphomeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEssentIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEtherscanIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEufyIntegration());
|
||||
@@ -1974,7 +1962,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitControllerIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
|
||||
@@ -2177,7 +2164,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMartecIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaryttsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMastodonIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatrixIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMatterIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaxcubeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMaytagIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMazdaIntegration());
|
||||
@@ -2259,7 +2245,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMyuplinkIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNadIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNamecheapdnsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNanoleafIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNaswebIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNationalGridUsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantNeatoIntegration());
|
||||
@@ -2737,7 +2722,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarIntegration(
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraccarServerIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTraceIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTractiveIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTradfriIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketCameraIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketFerryIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTrafikverketTrainIntegration());
|
||||
@@ -2852,7 +2836,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantWilightIntegration(
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWindowIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWirelesstagIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWithingsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWizIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWledIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWmsproIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantWolflinkIntegration());
|
||||
@@ -2869,7 +2852,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantXeomaIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiAqaraIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiBleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiMiioIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXiaomiTvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXmppIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantXs1Integration());
|
||||
@@ -2881,7 +2863,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaMusiccastInte
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYeelightsunflowerIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYolinkIntegration());
|
||||
@@ -2895,7 +2876,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeroconfIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZerprocIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZestimateIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZeversolarIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZhongHongIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZiggoMediaboxXlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZimiIntegration());
|
||||
@@ -2906,13 +2886,23 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1451;
|
||||
export const generatedHomeAssistantPortCount = 1441;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"cast",
|
||||
"deconz",
|
||||
"esphome",
|
||||
"homekit_controller",
|
||||
"hue",
|
||||
"matter",
|
||||
"mqtt",
|
||||
"nanoleaf",
|
||||
"roku",
|
||||
"shelly",
|
||||
"sonos",
|
||||
"tradfri",
|
||||
"wiz",
|
||||
"xiaomi_miio",
|
||||
"yeelight",
|
||||
"zha",
|
||||
"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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "homekit_controller",
|
||||
displayName: "HomeKit Device",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/homekit_controller",
|
||||
"upstreamDomain": "homekit_controller",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"aiohomekit==3.2.20"
|
||||
],
|
||||
"dependencies": [
|
||||
"bluetooth_adapters",
|
||||
"zeroconf"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"thread"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Jc2k",
|
||||
"@bdraco"
|
||||
]
|
||||
},
|
||||
export class HomekitControllerIntegration extends BaseIntegration<IHomekitControllerConfig> {
|
||||
public readonly domain = 'homekit_controller';
|
||||
public readonly displayName = 'HomeKit Device';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createHomekitControllerDiscoveryDescriptor();
|
||||
public readonly configFlow = new HomekitControllerConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/homekit_controller',
|
||||
upstreamDomain: 'homekit_controller',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['aiohomekit==3.2.20'],
|
||||
dependencies: ['bluetooth_adapters', 'zeroconf'],
|
||||
afterDependencies: ['thread'],
|
||||
codeowners: ['@Jc2k', '@bdraco'],
|
||||
zeroconf: ['_hap._tcp.local.', '_hap._udp.local.'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/homekit_controller',
|
||||
};
|
||||
|
||||
public async setup(configArg: IHomekitControllerConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new HomekitControllerRuntime(new HomekitControllerClient(configArg));
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for homekit_controller.
|
||||
export type THomekitTransport = 'ip' | 'ble' | 'thread' | 'coap' | 'snapshot' | 'unknown';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.discovery.js';
|
||||
export * from './homekit_controller.mapper.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.client.js';
|
||||
export * from './matter.classes.configflow.js';
|
||||
export * from './matter.discovery.js';
|
||||
export * from './matter.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "matter",
|
||||
displayName: "Matter",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/matter",
|
||||
"upstreamDomain": "matter",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"matter-python-client==0.6.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"websocket_api"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"hassio"
|
||||
],
|
||||
"codeowners": [
|
||||
"@home-assistant/matter"
|
||||
]
|
||||
},
|
||||
export class MatterIntegration extends BaseIntegration<IMatterConfig> {
|
||||
public readonly domain = 'matter';
|
||||
public readonly displayName = 'Matter';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createMatterDiscoveryDescriptor();
|
||||
public readonly configFlow = new MatterConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
domain: 'matter',
|
||||
name: 'Matter',
|
||||
upstreamPath: 'homeassistant/components/matter',
|
||||
upstreamDomain: 'matter',
|
||||
configFlow: true,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['matter-python-client==0.6.0'],
|
||||
dependencies: ['websocket_api'],
|
||||
afterDependencies: ['hassio'],
|
||||
codeowners: ['@home-assistant/matter'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/matter',
|
||||
zeroconf: ['_matter._tcp.local.', '_matterc._udp.local.'],
|
||||
};
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for matter.
|
||||
[key: string]: unknown;
|
||||
import type { TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
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.discovery.js';
|
||||
export * from './nanoleaf.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "nanoleaf",
|
||||
displayName: "Nanoleaf",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/nanoleaf",
|
||||
"upstreamDomain": "nanoleaf",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"aionanoleaf2==1.0.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@milanmeu",
|
||||
"@joostlek",
|
||||
"@loebi-ch",
|
||||
"@JaspervRijbroek",
|
||||
"@jonathanrobichaud4"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class NanoleafIntegration extends BaseIntegration<INanoleafConfig> {
|
||||
public readonly domain = 'nanoleaf';
|
||||
public readonly displayName = 'Nanoleaf';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createNanoleafDiscoveryDescriptor();
|
||||
public readonly configFlow = new NanoleafConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/nanoleaf',
|
||||
upstreamDomain: 'nanoleaf',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
documentation: 'https://www.home-assistant.io/integrations/nanoleaf',
|
||||
requirements: ['aionanoleaf2==1.0.2'],
|
||||
codeowners: ['@milanmeu', '@joostlek', '@loebi-ch', '@JaspervRijbroek', '@jonathanrobichaud4'],
|
||||
zeroconf: ['_nanoleafms._tcp.local.', '_nanoleafapi._tcp.local.'],
|
||||
ssdp: ['Nanoleaf_aurora:light', 'nanoleaf:nl29', 'nanoleaf:nl42', 'nanoleaf:nl52', 'nanoleaf:nl69', 'inanoleaf:nl81'],
|
||||
homekitModels: ['NL29', 'NL42', 'NL47', 'NL48', 'NL52', 'NL59', 'NL69', 'NL81'],
|
||||
};
|
||||
|
||||
public async setup(configArg: INanoleafConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new NanoleafRuntime(new NanoleafClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantNanoleafIntegration extends NanoleafIntegration {}
|
||||
|
||||
class NanoleafRuntime implements IIntegrationRuntime {
|
||||
public domain = 'nanoleaf';
|
||||
|
||||
constructor(private readonly client: NanoleafClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return NanoleafMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return NanoleafMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'nanoleaf' && requestArg.service === 'create_auth_token') {
|
||||
const result = await this.client.createAuthToken();
|
||||
return { success: result.success, error: result.error, data: result };
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'button') {
|
||||
if (requestArg.service === 'press') {
|
||||
await this.client.identify();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Nanoleaf button service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'select') {
|
||||
return this.handleSelectEffect(requestArg);
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'number') {
|
||||
return this.handleNumberService(requestArg);
|
||||
}
|
||||
|
||||
if (requestArg.domain !== 'light') {
|
||||
return { success: false, error: `Unsupported Nanoleaf service domain: ${requestArg.domain}` };
|
||||
}
|
||||
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.handleTurnOn(requestArg);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.turnOff(this.numberValue(requestArg.data, 'transition'));
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'set_value') {
|
||||
return this.handleSetValue(requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_percentage' || requestArg.service === 'set_brightness') {
|
||||
return this.handleSetBrightness(requestArg);
|
||||
}
|
||||
if (requestArg.service === 'select_effect') {
|
||||
return this.handleSelectEffect(requestArg);
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported Nanoleaf light service: ${requestArg.service}` };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async handleTurnOn(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const effect = this.stringValue(requestArg.data, 'effect', 'option');
|
||||
if (effect) {
|
||||
await this.client.setEffect(effect);
|
||||
}
|
||||
|
||||
const hsColor = requestArg.data?.hs_color;
|
||||
if (Array.isArray(hsColor) && typeof hsColor[0] === 'number' && typeof hsColor[1] === 'number') {
|
||||
await this.client.setHue(hsColor[0]);
|
||||
await this.client.setSaturation(hsColor[1]);
|
||||
}
|
||||
|
||||
const colorTemperature = this.numberValue(requestArg.data, 'color_temp_kelvin', 'kelvin', 'ct');
|
||||
if (colorTemperature !== undefined) {
|
||||
await this.client.setColorTemperature(colorTemperature);
|
||||
}
|
||||
|
||||
await this.client.turnOn();
|
||||
|
||||
const brightness = this.brightnessPercent(requestArg.data);
|
||||
if (brightness !== undefined) {
|
||||
await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition'));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async handleNumberService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service !== 'set_value' && requestArg.service !== 'set_percentage' && requestArg.service !== 'set_brightness') {
|
||||
return { success: false, error: `Unsupported Nanoleaf number service: ${requestArg.service}` };
|
||||
}
|
||||
return requestArg.service === 'set_value' ? this.handleSetValue(requestArg) : this.handleSetBrightness(requestArg);
|
||||
}
|
||||
|
||||
private async handleSetValue(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const value = this.numberValue(requestArg.data, 'value', 'brightness', 'percentage');
|
||||
if (value === undefined) {
|
||||
return { success: false, error: 'Nanoleaf set_value requires data.value.' };
|
||||
}
|
||||
|
||||
const target = `${requestArg.target.entityId ?? ''} ${this.stringValue(requestArg.data, 'attribute', 'feature') ?? ''}`.toLowerCase();
|
||||
if (target.includes('hue')) {
|
||||
await this.client.setHue(value);
|
||||
return { success: true };
|
||||
}
|
||||
if (target.includes('saturation') || target.includes('sat')) {
|
||||
await this.client.setSaturation(value);
|
||||
return { success: true };
|
||||
}
|
||||
if (target.includes('temperature') || target.includes('color_temp') || target.includes('ct')) {
|
||||
await this.client.setColorTemperature(value);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
await this.client.setBrightness(this.brightnessPercent(requestArg.data) ?? value, this.numberValue(requestArg.data, 'transition'));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async handleSetBrightness(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const brightness = this.brightnessPercent(requestArg.data);
|
||||
if (brightness === undefined) {
|
||||
return { success: false, error: 'Nanoleaf brightness service requires data.percentage, data.brightness, or data.value.' };
|
||||
}
|
||||
await this.client.setBrightness(brightness, this.numberValue(requestArg.data, 'transition'));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async handleSelectEffect(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const effect = this.stringValue(requestArg.data, 'effect', 'option', 'value');
|
||||
if (!effect) {
|
||||
return { success: false, error: 'Nanoleaf select_effect requires data.effect or data.option.' };
|
||||
}
|
||||
await this.client.setEffect(effect);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private brightnessPercent(dataArg: Record<string, unknown> | undefined): number | undefined {
|
||||
const percentage = this.numberValue(dataArg, 'brightness_pct', 'percentage', 'percent', 'value');
|
||||
if (percentage !== undefined) {
|
||||
return this.clamp(Math.round(percentage), 0, 100);
|
||||
}
|
||||
const brightness = this.numberValue(dataArg, 'brightness');
|
||||
if (brightness === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.clamp(Math.round(brightness > 100 ? brightness / 2.55 : brightness), 0, 100);
|
||||
}
|
||||
|
||||
private numberValue(dataArg: Record<string, unknown> | undefined, ...keysArg: string[]): number | undefined {
|
||||
if (!dataArg) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of keysArg) {
|
||||
const value = dataArg[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringValue(dataArg: Record<string, unknown> | undefined, ...keysArg: string[]): string | undefined {
|
||||
if (!dataArg) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of keysArg) {
|
||||
const value = dataArg[key];
|
||||
if (typeof value === 'string' && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// TODO: replace with the TypeScript-native config for nanoleaf.
|
||||
export type TNanoleafProtocol = 'http' | 'https';
|
||||
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;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "tradfri",
|
||||
displayName: "IKEA TRÅDFRI",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/tradfri",
|
||||
"upstreamDomain": "tradfri",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pytradfri[async]==9.0.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": []
|
||||
},
|
||||
export class TradfriIntegration extends BaseIntegration<ITradfriConfig> {
|
||||
public readonly domain = 'tradfri';
|
||||
public readonly displayName = 'IKEA TR\u00c5DFRI';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createTradfriDiscoveryDescriptor();
|
||||
public readonly configFlow = new TradfriConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/tradfri',
|
||||
upstreamDomain: 'tradfri',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pytradfri[async]==9.0.1'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: [] as string[],
|
||||
documentation: 'https://www.home-assistant.io/integrations/tradfri',
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for tradfri.
|
||||
import type { IDiscoveryCandidate, TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.discovery.js';
|
||||
export * from './wiz.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "wiz",
|
||||
displayName: "WiZ",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/wiz",
|
||||
"upstreamDomain": "wiz",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"pywizlight==0.6.3"
|
||||
export class WizIntegration extends BaseIntegration<IWizConfig> {
|
||||
public readonly domain = 'wiz';
|
||||
public readonly displayName = 'WiZ';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createWizDiscoveryDescriptor();
|
||||
public readonly configFlow = new WizConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/wiz',
|
||||
upstreamDomain: 'wiz',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['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"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@sbidy",
|
||||
"@arturpragacz"
|
||||
]
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
public async setup(configArg: IWizConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new WizRuntime(new WizClient(configArg));
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for wiz.
|
||||
import type { IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.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';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "xiaomi_miio",
|
||||
displayName: "Xiaomi Home",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/xiaomi_miio",
|
||||
"upstreamDomain": "xiaomi_miio",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"construct==2.10.68",
|
||||
"micloud==0.5",
|
||||
"python-miio==0.5.12"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@rytilahti",
|
||||
"@syssi",
|
||||
"@starkillerOG"
|
||||
]
|
||||
},
|
||||
export class XiaomiMiioIntegration extends BaseIntegration<IXiaomiMiioConfig> {
|
||||
public readonly domain = 'xiaomi_miio';
|
||||
public readonly displayName = 'Xiaomi Home';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createXiaomiMiioDiscoveryDescriptor();
|
||||
public readonly configFlow = new XiaomiMiioConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/xiaomi_miio',
|
||||
upstreamDomain: 'xiaomi_miio',
|
||||
documentation: 'https://www.home-assistant.io/integrations/xiaomi_miio',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['construct==2.10.68', 'micloud==0.5', 'python-miio==0.5.12'],
|
||||
zeroconf: ['_miio._udp.local.'],
|
||||
codeowners: ['@rytilahti', '@syssi', '@starkillerOG'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IXiaomiMiioConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new XiaomiMiioRuntime(new XiaomiMiioClient(configArg));
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for xiaomi_miio.
|
||||
import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.discovery.js';
|
||||
export * from './yeelight.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "yeelight",
|
||||
displayName: "Yeelight",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/yeelight",
|
||||
"upstreamDomain": "yeelight",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"yeelight==0.7.16",
|
||||
"async-upnp-client==0.46.2"
|
||||
],
|
||||
"dependencies": [
|
||||
"network"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"ssdp"
|
||||
],
|
||||
"codeowners": [
|
||||
"@zewelor",
|
||||
"@shenxn",
|
||||
"@starkillerOG",
|
||||
"@alexyao2015"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class YeelightIntegration extends BaseIntegration<IYeelightConfig> {
|
||||
public readonly domain = 'yeelight';
|
||||
public readonly displayName = 'Yeelight';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createYeelightDiscoveryDescriptor();
|
||||
public readonly configFlow = new YeelightConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/yeelight',
|
||||
upstreamDomain: 'yeelight',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
documentation: 'https://www.home-assistant.io/integrations/yeelight',
|
||||
requirements: ['yeelight==0.7.16', 'async-upnp-client==0.46.2'],
|
||||
dependencies: ['network'],
|
||||
afterDependencies: ['ssdp'],
|
||||
codeowners: ['@zewelor', '@shenxn', '@starkillerOG', '@alexyao2015'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IYeelightConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new YeelightRuntime(new YeelightClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: replace with the TypeScript-native config for yeelight.
|
||||
export type TYeelightPowerState = 'on' | 'off';
|
||||
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;
|
||||
}
|
||||
|
||||
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.client.js';
|
||||
export * from './zha.classes.configflow.js';
|
||||
export * from './zha.discovery.js';
|
||||
export * from './zha.mapper.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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "zha",
|
||||
displayName: "Zigbee Home Automation",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/zha",
|
||||
"upstreamDomain": "zha",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"zha==1.3.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"file_upload",
|
||||
"homeassistant_hardware"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"hassio",
|
||||
"onboarding",
|
||||
"usb"
|
||||
],
|
||||
"codeowners": [
|
||||
"@dmulcahey",
|
||||
"@adminiuga",
|
||||
"@puddly",
|
||||
"@TheJulianJES"
|
||||
]
|
||||
},
|
||||
export class ZhaIntegration extends BaseIntegration<IZhaConfig> {
|
||||
public readonly domain = 'zha';
|
||||
public readonly displayName = 'Zigbee Home Automation';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createZhaDiscoveryDescriptor();
|
||||
public readonly configFlow = new ZhaConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/zha',
|
||||
upstreamDomain: 'zha',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'unknown',
|
||||
};
|
||||
|
||||
public async setup(configArg: IZhaConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new ZhaRuntime(new ZhaClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantZhaIntegration extends ZhaIntegration {}
|
||||
|
||||
class ZhaRuntime implements IIntegrationRuntime {
|
||||
public domain = 'zha';
|
||||
|
||||
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