Skip to content

Commit

Permalink
feat: speed-controlled fans
Browse files Browse the repository at this point in the history
This implements the discovery side for speed-controlled fans. Here we no
longer need to emulate speed changes by changing modes and there is a
readily-usable onOff cluster for state. Fans now need to be either mode-
or speed-controlled, otherwise an assert is hit. No such fans currently
exist in the Z2M codebase and they cannot be reasonably controlled
anyways.
  • Loading branch information
lorenz committed Oct 25, 2024
1 parent c09aeec commit efaeb93
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 11 deletions.
38 changes: 27 additions & 11 deletions lib/extension/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,15 +936,14 @@ export default class HomeAssistant extends Extension {
discovery_payload: {
name: null,
state_topic: true,
state_value_template: '{{ value_json.fan_state }}',
command_topic: true,
command_topic_postfix: 'fan_state',
},
};

const speed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
const modeEmulatedSpeed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
const nativeSpeed = (firstExpose as zhc.Fan).features.filter(isNumericExpose).find((e) => e.name === 'speed');
// istanbul ignore else
if (speed) {
if (modeEmulatedSpeed) {
// A fan entity in Home Assistant 2021.3 and above may have a speed,
// controlled by a percentage from 1 to 100, and/or non-speed presets.
// The MQTT Fan integration allows the speed percentage to be mapped
Expand All @@ -958,9 +957,9 @@ export default class HomeAssistant extends Extension {
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
// always a valid speed.
let speeds = ['off'].concat(
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)),
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => modeEmulatedSpeed.values.includes(s)),
);
let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s));
let presets = ['on', 'auto', 'smart'].filter((s) => modeEmulatedSpeed.values.includes(s));

if (['99432'].includes(definition!.model)) {
// The Hampton Bay 99432 fan implements 4 speeds using the ZCL
Expand All @@ -972,22 +971,39 @@ export default class HomeAssistant extends Extension {
}

const allowed = [...speeds, ...presets];
speed.values.forEach((s) => assert(allowed.includes(s.toString())));
modeEmulatedSpeed.values.forEach((s) => assert(allowed.includes(s.toString())));
const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', ');
const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', ');
const presetList = presets.map((s) => `'${s}'`).join(', ');

discoveryEntry.discovery_payload.percentage_state_topic = true;
discoveryEntry.discovery_payload.percentage_command_topic = true;
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
discoveryEntry.discovery_payload.percentage_command_topic = 'fan_mode';
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${modeEmulatedSpeed.property}] | default('None') }}`;
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
discoveryEntry.discovery_payload.speed_range_min = 1;
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
assert(presets.length !== 0);
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode';
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`;
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${modeEmulatedSpeed.property} if value_json.${modeEmulatedSpeed.property} in [${presetList}] else 'None' | default('None') }}`;
discoveryEntry.discovery_payload.preset_modes = presets;

// Emulate state based on mode
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.fan_state }}';
discoveryEntry.discovery_payload.command_topic_postfix = 'fan_state';
} else if (nativeSpeed) {
discoveryEntry.discovery_payload.percentage_state_topic = true;
discoveryEntry.discovery_payload.percentage_command_topic = 'fan_speed';
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json.${nativeSpeed.property} | default('None') }}`;
discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`;
discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min;
discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max;

// Speed-controlled fans generally have an onOff cluster, use that for state
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.state }}';
discoveryEntry.discovery_payload.command_topic_postfix = 'state';
} else {
assert(false, 'Fans need to be either mode- or speed-controlled');
}

discoveryEntries.push(discoveryEntry);
Expand Down Expand Up @@ -1727,7 +1743,7 @@ export default class HomeAssistant extends Extension {
}

if (payload.percentage_command_topic) {
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/fan_mode`;
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.percentage_command_topic}`;
}

if (payload.preset_mode_state_topic) {
Expand Down
39 changes: 39 additions & 0 deletions test/homeassistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,45 @@ describe('HomeAssistant extension', () => {
);
});

it('Should discover devices with speed-controlled fan', async () => {
let payload;

payload = {
state_topic: 'zigbee2mqtt/fanbee',
state_value_template: '{{ value_json.state }}',
command_topic: 'zigbee2mqtt/fanbee/set/state',
percentage_state_topic: 'zigbee2mqtt/fanbee',
percentage_command_topic: 'zigbee2mqtt/fanbee/set/fan_speed',
percentage_value_template: "{{ value_json.fan_speed | default('None') }}",
percentage_command_template: "{{ value | default('') }}",
speed_range_min: 1,
speed_range_max: 254,
json_attributes_topic: 'zigbee2mqtt/fanbee',
name: null,
object_id: 'fanbee',
unique_id: '0x00124b00cfcf3298_fan_zigbee2mqtt',
origin: origin,
device: {
identifiers: ['zigbee2mqtt_0x00124b00cfcf3298'],
name: 'fanbee',
sw_version: null,
model: 'Fan with valve (FanBee)',
manufacturer: 'Lorenz Brun',
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
},
availability: [{topic: 'zigbee2mqtt/bridge/state'}],
};
const idx = MQTT.publish.mock.calls.findIndex(c => c[0] === 'homeassistant/fan/0x00124b00cfcf3298/fan/config');
expect(idx).not.toBe(-1);
expect(MQTT.publish).toHaveBeenNthCalledWith(
idx+1,
'homeassistant/fan/0x00124b00cfcf3298/fan/config',
stringify(payload),
{retain: true, qos: 1},
expect.any(Function),
);
});

it('Should discover thermostat devices', async () => {
const payload = {
action_template:
Expand Down
4 changes: 4 additions & 0 deletions test/stub/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ function writeDefaultConfiguration() {
'0x0017880104e45562': {
friendly_name: 'heating_actuator',
},
'0x00124b00cfcf3298': {
friendly_name: 'fanbee',
retain: true,
},
},
groups: {
1: {
Expand Down
10 changes: 10 additions & 0 deletions test/stub/zigbeeHerdsman.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,16 @@ const devices = {
null,
custom_clusters,
),
fanbee: new Device(
'Router',
'0x00124b00cfcf3298',
18129,
0xfff1,
[new Endpoint(8, [0, 3, 4, 5, 6, 8], []), new Endpoint(242, [], [33])],
true,
'DC Source',
'FanBee1',
),
};

const mock = {
Expand Down

0 comments on commit efaeb93

Please sign in to comment.