Skip to content

Commit b528d96

Browse files
committed
feat: speed-controlled fans
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.
1 parent 1accb8b commit b528d96

File tree

5 files changed

+79
-11
lines changed

5 files changed

+79
-11
lines changed

lib/extension/homeassistant.ts

+28-11
Original file line numberDiff line numberDiff line change
@@ -872,15 +872,17 @@ export default class HomeAssistant extends Extension {
872872
discovery_payload: {
873873
name: null,
874874
state_topic: true,
875-
state_value_template: '{{ value_json.fan_state }}',
876875
command_topic: true,
877-
command_topic_postfix: 'fan_state',
878876
},
879877
};
880878

881-
const speed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
879+
const modeEmulatedSpeed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode');
880+
const nativeSpeed = (firstExpose as zhc.Fan).features.filter(isNumericExpose).find((e) => e.name === 'speed');
882881

883-
if (speed) {
882+
// Exactly one mode needs to be active (logical xor)
883+
assert(!modeEmulatedSpeed != !nativeSpeed, 'Fans need to be either mode- or speed-controlled');
884+
885+
if (modeEmulatedSpeed) {
884886
// A fan entity in Home Assistant 2021.3 and above may have a speed,
885887
// controlled by a percentage from 1 to 100, and/or non-speed presets.
886888
// The MQTT Fan integration allows the speed percentage to be mapped
@@ -894,9 +896,9 @@ export default class HomeAssistant extends Extension {
894896
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
895897
// always a valid speed.
896898
let speeds = ['off'].concat(
897-
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)),
899+
['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => modeEmulatedSpeed.values.includes(s)),
898900
);
899-
let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s));
901+
let presets = ['on', 'auto', 'smart'].filter((s) => modeEmulatedSpeed.values.includes(s));
900902

901903
if (['99432'].includes(definition!.model)) {
902904
// The Hampton Bay 99432 fan implements 4 speeds using the ZCL
@@ -908,22 +910,37 @@ export default class HomeAssistant extends Extension {
908910
}
909911

910912
const allowed = [...speeds, ...presets];
911-
speed.values.forEach((s) => assert(allowed.includes(s.toString())));
913+
modeEmulatedSpeed.values.forEach((s) => assert(allowed.includes(s.toString())));
912914
const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', ');
913915
const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', ');
914916
const presetList = presets.map((s) => `'${s}'`).join(', ');
915917

916918
discoveryEntry.discovery_payload.percentage_state_topic = true;
917-
discoveryEntry.discovery_payload.percentage_command_topic = true;
918-
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`;
919+
discoveryEntry.discovery_payload.percentage_command_topic = 'fan_mode';
920+
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${modeEmulatedSpeed.property}] | default('None') }}`;
919921
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
920922
discoveryEntry.discovery_payload.speed_range_min = 1;
921923
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
922924
assert(presets.length !== 0);
923925
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
924926
discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode';
925-
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`;
927+
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${modeEmulatedSpeed.property} if value_json.${modeEmulatedSpeed.property} in [${presetList}] else 'None' | default('None') }}`;
926928
discoveryEntry.discovery_payload.preset_modes = presets;
929+
930+
// Emulate state based on mode
931+
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.fan_state }}';
932+
discoveryEntry.discovery_payload.command_topic_postfix = 'fan_state';
933+
} else if (nativeSpeed) {
934+
discoveryEntry.discovery_payload.percentage_state_topic = true;
935+
discoveryEntry.discovery_payload.percentage_command_topic = 'speed';
936+
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json.${nativeSpeed.property} | default('None') }}`;
937+
discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`;
938+
discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min;
939+
discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max;
940+
941+
// Speed-controlled fans generally have an onOff cluster, use that for state
942+
discoveryEntry.discovery_payload.state_value_template = '{{ value_json.state }}';
943+
discoveryEntry.discovery_payload.command_topic_postfix = 'state';
927944
}
928945

929946
discoveryEntries.push(discoveryEntry);
@@ -1621,7 +1638,7 @@ export default class HomeAssistant extends Extension {
16211638
}
16221639

16231640
if (payload.percentage_command_topic) {
1624-
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/fan_mode`;
1641+
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.percentage_command_topic}`;
16251642
}
16261643

16271644
if (payload.preset_mode_state_topic) {

test/extensions/bridge.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ describe('Extension: Bridge', () => {
141141
retain: false,
142142
},
143143
'0x000b57fffec6a5b7': {friendly_name: 'bulb_2', retain: false},
144+
'0x00124b00cfcf3298': {friendly_name: 'fanbee', retain: true},
144145
'0x0017880104a44559': {friendly_name: 'J1_cover'},
145146
'0x0017880104e43559': {friendly_name: 'U202DST600ZB'},
146147
'0x0017880104e44559': {friendly_name: '3157100_thermostat'},

test/extensions/homeassistant.test.ts

+36
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,42 @@ describe('Extension: HomeAssistant', () => {
873873
});
874874
});
875875

876+
it('Should discover devices with speed-controlled fan', async () => {
877+
const payload = {
878+
state_topic: 'zigbee2mqtt/fanbee',
879+
state_value_template: '{{ value_json.state }}',
880+
command_topic: 'zigbee2mqtt/fanbee/set/state',
881+
percentage_state_topic: 'zigbee2mqtt/fanbee',
882+
percentage_command_topic: 'zigbee2mqtt/fanbee/set/speed',
883+
percentage_value_template: "{{ value_json.speed | default('None') }}",
884+
percentage_command_template: "{{ value | default('') }}",
885+
speed_range_min: 1,
886+
speed_range_max: 254,
887+
name: null,
888+
object_id: 'fanbee',
889+
unique_id: '0x00124b00cfcf3298_fan_zigbee2mqtt',
890+
origin: origin,
891+
device: {
892+
identifiers: ['zigbee2mqtt_0x00124b00cfcf3298'],
893+
name: 'fanbee',
894+
model: 'Fan with valve',
895+
model_id: 'FanBee',
896+
manufacturer: 'Lorenz Brun',
897+
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
898+
},
899+
availability: [
900+
{
901+
topic: 'zigbee2mqtt/bridge/state',
902+
value_template: '{{ value_json.state }}',
903+
},
904+
],
905+
};
906+
907+
const idx = mockMQTTPublishAsync.mock.calls.findIndex((c) => c[0] === 'homeassistant/fan/0x00124b00cfcf3298/fan/config');
908+
expect(idx).not.toBe(-1);
909+
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[idx][1])).toStrictEqual(payload);
910+
});
911+
876912
it('Should discover thermostat devices', async () => {
877913
const payload = {
878914
action_template:

test/mocks/data.ts

+4
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ export const DEFAULT_CONFIGURATION = {
205205
'0x000b57cdfec6a5b3': {
206206
friendly_name: 'hue_twilight',
207207
},
208+
'0x00124b00cfcf3298': {
209+
friendly_name: 'fanbee',
210+
retain: true,
211+
},
208212
},
209213
groups: {
210214
1: {

test/mocks/zigbeeHerdsman.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,16 @@ export const devices = {
11361136
undefined,
11371137
CUSTOM_CLUSTERS,
11381138
),
1139+
fanbee: new Device(
1140+
'Router',
1141+
'0x00124b00cfcf3298',
1142+
18129,
1143+
0xfff1,
1144+
[new Endpoint(8, [0, 3, 4, 5, 6, 8], []), new Endpoint(242, [], [33])],
1145+
true,
1146+
'DC Source',
1147+
'FanBee1',
1148+
),
11391149
};
11401150

11411151
export const mockController = {

0 commit comments

Comments
 (0)