Home Assistant group discovery #2410 (#7672)

* Initial

* done
This commit is contained in:
Koen Kanters
2021-06-16 22:48:48 +02:00
committed by GitHub
parent c8fdfc77f2
commit 8dc7ad8dcc
8 changed files with 589 additions and 410 deletions
+460 -393
View File
@@ -18,8 +18,17 @@ const sensorClick = {
const ACCESS_STATE = 0b001;
const ACCESS_SET = 0b010;
const groupSupportedTypes = ['light', 'switch', 'lock', 'cover'];
const defaultStatusTopic = 'homeassistant/status';
const featurePropertyWithoutEndpoint = (feature) => {
if (feature.endpoint) {
return feature.property.slice(0, -1 + -1 * feature.endpoint.length);
} else {
return feature.property;
}
};
/**
* This extensions handles integration with HomeAssistant
*/
@@ -50,10 +59,394 @@ class HomeAssistant extends Extension {
this.eventBus.on('deviceRenamed', (data) =>
this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name,
);
this.eventBus.on(`groupMembersChanged`, (d) => this.groupMembersChanged(d), this.constructor.name);
this.populateMapping();
}
exposeToConfig(exposes, entityType, definition) {
// For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features
// to use for a bulb (e.g. color_xy/color_temp)
assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed');
const firstExpose = exposes[0];
assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type),
`Unsupported expose type ${firstExpose.type} for group`);
let discoveryEntry = null;
const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined;
const getProperty = (feature) => entityType === 'group' ?
featurePropertyWithoutEndpoint(feature) : feature.property;
/* istanbul ignore else */
if (firstExpose.type === 'light') {
const hasColorXY = exposes.find((expose) => expose.features.find((e) => e.name === 'color_xy'));
const hasColorHS = exposes.find((expose) => expose.features.find((e) => e.name === 'color_hs'));
const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness'));
const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp'));
discoveryEntry = {
type: 'light',
object_id: endpoint ? `light_${endpoint}` : 'light',
discovery_payload: {
brightness: !!hasBrightness,
schema: 'json',
command_topic: true,
brightness_scale: 254,
command_topic_prefix: endpoint,
state_topic_postfix: endpoint,
},
};
const colorModes = [
hasColorXY ? 'xy' : null,
!hasColorXY && hasColorHS ? 'hs' : null,
hasColorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.color_mode = true;
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
}
if (entityType === 'device' && hasColorTemp) {
const colorTemp = firstExpose.features.find((e) => e.name === 'color_temp');
discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
}
const effect = definition && definition.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
if (effect) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effect.values;
}
} else if (firstExpose.type === 'switch') {
const state = firstExpose.features.find((f) => f.name === 'state');
const property = getProperty(state);
discoveryEntry = {
type: 'switch',
object_id: endpoint ? `switch_${endpoint}` : 'switch',
discovery_payload: {
payload_off: state.value_off,
payload_on: state.value_on,
value_template: `{{ value_json.${property} }}`,
command_topic: true,
command_topic_prefix: endpoint,
},
};
const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
if (different.includes(property)) {
discoveryEntry.discovery_payload.command_topic_postfix = property;
discoveryEntry.discovery_payload.state_off = state.value_off;
discoveryEntry.discovery_payload.state_on = state.value_on;
discoveryEntry.object_id = property;
if (property === 'window_detection') {
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
}
}
} else if (firstExpose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = firstExpose.features.find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found');
const temperature = firstExpose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found');
discoveryEntry = {
type: 'climate',
object_id: endpoint ? `climate_${endpoint}` : 'climate',
discovery_payload: {
// Static
state_topic: false,
temperature_unit: 'C',
// Setpoint
temp_step: setpoint.value_step,
min_temp: setpoint.value_min.toString(),
max_temp: setpoint.value_max.toString(),
// Temperature
current_temperature_topic: true,
current_temperature_template: `{{ value_json.${temperature.property} }}`,
},
};
const mode = firstExpose.features.find((f) => f.name === 'system_mode');
if (mode) {
if (mode.values.includes('sleep')) {
// 'sleep' is not supported by homeassistent, but is valid according to ZCL
// TRV that support sleep (e.g. Viessmann) will have it removed from here,
// this allows other expose consumers to still use it, e.g. the frontend.
mode.values.splice(mode.values.indexOf('sleep'), 1);
}
discoveryEntry.discovery_payload.mode_state_topic = true;
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
discoveryEntry.discovery_payload.modes = mode.values;
discoveryEntry.discovery_payload.mode_command_topic = true;
}
const state = firstExpose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template =
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = firstExpose.features.find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template =
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const preset = firstExpose.features.find((f) => f.name === 'preset');
if (preset) {
discoveryEntry.discovery_payload.hold_modes = preset.values;
discoveryEntry.discovery_payload.hold_command_topic = true;
discoveryEntry.discovery_payload.hold_state_template =
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.hold_state_topic = true;
}
const awayMode = firstExpose.features.find((f) => f.name === 'away_mode');
if (awayMode) {
discoveryEntry.discovery_payload.away_mode_command_topic = true;
discoveryEntry.discovery_payload.away_mode_state_topic = true;
discoveryEntry.discovery_payload.away_mode_state_template =
`{{ value_json.${awayMode.property} }}`;
}
if (firstExpose.endpoint) {
discoveryEntry.discovery_payload.state_topic_postfix = firstExpose.endpoint;
}
} else if (firstExpose.type === 'lock') {
assert(!endpoint, `Endpoint not supported for lock type`);
const state = firstExpose.features.find((f) => f.name === 'state');
assert(state, 'No state found');
discoveryEntry = {
type: 'lock',
object_id: 'lock',
discovery_payload: {
command_topic: true,
value_template: `{{ value_json.${state.property} }}`,
},
};
if (state.property === 'keypad_lockout') {
// deprecated: keypad_lockout is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'keypad_lock';
} else if (state.property === 'child_lock') {
// deprecated: child_lock is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_locked = 'LOCK';
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK';
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'child_lock';
} else {
discoveryEntry.discovery_payload.state_locked = state.value_on;
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
}
if (state.property !== 'state') {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
}
} else if (firstExpose.type === 'cover') {
assert(!endpoint, `Endpoint not supported for cover type`);
const hasPosition = exposes.find((expose) => expose.features.find((e) => e.name === 'position'));
const hasTilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'));
discoveryEntry = {
type: 'cover',
object_id: 'cover',
discovery_payload: {
command_topic: true,
state_topic: !hasPosition,
},
};
if (!hasPosition && !hasTilt) {
discoveryEntry.discovery_payload.optimistic = true;
}
if (hasPosition) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
position_template: '{{ value_json.position }}',
set_position_template: '{ "position": {{ position }} }',
set_position_topic: true,
position_topic: true,
};
}
if (hasTilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
tilt_status_template: '{{ value_json.tilt }}',
};
}
} else if (firstExpose.type === 'fan') {
assert(!endpoint, `Endpoint not supported for fan type`);
discoveryEntry = {
type: 'fan',
object_id: 'fan',
discovery_payload: {
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
},
};
const speed = firstExpose.features.find((e) => e.name === 'mode');
if (speed) {
discoveryEntry.discovery_payload.speed_state_topic = true;
discoveryEntry.discovery_payload.speed_command_topic = true;
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
discoveryEntry.discovery_payload.speeds = speed.values;
}
} else if (firstExpose.type === 'binary') {
const lookup = {
occupancy: {device_class: 'motion'},
battery_low: {device_class: 'battery'},
water_leak: {device_class: 'moisture'},
vibration: {device_class: 'vibration'},
contact: {device_class: 'door'},
smoke: {device_class: 'smoke'},
gas: {device_class: 'gas'},
carbon_monoxide: {device_class: 'safety'},
presence: {device_class: 'presence'},
};
/**
* If Z2M binary attribute has SET access then expose it as `switch` in HA
* There is also a check on the values for typeof boolean to prevent invalid values and commands
* silently failing - commands work fine but some devices won't reject unexpected values.
* https://github.com/Koenkk/zigbee2mqtt/issues/7740
* Dont expose boolean values for now: https://github.com/Koenkk/zigbee2mqtt/issues/7797
*/
if (firstExpose.access & ACCESS_SET && typeof firstExpose.value_on !== 'boolean') {
discoveryEntry = {
type: 'switch',
object_id: endpoint ?
`switch_${firstExpose.name}_${endpoint}` :
`switch_${firstExpose.name}`,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on,
payload_off: firstExpose.value_off,
command_topic: true,
command_topic_prefix: endpoint,
command_topic_postfix: firstExpose.property,
...(lookup[firstExpose.name] || {}),
},
};
} else {
discoveryEntry = {
type: 'binary_sensor',
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
payload_on: firstExpose.value_on,
payload_off: firstExpose.value_off,
...(lookup[firstExpose.name] || {}),
},
};
}
} else if (firstExpose.type === 'numeric') {
const lookup = {
battery: {device_class: 'battery', state_class: 'measurement'},
temperature: {device_class: 'temperature', state_class: 'measurement'},
humidity: {device_class: 'humidity', state_class: 'measurement'},
illuminance_lux: {device_class: 'illuminance', state_class: 'measurement'},
illuminance: {
device_class: 'illuminance', enabled_by_default: false, state_class: 'measurement',
},
soil_moisture: {icon: 'mdi:water-percent', state_class: 'measurement'},
position: {icon: 'mdi:valve', state_class: 'measurement'},
pressure: {device_class: 'pressure', state_class: 'measurement'},
power: {device_class: 'power', state_class: 'measurement'},
linkquality: {enabled_by_default: false, icon: 'mdi:signal', state_class: 'measurement'},
current: {device_class: 'current', state_class: 'measurement'},
voltage: {device_class: 'voltage', enabled_by_default: false, state_class: 'measurement'},
current_phase_b: {device_class: 'current', state_class: 'measurement'},
voltage_phase_b: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
current_phase_c: {device_class: 'current', state_class: 'measurement'},
voltage_phase_c: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
energy: {device_class: 'energy'},
smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
pm25: {icon: 'mdi:air-filter', state_class: 'measurement'},
pm10: {icon: 'mdi:air-filter', state_class: 'measurement'},
voc: {icon: 'mdi:air-filter', state_class: 'measurement'},
aqi: {icon: 'mdi:air-filter', state_class: 'measurement'},
hcho: {icon: 'mdi:air-filter', state_class: 'measurement'},
requested_brightness_level: {enabled_by_default: false, icon: 'mdi:brightness-5'},
requested_brightness_percent: {enabled_by_default: false, icon: 'mdi:brightness-5'},
eco2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
co2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
local_temperature: {device_class: 'temperature', state_class: 'measurement'},
x_axis: {icon: 'mdi:axis-x-arrow'},
y_axis: {icon: 'mdi:axis-y-arrow'},
z_axis: {icon: 'mdi:axis-z-arrow'},
};
discoveryEntry = {
type: 'sensor',
object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
...lookup[firstExpose.name],
},
};
} else if (firstExpose.type === 'enum' || firstExpose.type === 'text' || firstExpose.type === 'composite') {
if (firstExpose.access & ACCESS_STATE) {
const lookup = {
action: {icon: 'mdi:gesture-double-tap'},
};
discoveryEntry = {
type: 'sensor',
object_id: firstExpose.property,
discovery_payload: {
value_template: `{{ value_json.${firstExpose.property} }}`,
...lookup[firstExpose.name],
},
};
}
} else {
throw new Error(`Unsupported exposes type: '${firstExpose.type}'`);
}
return discoveryEntry;
}
populateMapping() {
for (const def of zigbeeHerdsmanConverters.definitions) {
this.mapping[def.model] = [];
@@ -81,380 +474,14 @@ class HomeAssistant extends Extension {
}
for (const expose of def.exposes) {
let discoveryEntry = null;
/* istanbul ignore else */
if (expose.type === 'light') {
const colorXY = expose.features.find((e) => e.name === 'color_xy');
const colorHS = expose.features.find((e) => e.name === 'color_hs');
const brightness = expose.features.find((e) => e.name === 'brightness');
const colorTemp = expose.features.find((e) => e.name === 'color_temp');
discoveryEntry = {
type: 'light',
object_id: expose.endpoint ? `light_${expose.endpoint}` : 'light',
discovery_payload: {
brightness: !!brightness,
schema: 'json',
command_topic: true,
brightness_scale: 254,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
state_topic_postfix: expose.endpoint ? expose.endpoint : undefined,
},
};
const colorModes = [
colorXY ? 'xy' : null,
!colorXY && colorHS ? 'hs' : null,
colorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.color_mode = true;
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
}
if (colorTemp) {
discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
}
const effect = def.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
if (effect) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effect.values;
}
} else if (expose.type === 'switch') {
const state = expose.features.find((f) => f.name === 'state');
discoveryEntry = {
type: 'switch',
object_id: expose.endpoint ? `switch_${expose.endpoint}` : 'switch',
discovery_payload: {
payload_off: state.value_off,
payload_on: state.value_on,
value_template: `{{ value_json.${state.property} }}`,
command_topic: true,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
},
};
const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
if (different.includes(state.property)) {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
discoveryEntry.discovery_payload.state_off = state.value_off;
discoveryEntry.discovery_payload.state_on = state.value_on;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = state.property;
if (state.property === 'window_detection') {
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
}
}
} else if (expose.type === 'climate') {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = expose.features.find((f) => setpointProperties.includes(f.name));
assert(setpoint, 'No setpoint found');
const temperature = expose.features.find((f) => f.name === 'local_temperature');
assert(temperature, 'No temperature found');
discoveryEntry = {
type: 'climate',
object_id: expose.endpoint ? `climate_${expose.endpoint}` : 'climate',
discovery_payload: {
// Static
state_topic: false,
temperature_unit: 'C',
// Setpoint
temp_step: setpoint.value_step,
min_temp: setpoint.value_min.toString(),
max_temp: setpoint.value_max.toString(),
// Temperature
current_temperature_topic: true,
current_temperature_template: `{{ value_json.${temperature.property} }}`,
},
};
const mode = expose.features.find((f) => f.name === 'system_mode');
if (mode) {
if (mode.values.includes('sleep')) {
// 'sleep' is not supported by homeassistent, but is valid according to ZCL
// TRV that support sleep (e.g. Viessmann) will have it removed from here,
// this allows other expose consumers to still use it, e.g. the frontend.
mode.values.splice(mode.values.indexOf('sleep'), 1);
}
discoveryEntry.discovery_payload.mode_state_topic = true;
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
discoveryEntry.discovery_payload.modes = mode.values;
discoveryEntry.discovery_payload.mode_command_topic = true;
}
const state = expose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
const coolingSetpoint = expose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template =
`{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
} else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template =
`{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = expose.features.find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template =
`{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const preset = expose.features.find((f) => f.name === 'preset');
if (preset) {
discoveryEntry.discovery_payload.hold_modes = preset.values;
discoveryEntry.discovery_payload.hold_command_topic = true;
discoveryEntry.discovery_payload.hold_state_template =
`{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.hold_state_topic = true;
}
const awayMode = expose.features.find((f) => f.name === 'away_mode');
if (awayMode) {
discoveryEntry.discovery_payload.away_mode_command_topic = true;
discoveryEntry.discovery_payload.away_mode_state_topic = true;
discoveryEntry.discovery_payload.away_mode_state_template =
`{{ value_json.${awayMode.property} }}`;
}
if (expose.endpoint) {
discoveryEntry.discovery_payload.state_topic_postfix = expose.endpoint;
}
} else if (expose.type === 'lock') {
assert(!expose.endpoint, `Endpoint not supported for lock type`);
const state = expose.features.find((f) => f.name === 'state');
assert(state, 'No state found');
discoveryEntry = {
type: 'lock',
object_id: 'lock',
discovery_payload: {
command_topic: true,
value_template: `{{ value_json.${state.property} }}`,
},
};
if (state.property === 'keypad_lockout') {
// deprecated: keypad_lockout is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'keypad_lock';
} else if (state.property === 'child_lock') {
// deprecated: child_lock is messy, but changing is breaking
discoveryEntry.discovery_payload.payload_lock = state.value_on;
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
discoveryEntry.discovery_payload.state_locked = 'LOCK';
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK';
discoveryEntry.discovery_payload.state_topic = true;
discoveryEntry.object_id = 'child_lock';
} else {
discoveryEntry.discovery_payload.state_locked = state.value_on;
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
}
if (state.property !== 'state') {
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
}
} else if (expose.type === 'cover') {
assert(!expose.endpoint, `Endpoint not supported for cover type`);
const hasPosition = expose.features.find((e) => e.name === 'position');
const hasTilt = expose.features.find((e) => e.name === 'tilt');
discoveryEntry = {
type: 'cover',
object_id: 'cover',
discovery_payload: {
command_topic: true,
state_topic: !hasPosition,
},
};
if (!hasPosition && !hasTilt) {
discoveryEntry.discovery_payload.optimistic = true;
}
if (hasPosition) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
position_template: '{{ value_json.position }}',
set_position_template: '{ "position": {{ position }} }',
set_position_topic: true,
position_topic: true,
};
}
if (hasTilt) {
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
tilt_status_template: '{{ value_json.tilt }}',
};
}
} else if (expose.type === 'fan') {
assert(!expose.endpoint, `Endpoint not supported for fan type`);
discoveryEntry = {
type: 'fan',
object_id: 'fan',
discovery_payload: {
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
},
};
const speed = expose.features.find((e) => e.name === 'mode');
if (speed) {
discoveryEntry.discovery_payload.speed_state_topic = true;
discoveryEntry.discovery_payload.speed_command_topic = true;
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
discoveryEntry.discovery_payload.speeds = speed.values;
}
} else if (expose.type === 'binary') {
const lookup = {
occupancy: {device_class: 'motion'},
battery_low: {device_class: 'battery'},
water_leak: {device_class: 'moisture'},
vibration: {device_class: 'vibration'},
contact: {device_class: 'door'},
smoke: {device_class: 'smoke'},
gas: {device_class: 'gas'},
carbon_monoxide: {device_class: 'safety'},
presence: {device_class: 'presence'},
};
/**
* If Z2M binary attribute has SET access then expose it as `switch` in HA
* There is also a check on the values for typeof boolean to prevent invalid values and commands
* silently failing - commands work fine but some devices won't reject unexpected values.
* https://github.com/Koenkk/zigbee2mqtt/issues/7740
* Dont expose boolean values for now: https://github.com/Koenkk/zigbee2mqtt/issues/7797
*/
if (expose.access & ACCESS_SET && typeof expose.value_on !== 'boolean') {
discoveryEntry = {
type: 'switch',
object_id: expose.endpoint ?
`switch_${expose.name}_${expose.endpoint}` :
`switch_${expose.name}`,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
payload_on: expose.value_on,
payload_off: expose.value_off,
command_topic: true,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
command_topic_postfix: expose.property,
...(lookup[expose.name] || {}),
},
};
} else {
discoveryEntry = {
type: 'binary_sensor',
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
payload_on: expose.value_on,
payload_off: expose.value_off,
...(lookup[expose.name] || {}),
},
};
}
} else if (expose.type === 'numeric') {
const lookup = {
battery: {device_class: 'battery', state_class: 'measurement'},
temperature: {device_class: 'temperature', state_class: 'measurement'},
humidity: {device_class: 'humidity', state_class: 'measurement'},
illuminance_lux: {device_class: 'illuminance', state_class: 'measurement'},
illuminance: {
device_class: 'illuminance', enabled_by_default: false, state_class: 'measurement',
},
soil_moisture: {icon: 'mdi:water-percent', state_class: 'measurement'},
position: {icon: 'mdi:valve', state_class: 'measurement'},
pressure: {device_class: 'pressure', state_class: 'measurement'},
power: {device_class: 'power', state_class: 'measurement'},
linkquality: {enabled_by_default: false, icon: 'mdi:signal', state_class: 'measurement'},
current: {device_class: 'current', state_class: 'measurement'},
voltage: {device_class: 'voltage', enabled_by_default: false, state_class: 'measurement'},
current_phase_b: {device_class: 'current', state_class: 'measurement'},
voltage_phase_b: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
current_phase_c: {device_class: 'current', state_class: 'measurement'},
voltage_phase_c: {
device_class: 'voltage', enabled_by_default: false, state_class: 'measurement',
},
energy: {device_class: 'energy'},
smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'},
pm25: {icon: 'mdi:air-filter', state_class: 'measurement'},
pm10: {icon: 'mdi:air-filter', state_class: 'measurement'},
voc: {icon: 'mdi:air-filter', state_class: 'measurement'},
aqi: {icon: 'mdi:air-filter', state_class: 'measurement'},
hcho: {icon: 'mdi:air-filter', state_class: 'measurement'},
requested_brightness_level: {enabled_by_default: false, icon: 'mdi:brightness-5'},
requested_brightness_percent: {enabled_by_default: false, icon: 'mdi:brightness-5'},
eco2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
co2: {icon: 'mdi:molecule-co2', state_class: 'measurement'},
local_temperature: {device_class: 'temperature', state_class: 'measurement'},
x_axis: {icon: 'mdi:axis-x-arrow'},
y_axis: {icon: 'mdi:axis-y-arrow'},
z_axis: {icon: 'mdi:axis-z-arrow'},
};
discoveryEntry = {
type: 'sensor',
object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
...(expose.unit && {unit_of_measurement: expose.unit}),
...lookup[expose.name],
},
};
} else if (expose.type === 'enum' || expose.type === 'text' || expose.type === 'composite') {
if (expose.access & ACCESS_STATE) {
const lookup = {
action: {icon: 'mdi:gesture-double-tap'},
};
discoveryEntry = {
type: 'sensor',
object_id: expose.property,
discovery_payload: {
value_template: `{{ value_json.${expose.property} }}`,
...lookup[expose.name],
},
};
}
} else {
throw new Error(`Unsupported exposes type: '${expose.type}'`);
}
const discoveryEntry = this.exposeToConfig([expose], 'device', def);
if (discoveryEntry) {
this.mapping[def.model].push(discoveryEntry);
}
}
}
// Deprecated in favour of exposes
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
if (definition.hasOwnProperty('homeassistant')) {
this.mapping[definition.model] = definition.homeassistant;
@@ -466,11 +493,15 @@ class HomeAssistant extends Extension {
logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`);
delete this.discovered[resolvedEntity.device.ieeeAddr];
for (const config of this.getConfigs(resolvedEntity)) {
const topic = this.getDiscoveryTopic(config, resolvedEntity.device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
}
}
groupMembersChanged(data) {
this.discover(data.group, true);
}
async onPublishEntityState(data) {
/**
* In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes
@@ -537,7 +568,7 @@ class HomeAssistant extends Extension {
// https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
if (homeAssisantRename) {
for (const config of this.getConfigs(resolvedEntity)) {
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
}
}
@@ -559,17 +590,43 @@ class HomeAssistant extends Extension {
this.mqtt.subscribe(`${this.discoveryTopic}/#`);
// MQTT discovery of all paired devices on startup.
for (const device of this.zigbee.getClients()) {
const resolvedEntity = this.zigbee.resolveEntity(device);
for (const entity of [...this.zigbee.getClients(), ...this.zigbee.getGroups()]) {
const resolvedEntity = this.zigbee.resolveEntity(entity);
this.discover(resolvedEntity, true);
}
}
getConfigs(resolvedEntity) {
if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
if (!resolvedEntity || (resolvedEntity.type === 'device' && !resolvedEntity.definition) ||
(resolvedEntity.type === 'device' && !this.mapping[resolvedEntity.definition.model])) return [];
let configs = this.mapping[resolvedEntity.definition.model].slice();
if (resolvedEntity.definition.hasOwnProperty('ota')) {
const isDevice = resolvedEntity.type === 'device';
let configs;
if (isDevice) {
configs = this.mapping[resolvedEntity.definition.model].slice();
} else { // group
const exposesByType = {};
resolvedEntity.group.members.map((m) => zigbeeHerdsmanConverters.findByDevice(m.getDevice()))
.filter((m) => m != null).forEach((definition) => {
for (const expose of definition.exposes.filter((e) => groupSupportedTypes.includes(e.type))) {
let key = expose.type;
if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) {
// A device can have multiple of these types which have to discovered seperately.
// e.g. switch with property state and valve_detection.
const state = expose.features.find((f) => f.name === 'state');
key += featurePropertyWithoutEndpoint(state.property);
}
if (!exposesByType[key]) exposesByType[key] = [];
exposesByType[key].push(expose);
}
});
configs = Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group'));
}
if (isDevice && resolvedEntity.definition.hasOwnProperty('ota')) {
const updateStateSensor = {
type: 'sensor',
object_id: 'update_state',
@@ -596,7 +653,7 @@ class HomeAssistant extends Extension {
}
}
if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
if (isDevice && resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
configs = configs.filter((c) => c !== sensorClick);
}
@@ -624,9 +681,13 @@ class HomeAssistant extends Extension {
discover(resolvedEntity, force=false) {
// Check if already discoverd and check if there are configs.
const {device, definition} = resolvedEntity;
const discover = force || !this.discovered[device.ieeeAddr];
if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
const {device, definition, group} = resolvedEntity;
const discoverKey = resolvedEntity.type === 'device' ? device.ieeeAddr : group.groupID;
const discover = force || !this.discovered[discoverKey];
if (group) {
if (!discover || group.members.length === 0) return;
} else if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
(resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
return;
}
@@ -805,11 +866,11 @@ class HomeAssistant extends Extension {
}
}
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, resolvedEntity);
this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
});
this.discovered[device.ieeeAddr] = true;
this.discovered[discoverKey] = true;
}
onMQTTMessage(topic, message) {
@@ -833,19 +894,19 @@ class HomeAssistant extends Extension {
return;
}
const ieeeAddr = discoveryMatch[2];
const resolvedEntity = this.zigbee.resolveEntity(ieeeAddr);
let clear = !resolvedEntity || !resolvedEntity.definition;
const ID = discoveryMatch[2];
const resolvedEntity = this.zigbee.resolveEntity(ID);
let clear = !resolvedEntity || resolvedEntity.type === 'device' && !resolvedEntity.definition;
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
if (resolvedEntity) {
const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`;
const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`;
if (isDeviceAutomation && message.topic === triggerTopic) {
if (!this.discoveredTriggers[ieeeAddr]) {
this.discoveredTriggers[ieeeAddr] = new Set();
if (!this.discoveredTriggers[ID]) {
this.discoveredTriggers[ID] = new Set();
}
this.discoveredTriggers[ieeeAddr].add(discoveryMatch[3]);
this.discoveredTriggers[ID].add(discoveryMatch[3]);
}
}
@@ -884,13 +945,18 @@ class HomeAssistant extends Extension {
}
getDevicePayload(resolvedEntity) {
return {
const payload = {
identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
name: resolvedEntity.settings.friendlyName,
sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
model: `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`,
manufacturer: resolvedEntity.definition.vendor,
};
if (resolvedEntity.type === 'device') {
payload.model = `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`;
payload.manufacturer = resolvedEntity.definition.vendor;
}
return payload;
}
adjustMessagePayloadBeforePublish(resolvedEntity, messagePayload) {
@@ -926,8 +992,9 @@ class HomeAssistant extends Extension {
}
}
getDiscoveryTopic(config, device) {
return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`;
getDiscoveryTopic(config, resolvedEntity) {
const key = resolvedEntity.type === 'device' ? resolvedEntity.device.ieeeAddr : resolvedEntity.group.groupID;
return `${config.type}/${key}/${config.object_id}/config`;
}
async publishDeviceTriggerDiscover(entity, key, value, force=false) {
@@ -956,7 +1023,7 @@ class HomeAssistant extends Extension {
},
};
const topic = this.getDiscoveryTopic(config, device);
const topic = this.getDiscoveryTopic(config, entity);
const payload = {
...config.discovery_payload,
subtype: value,
+5 -5
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -317,11 +317,12 @@ describe('Groups', () => {
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device1, endpoint: device1.getEndpoint(1), type: 'attributeReport', linkquality: 10});
await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device: device2, endpoint: device2.getEndpoint(1), type: 'attributeReport', linkquality: 10});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(4);
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/group_with_tradfri", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith("zigbee2mqtt/ha_discovery_group", stringify({"state":"ON"}), {"retain": false, qos: 0}, expect.any(Function));
});
it('Should publish state change of all members when a group changes its state', async () => {
+96 -1
View File
@@ -44,13 +44,68 @@ describe('HomeAssistant extension', () => {
expect(duplicated).toHaveLength(0);
});
it('Should discover devices', async () => {
it('Should discover devices and groups', async () => {
controller = new Controller(false);
await controller.start();
let payload;
await flushPromises();
payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"brightness":true,
"brightness_scale":254,
"color_mode":true,
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":[
"zigbee2mqtt_9"
],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"schema":"json",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"supported_color_modes":[
"xy",
"color_temp"
],
"unique_id":"9_light_zigbee2mqtt"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/9/light/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":["zigbee2mqtt_9"],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"payload_off":"OFF",
"payload_on":"ON",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"unique_id":"9_switch_zigbee2mqtt",
"value_template":"{{ value_json.state }}"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/switch/9/switch/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
payload = {
'unit_of_measurement': '°C',
'device_class': 'temperature',
@@ -1614,4 +1669,44 @@ describe('HomeAssistant extension', () => {
expect.any(Function),
);
});
it('Should rediscover group when device is added to it', async () => {
controller = new Controller(false);
await controller.start();
await flushPromises();
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'}));
await flushPromises();
const payload = {
"availability":[{"topic":"zigbee2mqtt/bridge/state"}],
"brightness":true,
"brightness_scale":254,
"color_mode":true,
"command_topic":"zigbee2mqtt/ha_discovery_group/set",
"device":{
"identifiers":[
"zigbee2mqtt_9"
],
"name":"ha_discovery_group",
"sw_version":this.version,
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":"ha_discovery_group",
"schema":"json",
"state_topic":"zigbee2mqtt/ha_discovery_group",
"supported_color_modes":[
"xy",
"color_temp"
],
"unique_id":"9_light_zigbee2mqtt"
};
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/9/light/config',
stringify(payload),
{ retain: true, qos: 0 },
expect.any(Function),
);
});
});
+1 -1
View File
@@ -188,7 +188,7 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log');
const payload = JSON.parse(MQTT.publish.mock.calls[0][1]);
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"});
expect(payload).toStrictEqual({"message":[{"ID":1,"devices":[],"friendly_name":"group_1","retain":false},{"ID":2,"devices":[],"friendly_name":"group_2","retain":false},{"ID":9,"devices":["bulb_color_2","bulb_2","wall_switch_double/right"],"friendly_name":"ha_discovery_group"},{"ID":11,"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},{"ID":12,"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},{"ID":14,"devices":["power_plug"],"friendly_name":"switch_group","retain":false},{"ID":21,"devices":["GLEDOPTO_2ID/cct"],"friendly_name":"gledopto_group"},{"ID":15071,"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false}],"type":"groups"});
});
it('Should allow rename devices', async () => {
+17 -7
View File
@@ -176,10 +176,10 @@ describe('Publish', () => {
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genOnOff", "off", {}, {});
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/wall_switch_double');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({state_right: 'OFF'});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 0, "retain": false});
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/wall_switch_double');
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({state_right: 'OFF'});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({"qos": 0, "retain": false});
});
it('Should publish messages to zigbee devices to non default-ep with state_[EP]', async () => {
@@ -403,10 +403,10 @@ describe('Publish', () => {
it('Should create and publish to group which is in configuration.yaml but not in zigbee-herdsman', async () => {
delete zigbeeHerdsman.groups.group_2;
expect(Object.values(zigbeeHerdsman.groups).length).toBe(8);
expect(Object.values(zigbeeHerdsman.groups).length).toBe(9);
await MQTT.events.message('zigbee2mqtt/group_2/set', stringify({state: 'ON'}));
await flushPromises();
expect(Object.values(zigbeeHerdsman.groups).length).toBe(9);
expect(Object.values(zigbeeHerdsman.groups).length).toBe(10);
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledTimes(1);
expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledWith("genOnOff", "on", {}, {});
});
@@ -1400,7 +1400,7 @@ describe('Publish', () => {
await flushPromises();
expect(group.command).toHaveBeenCalledTimes(1);
expect(group.command).toHaveBeenCalledWith('genScenes', 'recall', { groupid: 15071, sceneid: 1 }, {});
expect(MQTT.publish).toHaveBeenCalledTimes(5);
expect(MQTT.publish).toHaveBeenCalledTimes(7);
expect(MQTT.publish).toHaveBeenNthCalledWith(1,
'zigbee2mqtt/group_tradfri_remote',
stringify({"brightness":50,"color_temp":290,"state":"ON","color_mode": "color_temp"}),
@@ -1422,10 +1422,20 @@ describe('Publish', () => {
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(5,
'zigbee2mqtt/ha_discovery_group',
stringify({"brightness":50,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(6,
'zigbee2mqtt/group_with_tradfri',
stringify({"brightness":100,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
expect(MQTT.publish).toHaveBeenNthCalledWith(7,
'zigbee2mqtt/ha_discovery_group',
stringify({"brightness":100,"color_mode":"color_temp","color_temp":290,"state":"ON"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should sync colors', async () => {
+5 -1
View File
@@ -207,7 +207,11 @@ function writeDefaultConfiguration() {
'21': {
friendly_name: 'gledopto_group',
devices: ['GLEDOPTO_2ID/cct'],
}
},
'9': {
friendly_name: 'ha_discovery_group',
devices: ['bulb_color_2', 'bulb_2', 'wall_switch_double/right']
},
},
external_converters: [],
};
+3 -1
View File
@@ -132,6 +132,7 @@ const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new End
const TS0601_thermostat = new Device('EndDevice', '0x0017882104a44559', 6544,4151, [new Endpoint(1, [], [], '0x0017882104a44559')], true, "Mains (single phase)", 'kud7u2l');
const ZNCZ02LM = new Device('Router', '0x0017880104e45524', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45524')], true, "Mains (single phase)", "lumi.plug");
const GLEDOPTO_2ID = new Device('Router', '0x0017880104e45724', 6540,4151, [new Endpoint(11, [0,3,4,5,6,8,768], [], '0x0017880104e45724', [], {}, [], 49246, 528), new Endpoint(12, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 260, 258), new Endpoint(13, [4096], [4096], '0x0017880104e45724', [], {}, [], 49246, 57694), new Endpoint(15, [0, 3, 4, 5, 6, 8, 768], [], '0x0017880104e45724', [], {}, [], 49246, 256)], true, "Mains (single phase)", 'GL-C-007', false, 'GLEDOPTO');
const QBKG03LM = new Device('Router', '0x0017880104e45542', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45542'), new Endpoint(2, [0, 6], [], '0x0017880104e45542'), new Endpoint(3, [0, 6], [], '0x0017880104e45542')], true, "Mains (single phase)", 'lumi.ctrl_neutral2');
const groups = {
'group_1': new Group(1, []),
@@ -142,6 +143,7 @@ const groups = {
'group_with_switch': new Group(14, [ZNCZ02LM.endpoints[0]]),
'gledopto_group': new Group(21, [GLEDOPTO_2ID.endpoints[3]]),
'default_bind_group': new Group(901, []),
'ha_discovery_group': new Group(9, [bulb_color_2.endpoints[0], bulb_2.endpoints[0], QBKG03LM.endpoints[1]]),
}
const devices = {
@@ -162,7 +164,7 @@ const devices = {
'ZNCZ02LM': ZNCZ02LM,
'E1743': new Device('Router', '0x0017880104e45540', 6540,4476, [new Endpoint(1, [0], [])], true, "Mains (single phase)", 'TRADFRI on/off switch'),
'QBKG04LM': new Device('Router', '0x0017880104e45541', 6549,4151, [new Endpoint(1, [0], [25]), new Endpoint(2, [0, 6], [])], true, "Mains (single phase)", 'lumi.ctrl_neutral1'),
'QBKG03LM':new Device('Router', '0x0017880104e45542', 6540,4151, [new Endpoint(1, [0], [], '0x0017880104e45542'), new Endpoint(2, [0, 6], [], '0x0017880104e45542'), new Endpoint(3, [0, 6], [], '0x0017880104e45542')], true, "Mains (single phase)", 'lumi.ctrl_neutral2'),
'QBKG03LM':QBKG03LM,
'GLEDOPTO1112': new Device('Router', '0x0017880104e45543', 6540, 4151, [new Endpoint(11, [0], [], '0x0017880104e45543'), new Endpoint(13, [0], [], '0x0017880104e45543')], true, "Mains (single phase)", 'GL-C-008'),
'GLEDOPTO111213': new Device('Router', '0x0017880104e45544', 6540,4151, [new Endpoint(11, [0], []), new Endpoint(13, [0], []), new Endpoint(12, [0], [])], true, "Mains (single phase)", 'GL-C-008'),
'GLEDOPTO_2ID': GLEDOPTO_2ID,