From e664735d1576fd4ae7c55c837b80e767585dde0a Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sat, 21 Oct 2023 15:52:42 +0200 Subject: [PATCH 1/7] feat(ignore): Refactor legrand.ts Introduced a Legrand specific library --- src/devices/legrand.ts | 45 ++++-------------------------------------- src/lib/legrand.ts | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 41 deletions(-) create mode 100644 src/lib/legrand.ts diff --git a/src/devices/legrand.ts b/src/devices/legrand.ts index 2aac6a11e81f6..639bb1cc8dea9 100644 --- a/src/devices/legrand.ts +++ b/src/devices/legrand.ts @@ -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} 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', @@ -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), @@ -538,7 +501,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'])], }, diff --git a/src/lib/legrand.ts b/src/lib/legrand.ts new file mode 100644 index 0000000000000..8401daca4c225 --- /dev/null +++ b/src/lib/legrand.ts @@ -0,0 +1,39 @@ +import {Fz, OnEvent, Tz} from '../lib/types'; +import * as utils from '../lib/utils'; + +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, +}; + +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, +}; From aff00c2a7e4c0b57500f5494d202e17b5ab9077a Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sun, 22 Oct 2023 14:57:31 +0200 Subject: [PATCH 2/7] 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 --- src/lib/legrand.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/lib/legrand.ts b/src/lib/legrand.ts index 8401daca4c225..179e46f745d34 100644 --- a/src/lib/legrand.ts +++ b/src/lib/legrand.ts @@ -1,5 +1,22 @@ -import {Fz, OnEvent, Tz} from '../lib/types'; +import {Fz, Tz, OnEvent} from '../lib/types'; +import * as exposes from './exposes'; import * as utils from '../lib/utils'; +const e = exposes.presets; +const ea = exposes.access; + +const shutterCalibrationModes = { + 'Classic (NLLV)': {ID: 0, onlyNLLV: true}, + 'Specific (NLLV)': {ID: 1, onlyNLLV: true}, + 'Up/Down/Stop': {ID: 2, onlyNLLV: false}, + 'Temporal': {ID: 3, onlyNLLV: false}, + 'Venetian (BSO)': {ID: 4, onlyNLLV: false}, +}; + +const getApplicableCalibrationModes = (isNLLVSwitch: boolean) => { + return Object.fromEntries(Object.entries(shutterCalibrationModes) + .filter((e) => isNLLVSwitch ? true : e[1].onlyNLLV === false) + .map((e) => [e[0], e[1].ID])); +}; export const readInitialBatteryState: OnEvent = async (type, data, device, options) => { if (['deviceAnnounce'].includes(type)) { @@ -19,6 +36,20 @@ export const tzLegrand = { 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.keys(applicableModes)); + const idx = applicableModes[value as string]; + await entity.write('closuresWindowCovering', {'tuyaMotorReversal': idx}); + }, + convertGet: async (entity, key, meta) => { + await entity.read('closuresWindowCovering', [0xf002]); + }, + } as Tz.Converter; + }, }; export const fzLegrand = { @@ -36,4 +67,44 @@ export const fzLegrand = { } }, } as Fz.Converter, + calibration_mode: (isNLLVSwitch: boolean) => { + return { + cluster: 'closuresWindowCovering', + type: ['attributeReport', 'readResponse'], + convert: (model, msg, publish, options, meta) => { + const attr = 'tuyaMotorReversal'; + if (msg.data.hasOwnProperty(attr)) { + const idx = msg.data[attr]; + const applicableModes = getApplicableCalibrationModes(isNLLVSwitch); + utils.validateValue(idx, Object.values(applicableModes)); + const calMode = utils.getKey(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.keys(modes)) + .withDescription('Defines the calibration mode of the switch. (Caution: Changing modes requires a recalibration of the shutter switch!)'); + }, }; From e3908fddfe617b37a58ad1ebcccc6f0df4fc7f91 Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sat, 28 Oct 2023 12:04:18 +0200 Subject: [PATCH 3/7] fix(ignore): Switched to Legrand spec. attributes Requires zigbee-herdsman PR#784 --- src/lib/legrand.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/legrand.ts b/src/lib/legrand.ts index 179e46f745d34..7af572ecc707b 100644 --- a/src/lib/legrand.ts +++ b/src/lib/legrand.ts @@ -4,6 +4,8 @@ import * as utils from '../lib/utils'; const e = exposes.presets; const ea = exposes.access; +const legrandOptions = {manufacturerCode: 4129, disableDefaultResponse: true}; + const shutterCalibrationModes = { 'Classic (NLLV)': {ID: 0, onlyNLLV: true}, 'Specific (NLLV)': {ID: 1, onlyNLLV: true}, @@ -43,10 +45,10 @@ export const tzLegrand = { const applicableModes = getApplicableCalibrationModes(isNLLVSwitch); utils.validateValue(value, Object.keys(applicableModes)); const idx = applicableModes[value as string]; - await entity.write('closuresWindowCovering', {'tuyaMotorReversal': idx}); + await entity.write('closuresWindowCovering', {'calibrationMode': idx}, legrandOptions); }, convertGet: async (entity, key, meta) => { - await entity.read('closuresWindowCovering', [0xf002]); + await entity.read('closuresWindowCovering', ['calibrationMode'], legrandOptions); }, } as Tz.Converter; }, @@ -72,7 +74,7 @@ export const fzLegrand = { cluster: 'closuresWindowCovering', type: ['attributeReport', 'readResponse'], convert: (model, msg, publish, options, meta) => { - const attr = 'tuyaMotorReversal'; + const attr = 'calibrationMode'; if (msg.data.hasOwnProperty(attr)) { const idx = msg.data[attr]; const applicableModes = getApplicableCalibrationModes(isNLLVSwitch); From 0481dbf4530a7dd768c018a53ab0e79fb3e9cec4 Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sun, 22 Oct 2023 14:58:11 +0200 Subject: [PATCH 4/7] feat(add): 067776(A) Enabled new features * Support calibration mode switching * Support Venetian (BSO) mode --- src/devices/legrand.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/devices/legrand.ts b/src/devices/legrand.ts index 639bb1cc8dea9..f91608b3cc2cd 100644 --- a/src/devices/legrand.ts +++ b/src/devices/legrand.ts @@ -6,7 +6,7 @@ import tz from '../converters/toZigbee'; import * as reporting from '../lib/reporting'; import extend from '../lib/extend'; import * as ota from '../lib/ota'; -import {tzLegrand, fzLegrand, readInitialBatteryState} from '../lib/legrand'; +import {tzLegrand, fzLegrand, readInitialBatteryState, _067776} from '../lib/legrand'; const e = exposes.presets; const ea = exposes.access; @@ -133,20 +133,23 @@ 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); + await reporting.currentPositionLiftPercentage(endpoint, {max: 120}); + await reporting.currentPositionTiltPercentage(endpoint, {max: 120}); }, }, { @@ -183,20 +186,23 @@ 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); + await reporting.currentPositionLiftPercentage(endpoint, {max: 120}); + await reporting.currentPositionTiltPercentage(endpoint, {max: 120}); }, }, { From 0f2f79deb19b4f2a2e0bf661a105f7db6d75fb3f Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sat, 28 Oct 2023 12:25:37 +0200 Subject: [PATCH 5/7] fix(ignore): Updated Legrand 067776(A) reporting Added manufacturer code to reporting --- src/devices/legrand.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/devices/legrand.ts b/src/devices/legrand.ts index f91608b3cc2cd..4ae2947545c3d 100644 --- a/src/devices/legrand.ts +++ b/src/devices/legrand.ts @@ -148,8 +148,11 @@ const definitions: Definition[] = [ configure: async (device, coordinatorEndpoint, logger) => { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ['genBinaryInput', 'closuresWindowCovering', 'genIdentify']); - await reporting.currentPositionLiftPercentage(endpoint, {max: 120}); - await reporting.currentPositionTiltPercentage(endpoint, {max: 120}); + 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}); }, }, { @@ -201,8 +204,11 @@ const definitions: Definition[] = [ configure: async (device, coordinatorEndpoint, logger) => { const endpoint = device.getEndpoint(1); await reporting.bind(endpoint, coordinatorEndpoint, ['genBinaryInput', 'closuresWindowCovering', 'genIdentify']); - await reporting.currentPositionLiftPercentage(endpoint, {max: 120}); - await reporting.currentPositionTiltPercentage(endpoint, {max: 120}); + 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}); }, }, { From 1b61400f2f1b6e2cd906492e3585dcf9d00ac479 Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sun, 29 Oct 2023 18:03:27 +0100 Subject: [PATCH 6/7] fix(refactor): Refactored shutterCalibrationModes Avoid the usage of string litterals as keys --- src/lib/legrand.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/lib/legrand.ts b/src/lib/legrand.ts index 7af572ecc707b..e2e52689b137d 100644 --- a/src/lib/legrand.ts +++ b/src/lib/legrand.ts @@ -1,4 +1,4 @@ -import {Fz, Tz, OnEvent} from '../lib/types'; +import {Fz, Tz, OnEvent, KeyValueString} from '../lib/types'; import * as exposes from './exposes'; import * as utils from '../lib/utils'; const e = exposes.presets; @@ -6,18 +6,18 @@ const ea = exposes.access; const legrandOptions = {manufacturerCode: 4129, disableDefaultResponse: true}; -const shutterCalibrationModes = { - 'Classic (NLLV)': {ID: 0, onlyNLLV: true}, - 'Specific (NLLV)': {ID: 1, onlyNLLV: true}, - 'Up/Down/Stop': {ID: 2, onlyNLLV: false}, - 'Temporal': {ID: 3, onlyNLLV: false}, - 'Venetian (BSO)': {ID: 4, onlyNLLV: false}, +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) => { +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].ID])); + .map((e) => [e[0], e[1].description])); }; export const readInitialBatteryState: OnEvent = async (type, data, device, options) => { @@ -43,8 +43,8 @@ export const tzLegrand = { key: ['calibration_mode'], convertSet: async (entity, key, value, meta) => { const applicableModes = getApplicableCalibrationModes(isNLLVSwitch); - utils.validateValue(value, Object.keys(applicableModes)); - const idx = applicableModes[value as string]; + utils.validateValue(value, Object.values(applicableModes)); + const idx = utils.getKey(applicableModes, value); await entity.write('closuresWindowCovering', {'calibrationMode': idx}, legrandOptions); }, convertGet: async (entity, key, meta) => { @@ -76,10 +76,10 @@ export const fzLegrand = { convert: (model, msg, publish, options, meta) => { const attr = 'calibrationMode'; if (msg.data.hasOwnProperty(attr)) { - const idx = msg.data[attr]; const applicableModes = getApplicableCalibrationModes(isNLLVSwitch); - utils.validateValue(idx, Object.values(applicableModes)); - const calMode = utils.getKey(applicableModes, idx); + const idx = msg.data[attr]; + utils.validateValue(String(idx), Object.keys(applicableModes)); + const calMode = applicableModes[idx]; return {calibration_mode: calMode}; } }, @@ -106,7 +106,7 @@ export const _067776 = { }, getCalibrationModes: (isNLLVSwitch: boolean) => { const modes = getApplicableCalibrationModes(isNLLVSwitch); - return e.enum('calibration_mode', ea.ALL, Object.keys(modes)) + 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!)'); }, }; From e293c040173e22f9a0291d26b0d126c60ffb9057 Mon Sep 17 00:00:00 2001 From: Fabian Mangold Date: Sun, 29 Oct 2023 19:53:56 +0100 Subject: [PATCH 7/7] fix(ignore): Refactor shutterCalibrationModes Changed description to snake_case --- src/lib/legrand.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/legrand.ts b/src/lib/legrand.ts index e2e52689b137d..ae1be516d9d8f 100644 --- a/src/lib/legrand.ts +++ b/src/lib/legrand.ts @@ -7,11 +7,11 @@ 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}, + 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 => {