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();