Skip to content

Commit

Permalink
feat: Support calibration + Venetian mode for Legrand 067776(A) (#6333)
Browse files Browse the repository at this point in the history
* feat(ignore): Refactor legrand.ts

Introduced a Legrand specific library

* feat(add): Added 067776(A) specifics to library

* Added array of supported calibration modes
* Added FZ/TZ to support calibration modes
* Added 067776(A) specific helper methods

* fix(ignore): Switched to Legrand spec. attributes

Requires zigbee-herdsman PR#784

* feat(add): 067776(A) Enabled new features

* Support calibration mode switching
* Support Venetian (BSO) mode

* fix(ignore): Updated Legrand 067776(A) reporting

Added manufacturer code to reporting

* fix(refactor): Refactored shutterCalibrationModes

Avoid the usage of string litterals as keys

* fix(ignore): Refactor shutterCalibrationModes

Changed description to snake_case
  • Loading branch information
FabianMangold authored Oct 30, 2023
1 parent 73c0b11 commit a7b5667
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 49 deletions.
73 changes: 24 additions & 49 deletions src/devices/legrand.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,15 @@
import {Definition, Fz, OnEvent, Tz} from '../lib/types';
import {Definition} from '../lib/types';
import * as exposes from '../lib/exposes';
import fz from '../converters/fromZigbee';
import * as legacy from '../lib/legacy';
import tz from '../converters/toZigbee';
import * as reporting from '../lib/reporting';
import extend from '../lib/extend';
import * as utils from '../lib/utils';
import * as ota from '../lib/ota';
import {tzLegrand, fzLegrand, readInitialBatteryState, _067776} from '../lib/legrand';
const e = exposes.presets;
const ea = exposes.access;

const readInitialBatteryState: OnEvent = async (type, data, device, options) => {
if (['deviceAnnounce'].includes(type)) {
const endpoint = device.getEndpoint(1);
const options = {manufacturerCode: 0x1021, disableDefaultResponse: true};
await endpoint.read('genPowerCfg', ['batteryVoltage'], options);
}
};

const tzLocal = {
auto_mode: {
key: ['auto_mode'],
convertSet: async (entity, key, value, meta) => {
const mode = utils.getFromLookup(value, {'off': 0x00, 'auto': 0x02, 'on_override': 0x03});
const payload = {data: Buffer.from([mode])};
await entity.command('manuSpecificLegrandDevices3', 'command0', payload);
return {state: {'auto_mode': value}};
},
} as Tz.Converter,
};

const fzlocal = {
legrand_600087l: {
cluster: 'greenPower',
type: ['commandNotification'],
convert: (model, msg, publish, options, meta) => {
const commandID = msg.data.commandID;
const lookup: {[s: number]: string} = {0x34: 'stop', 0x35: 'up', 0x36: 'down'};
if (commandID === 224) return;
if (!lookup.hasOwnProperty(commandID)) {
meta.logger.error(`GreenPower_3 error: missing command '${commandID}'`);
} else {
return {action: lookup[commandID]};
}
},
} as Fz.Converter,
};

const definitions: Definition[] = [
{
zigbeeModel: [' Pocket remote\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000',
Expand Down Expand Up @@ -100,7 +63,7 @@ const definitions: Definition[] = [
extend: extend.switch(),
ota: ota.zigbeeOTA,
fromZigbee: [fz.identify, fz.on_off, fz.electrical_measurement, fz.legrand_cluster_fc01, fz.ignore_basic_report, fz.ignore_genOta],
toZigbee: [tz.legrand_deviceMode, tz.on_off, tz.legrand_identify, tz.electrical_measurement_power, tzLocal.auto_mode],
toZigbee: [tz.legrand_deviceMode, tz.on_off, tz.legrand_identify, tz.electrical_measurement_power, tzLegrand.auto_mode],
exposes: [
e.switch().withState('state', true, 'On/off (works only if device is in "switch" mode)'),
e.power().withAccess(ea.STATE_GET),
Expand Down Expand Up @@ -170,20 +133,26 @@ const definitions: Definition[] = [
vendor: 'Legrand',
description: 'Netatmo wired shutter switch',
ota: ota.zigbeeOTA,
fromZigbee: [fz.ignore_basic_report, fz.cover_position_tilt, fz.legrand_binary_input_moving, fz.identify, fz.legrand_led_in_dark],
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.legrand_identify, tz.legrand_settingEnableLedInDark],
fromZigbee: [fz.ignore_basic_report, fz.cover_position_tilt, fz.legrand_binary_input_moving, fz.identify,
fz.legrand_led_in_dark, fzLegrand.calibration_mode(false)],
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.legrand_identify, tz.legrand_settingEnableLedInDark, tzLegrand.calibration_mode(false)],
exposes: [
e.cover_position(),
_067776.getCover(),
e.action(['moving', 'identify']),
e.enum('identify', ea.SET, ['blink'])
.withDescription('Blinks the built-in LED to make it easier to identify the device'),
e.binary('led_in_dark', ea.ALL, 'ON', 'OFF')
.withDescription('Enables the built-in LED allowing to see the switch in the dark'),
_067776.getCalibrationModes(false),
],
configure: async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(1);
await reporting.bind(endpoint, coordinatorEndpoint, ['genBinaryInput', 'closuresWindowCovering', 'genIdentify']);
await reporting.currentPositionLiftPercentage(endpoint);
let p = reporting.payload('currentPositionLiftPercentage', 1, 120, 1);
await endpoint.configureReporting('closuresWindowCovering', p, {manufacturerCode: 4129});

p = reporting.payload('currentPositionTiltPercentage', 1, 120, 1);
await endpoint.configureReporting('closuresWindowCovering', p, {manufacturerCode: 4129});
},
},
{
Expand Down Expand Up @@ -220,20 +189,26 @@ const definitions: Definition[] = [
vendor: 'Legrand',
description: 'Netatmo wired shutter switch with level control (NLLV)',
ota: ota.zigbeeOTA,
fromZigbee: [fz.ignore_basic_report, fz.cover_position_tilt, fz.legrand_binary_input_moving, fz.identify, fz.legrand_led_in_dark],
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.legrand_identify, tz.legrand_settingEnableLedInDark],
fromZigbee: [fz.ignore_basic_report, fz.cover_position_tilt, fz.legrand_binary_input_moving, fz.identify,
fz.legrand_led_in_dark, fzLegrand.calibration_mode(true)],
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.legrand_identify, tz.legrand_settingEnableLedInDark, tzLegrand.calibration_mode(true)],
exposes: [
e.cover_position(),
_067776.getCover(),
e.action(['moving', 'identify']),
e.enum('identify', ea.SET, ['blink'])
.withDescription('Blinks the built-in LED to make it easier to identify the device'),
e.binary('led_in_dark', ea.ALL, 'ON', 'OFF')
.withDescription('Enables the built-in LED allowing to see the switch in the dark'),
_067776.getCalibrationModes(true),
],
configure: async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(1);
await reporting.bind(endpoint, coordinatorEndpoint, ['genBinaryInput', 'closuresWindowCovering', 'genIdentify']);
await reporting.currentPositionLiftPercentage(endpoint);
let p = reporting.payload('currentPositionLiftPercentage', 1, 120, 1);
await endpoint.configureReporting('closuresWindowCovering', p, {manufacturerCode: 4129});

p = reporting.payload('currentPositionTiltPercentage', 1, 120, 1);
await endpoint.configureReporting('closuresWindowCovering', p, {manufacturerCode: 4129});
},
},
{
Expand Down Expand Up @@ -538,7 +513,7 @@ const definitions: Definition[] = [
model: '600087L',
vendor: 'Legrand',
description: 'Wireless and batteryless blind control switch',
fromZigbee: [fzlocal.legrand_600087l],
fromZigbee: [fzLegrand.legrand_600087l],
toZigbee: [],
exposes: [e.action(['stop', 'up', 'down'])],
},
Expand Down
112 changes: 112 additions & 0 deletions src/lib/legrand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {Fz, Tz, OnEvent, KeyValueString} from '../lib/types';
import * as exposes from './exposes';
import * as utils from '../lib/utils';
const e = exposes.presets;
const ea = exposes.access;

const legrandOptions = {manufacturerCode: 4129, disableDefaultResponse: true};

const shutterCalibrationModes: {[k: number]: {description: string, onlyNLLV: boolean}} = {
0: {description: 'classic_nllv', onlyNLLV: true},
1: {description: 'specific_nllv', onlyNLLV: true},
2: {description: 'up_down_stop', onlyNLLV: false},
3: {description: 'temporal', onlyNLLV: false},
4: {description: 'venetian_bso', onlyNLLV: false},
};

const getApplicableCalibrationModes = (isNLLVSwitch: boolean): KeyValueString => {
return Object.fromEntries(Object.entries(shutterCalibrationModes)
.filter((e) => isNLLVSwitch ? true : e[1].onlyNLLV === false)
.map((e) => [e[0], e[1].description]));
};

export const readInitialBatteryState: OnEvent = async (type, data, device, options) => {
if (['deviceAnnounce'].includes(type)) {
const endpoint = device.getEndpoint(1);
const options = {manufacturerCode: 0x1021, disableDefaultResponse: true};
await endpoint.read('genPowerCfg', ['batteryVoltage'], options);
}
};

export const tzLegrand = {
auto_mode: {
key: ['auto_mode'],
convertSet: async (entity, key, value, meta) => {
const mode = utils.getFromLookup(value, {'off': 0x00, 'auto': 0x02, 'on_override': 0x03});
const payload = {data: Buffer.from([mode])};
await entity.command('manuSpecificLegrandDevices3', 'command0', payload);
return {state: {'auto_mode': value}};
},
} as Tz.Converter,
calibration_mode: (isNLLVSwitch: boolean) => {
return {
key: ['calibration_mode'],
convertSet: async (entity, key, value, meta) => {
const applicableModes = getApplicableCalibrationModes(isNLLVSwitch);
utils.validateValue(value, Object.values(applicableModes));
const idx = utils.getKey(applicableModes, value);
await entity.write('closuresWindowCovering', {'calibrationMode': idx}, legrandOptions);
},
convertGet: async (entity, key, meta) => {
await entity.read('closuresWindowCovering', ['calibrationMode'], legrandOptions);
},
} as Tz.Converter;
},
};

export const fzLegrand = {
legrand_600087l: {
cluster: 'greenPower',
type: ['commandNotification'],
convert: (model, msg, publish, options, meta) => {
const commandID = msg.data.commandID;
const lookup: {[s: number]: string} = {0x34: 'stop', 0x35: 'up', 0x36: 'down'};
if (commandID === 224) return;
if (!lookup.hasOwnProperty(commandID)) {
meta.logger.error(`GreenPower_3 error: missing command '${commandID}'`);
} else {
return {action: lookup[commandID]};
}
},
} as Fz.Converter,
calibration_mode: (isNLLVSwitch: boolean) => {
return {
cluster: 'closuresWindowCovering',
type: ['attributeReport', 'readResponse'],
convert: (model, msg, publish, options, meta) => {
const attr = 'calibrationMode';
if (msg.data.hasOwnProperty(attr)) {
const applicableModes = getApplicableCalibrationModes(isNLLVSwitch);
const idx = msg.data[attr];
utils.validateValue(String(idx), Object.keys(applicableModes));
const calMode = applicableModes[idx];
return {calibration_mode: calMode};
}
},
} as Fz.Converter;
},
};

export const _067776 = {
getCover: () => {
const c = e.cover_position();
if (c.hasOwnProperty('features')) {
c.features.push(new exposes.Numeric('tilt', ea.ALL)
.withValueMin(0).withValueMax(100)
.withValueStep(25)
.withPreset('Closed', 0, 'Vertical')
.withPreset('25 %', 25, '25%')
.withPreset('50 %', 50, '50%')
.withPreset('75 %', 75, '75%')
.withPreset('Open', 100, 'Horizontal')
.withUnit('%')
.withDescription('Tilt percentage of that cover'));
}
return c;
},
getCalibrationModes: (isNLLVSwitch: boolean) => {
const modes = getApplicableCalibrationModes(isNLLVSwitch);
return e.enum('calibration_mode', ea.ALL, Object.values(modes))
.withDescription('Defines the calibration mode of the switch. (Caution: Changing modes requires a recalibration of the shutter switch!)');
},
};

0 comments on commit a7b5667

Please sign in to comment.