Add native hub protocol integrations

This commit is contained in:
2026-05-05 14:57:06 +00:00
parent 2823a1c718
commit 1eebd71e7d
102 changed files with 16316 additions and 330 deletions
+49
View File
@@ -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();
+136
View File
@@ -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();
+104
View File
@@ -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();
+48
View File
@@ -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();
+90
View File
@@ -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();
+123
View File
@@ -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();
+56
View File
@@ -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();
+83
View File
@@ -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();
+19
View File
@@ -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();
+32
View File
@@ -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
View File
@@ -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));
}
}
+250
View File
@@ -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 {};
}
};
+525
View File
@@ -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));
}
}
+269 -2
View File
@@ -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;
}
+4
View File
@@ -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());
};
+529
View File
@@ -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';
}
}
+248 -2
View File
@@ -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>;
}
+4
View File
@@ -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';
+11 -21
View File
@@ -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.
+4
View File
@@ -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';
}
}
+111
View File
@@ -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());
};
+873
View File
@@ -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';
}
}
+178 -3
View File
@@ -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.
+4
View File
@@ -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());
};
+274
View File
@@ -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);
}
}
+183 -2
View File
@@ -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.
+5 -1
View File
@@ -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());
};
+829
View File
@@ -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';
}
}
+294 -2
View File
@@ -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.
+4
View File
@@ -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';
+278
View File
@@ -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);
}
}
+76 -25
View File
@@ -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();
}
}
+314
View File
@@ -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());
};
+764
View File
@@ -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);
}
}
+263 -2
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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());
};
+219
View File
@@ -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';
}
}
+216 -2
View File
@@ -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.
+4
View File
@@ -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';
+77
View File
@@ -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);
}
}
+125 -32
View File
@@ -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);
}
}
+322
View File
@@ -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