Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"extensions": [
"ms-vscode.cpptools",
"ms-vscode.hexeditor",
"redhat.vscode-yaml"
"redhat.vscode-yaml",
"ms-python.python"
]
}
}
Expand Down
20 changes: 10 additions & 10 deletions device_db.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1245,8 +1245,8 @@ MODULE_GIRIER_TS130F_1GANG:
stock_image_type: 54179
firmware_image_type: 43616
build: yes
status: in_progress
info: Curtains not implemented!
status: fully_supported
info: Supported
threads: https://github.com/romasku/tuya-zigbee-switch/issues/270
store: https://www.aliexpress.com/item/1005003864471089.html
MODULE_GIRIER_TS130F_2GANG:
Expand All @@ -1272,8 +1272,8 @@ MODULE_GIRIER_TS130F_2GANG:
stock_image_type: 54179
firmware_image_type: 45597
build: yes
status: in_progress
info: Curtains in progress! Reset button on B4 pin, same as switch
status: fully_supported
info: Supported - Reset button on B4 pin, same as switch
threads: https://github.com/romasku/tuya-zigbee-switch/issues/270
store: https://www.aliexpress.com/item/1005003864471089.html
MODULE_GIRIER_ZTU_TS0001:
Expand Down Expand Up @@ -2536,8 +2536,8 @@ MODULE_TUYA_NOVATO_TS130F:
device_type: router
stock_model_name: TS130F
stock_manufacturer_name: _TZ3210_ol1uhvza
stock_converter_manufacturer: Tuya
stock_converter_model: TS130F
stock_converter_manufacturer: Lonsonho
stock_converter_model: QS-Zigbee-C03
override_z2m_device: null
tuya_module: ZTLC9
mcu_family: Telink
Expand All @@ -2546,12 +2546,12 @@ MODULE_TUYA_NOVATO_TS130F:
alt_config_str: ol1uhvza;TS130F-NOV;BC3u;LC4;SC2f;RB5;SB4f;RD2;
old_manufacturer_names: null
old_zb_models: null
stock_manufacturer_id: null
stock_image_type: null
stock_manufacturer_id: 4417
stock_image_type: 54179
firmware_image_type: 43609
build: yes
status: in_progress
info: Curtains in progress!
status: fully_supported
info: Supported
threads: https://github.com/romasku/tuya-zigbee-switch/pull/231
store: https://www.aliexpress.com/item/1005007010573203.html
MODULE_TUYA_OXT_A_TS0003:
Expand Down
3 changes: 2 additions & 1 deletion docs/changelog_fw.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Please describe what you are working on, under ## Upcoming
### Features

- **Cover cluster** (window covering) for controlling the motor of curtains, blinds, and shutters.
Supports open, close, and stop commands with motor safety delays.
Supports open, close, stop, and position control commands with configurable opening/closing
travel times and deadzone configuration for precise calibration.
- **Cover switch cluster** for handling user input from window covering switches.
Supports toggle/momentary switches, stop-on-repeat, stop button, local control, and remote device binding.
- Relays now respond to *MoveToLevelWithOnOff*
Expand Down
128 changes: 84 additions & 44 deletions helper_scripts/templates/switch_custom.js.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ const {
text,
binary,
windowCovering,
deviceAddCustomCluster,
} = require("zigbee-herdsman-converters/lib/modernExtend");
const {assertString} = require("zigbee-herdsman-converters/lib/utils");
const reporting = require("zigbee-herdsman-converters/lib/reporting");
Expand Down Expand Up @@ -271,8 +270,8 @@ const romasku = {
name,
endpointName,
lookup: { toggle: 0, momentary: 1 },
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "switchType",
cluster: 0xFC01,
attribute: {ID: 0x0000, type: Zcl.DataType.ENUM8},
description: "Type of cover switch: toggle (rocker) or momentary (push button)",
entityCategory: "config",
}),
Expand All @@ -284,8 +283,8 @@ const romasku = {
['detached', 0],
...Array.from({ length: output_cnt || 2 }, (_, i) => [`cover_${i + 1}`, i + 1])
]),
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "coverIndex",
cluster: 0xFC01,
attribute: {ID: 0x0001, type: Zcl.DataType.UINT8},
description: "Which cover to control locally (detached = no local control)",
entityCategory: "config",
}),
Expand All @@ -295,8 +294,8 @@ const romasku = {
endpointName,
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "reversal",
cluster: 0xFC01,
attribute: {ID: 0x0002, type: Zcl.DataType.BOOLEAN},
description: "Inverts UP/DOWN direction for inputs",
access: "ALL",
entityCategory: "config",
Expand All @@ -306,8 +305,8 @@ const romasku = {
name,
endpointName,
lookup: { immediate: 0, short_press: 1, long_press: 2, hybrid: 3 },
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "localMode",
cluster: 0xFC01,
attribute: {ID: 0x0003, type: Zcl.DataType.ENUM8},
description: "When to trigger local cover: immediate (start/stop on press), short_press (trigger on release), long_press (trigger after long press duration), hybrid (trigger on release or continuous movement while held). Only affects momentary switches",
entityCategory: "config",
}),
Expand All @@ -316,17 +315,17 @@ const romasku = {
name,
endpointName,
lookup: { immediate: 0, short_press: 1, long_press: 2, hybrid: 3 },
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "bindedMode",
cluster: 0xFC01,
attribute: {ID: 0x0004, type: Zcl.DataType.ENUM8},
description: "When to send commands to bound devices: immediate (start/stop on press), short_press (trigger on release), long_press (trigger after long press duration), hybrid (trigger on release or continuous movement while held). Only affects momentary switches",
entityCategory: "config",
}),
coverSwitchLongPressDuration: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "manuSpecificTuyaCoverSwitchConfig",
attribute: "longPressDuration",
cluster: 0xFC01,
attribute: {ID: 0x0005, type: Zcl.DataType.UINT16},
description: "Threshold in milliseconds to distinguish short press from long press",
valueMin: 0,
valueMax: 5000,
Expand All @@ -336,14 +335,14 @@ const romasku = {
enumLookup({
name,
endpointName,
access: "STATE_GET",
access: "STATE",
lookup: {
stopped: 0,
opening: 1,
closing: 2
},
cluster: "closuresWindowCovering",
attribute: "moving",
attribute: {ID: 0xff00, type: Zcl.DataType.ENUM8},
description: "Cover movement status",
entityCategory: "diagnostic",
}),
Expand All @@ -354,10 +353,72 @@ const romasku = {
valueOn: [true, 1],
valueOff: [false, 0],
cluster: "closuresWindowCovering",
attribute: "motorReversal",
attribute: {ID: 0xff01, type: Zcl.DataType.BOOLEAN},
description: "Reverse motor direction (swap OPEN/CLOSE relays)",
entityCategory: "config",
}),
coverOpenTime: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "closuresWindowCovering",
attribute: {ID: 0xff02, type: Zcl.DataType.UINT16},
description: "Travel time for the OPENING direction (0.1 s precision). " +
"For symmetric covers you only need to set this value. " +
"Set both open_time and close_time to 0 to enter manual mode: " +
"position tracking is disabled, the motor runs until stopped or " +
"until a 5-minute safety timeout, and position commands are ignored.",
valueMin: 0,
valueMax: 300,
valueStep: 0.1,
scale: 10,
unit: "s",
entityCategory: "config",
}),
coverCloseTime: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "closuresWindowCovering",
attribute: {ID: 0xff03, type: Zcl.DataType.UINT16},
description: "Travel time for the CLOSING direction (0.1 s precision). " +
"Set to 0 to use the same value as open_time. " +
"Set explicitly only if closing speed differs from opening speed.",
valueMin: 0,
valueMax: 300,
valueStep: 0.1,
scale: 10,
unit: "s",
entityCategory: "config",
}),
coverOpenDeadzone: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "closuresWindowCovering",
attribute: {ID: 0xff04, type: Zcl.DataType.UINT16},
description: "Mechanical deadzone at the OPEN end (100%) as a percentage of " +
"total travel. Motor movement within this zone does not change the " +
"reported position.",
valueMin: 0,
valueMax: 50,
unit: "%",
entityCategory: "config",
}),
coverClosedDeadzone: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "closuresWindowCovering",
attribute: {ID: 0xff05, type: Zcl.DataType.UINT16},
description: "Mechanical deadzone at the CLOSED end (0%) as a percentage of " +
"total travel. Motor movement within this zone does not change the " +
"reported position.",
valueMin: 0,
valueMax: 50,
unit: "%",
entityCategory: "config",
}),
};

const definitions = [
Expand All @@ -376,31 +437,6 @@ const definitions = [
{% if device.has_battery_cluster %}
romasku.batteryPercentage(),
{% endif %}
{% if device.coverSwitchNames %}
deviceAddCustomCluster("manuSpecificTuyaCoverSwitchConfig", {
ID: 0xFC01,
manufacturerCode: 0x125D,
attributes: {
switchType: {ID: 0x0000, type: Zcl.DataType.ENUM8, write: true},
coverIndex: {ID: 0x0001, type: Zcl.DataType.UINT8, write: true},
reversal: {ID: 0x0002, type: Zcl.DataType.BOOLEAN, write: true},
localMode: {ID: 0x0003, type: Zcl.DataType.ENUM8, write: true},
bindedMode: {ID: 0x0004, type: Zcl.DataType.ENUM8, write: true},
longPressDuration: {ID: 0x0005, type: Zcl.DataType.UINT16, write: true},
},
commands: {},
commandsResponse: {},
}),
{% endif %}
{% if device.coverNames %}
deviceAddCustomCluster("closuresWindowCovering", {
ID: 0x0102,
attributes: {
moving: {ID: 0xff00, type: Zcl.DataType.ENUM8},
motorReversal: {ID: 0xff01, type: Zcl.DataType.BOOLEAN, write: true},
},
}),
{% endif %}
deviceEndpoints({ endpoints: {
{%- for switchName in device.switchNames -%}
"{{switchName}}": {{loop.index}},{{" "}}
Expand Down Expand Up @@ -444,11 +480,15 @@ const definitions = [
windowCovering({
controls: ["lift"],
coverInverted: true,
configureReporting: false,
configureReporting: true,
endpointNames: ["{{coverName}}"]
}),
romasku.coverMoving("{{coverName}}_moving", "{{coverName}}"),
romasku.coverMoving("moving", "{{coverName}}"),
romasku.coverMotorReversal("{{coverName}}_motor_reversal", "{{coverName}}"),
romasku.coverOpenTime("{{coverName}}_open_time", "{{coverName}}"),
romasku.coverCloseTime("{{coverName}}_close_time", "{{coverName}}"),
romasku.coverOpenDeadzone("{{coverName}}_open_deadzone", "{{coverName}}"),
romasku.coverClosedDeadzone("{{coverName}}_closed_deadzone", "{{coverName}}"),
{% endfor %}
{% for coverSwitchName in device.coverSwitchNames %}
romasku.coverSwitchPressAction("{{coverSwitchName}}_press_action", "{{coverSwitchName}}"),
Expand Down Expand Up @@ -529,7 +569,7 @@ const definitions = [
await reporting.bind(cover{{loop.index}}, coordinatorEndpoint, ["closuresWindowCovering"]);
await cover{{loop.index}}.configureReporting("closuresWindowCovering", [
{
attribute: "moving",
attribute: {ID: 0xff00, type: Zcl.DataType.ENUM8},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.MAX,
reportableChange: 1,
Expand Down
1 change: 1 addition & 0 deletions src/telink/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ TELINK_SOURCES := \
custom_zcl/zcl_multistate_input.c \
custom_zcl/zcl_onoff_configuration.c \
custom_zcl/zcl_cover_switch_config.c \
custom_zcl/zcl_window_covering_custom.c \
patch_sdk/drv_nv.c

# Common source files (shared with Silicon Labs build)
Expand Down
49 changes: 49 additions & 0 deletions src/telink/custom_zcl/zcl_window_covering_custom.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#pragma pack(push, 1)
#include "zcl_include.h"
#pragma pack(pop)

/*
* Custom Window Covering Cluster Implementation
*
* This custom implementation is required because the SDK's default
* zcl_windowCovering_register() only handles UP_OPEN, DOWN_CLOSE, and STOP
* commands. When a GO_TO_LIFT_PERCENTAGE command is received, the SDK returns
* ZCL_STA_UNSUP_CLUSTER_COMMAND even though the command is defined in the spec.
*
* This custom handler adds support for GO_TO_LIFT_PERCENTAGE by properly parsing
* the single-byte percentage payload and passing it to the application callback.
*/

static status_t zcl_windowCovering_custom_cmdHandler(zclIncoming_t *incoming);

_CODE_ZCL_ status_t zcl_windowCovering_custom_register(u8 endpoint, u16 manuCode, u8 attrNum,
const zclAttrInfo_t attrTbl[],
cluster_forAppCb_t cb) {
return(zcl_registerCluster(endpoint, ZCL_CLUSTER_CLOSURES_WINDOW_COVERING, manuCode, attrNum,
attrTbl, zcl_windowCovering_custom_cmdHandler, cb));
}

_CODE_ZCL_ static status_t zcl_windowCovering_custom_cmdHandler(zclIncoming_t *incoming) {
if (incoming->hdr.frmCtrl.bf.dir != ZCL_FRAME_CLIENT_SERVER_DIR) {
return(ZCL_STA_UNSUP_CLUSTER_COMMAND);
}

void *payload = NULL;
switch (incoming->hdr.cmd) {
case ZCL_CMD_UP_OPEN:
case ZCL_CMD_DOWN_CLOSE:
case ZCL_CMD_STOP:
break;
case ZCL_CMD_GO_TO_LIFT_PERCENTAGE:
payload = (void *)incoming->pData;
break;
default:
return(ZCL_STA_UNSUP_CLUSTER_COMMAND);
}

if (!incoming->clusterAppCb) {
return(ZCL_STA_FAILURE);
}

return(incoming->clusterAppCb(&(incoming->addrInfo), incoming->hdr.cmd, payload));
}
10 changes: 10 additions & 0 deletions src/telink/custom_zcl/zcl_window_covering_custom.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#pragma once

#pragma pack(push, 1)
#include "zcl_include.h"
#pragma pack(pop)

status_t zcl_windowCovering_custom_register(u8 endpoint, u16 manuCode,
u8 attrNum,
const zclAttrInfo_t attrTbl[],
cluster_forAppCb_t cb);
9 changes: 5 additions & 4 deletions src/telink/hal/zigbee_zcl.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "zcl_cover_switch_config.h"
#include "zcl_include.h"
#include "zcl_multistate_input.h"
#include "zcl_window_covering_custom.h"
#include "zcl_onoff_configuration.h"
#pragma pack(pop)

Expand Down Expand Up @@ -54,9 +55,9 @@ static cluster_registerFunc_t get_register_func_by_cluster_id(u16 cluster_id) {
return zcl_multistate_input_register;
}
if (cluster_id == ZCL_CLUSTER_CLOSURES_WINDOW_COVERING) {
return zcl_windowCovering_register;
return zcl_windowCovering_custom_register;
}
if (cluster_id == 0xFC01) { // Cover Switch Config
if (cluster_id == ZCL_CLUSTER_COVER_SWITCH_CONFIG) {
return zcl_cover_switch_config_register;
}
if (cluster_id == ZCL_CLUSTER_GEN_POLL_CONTROL) {
Expand Down Expand Up @@ -106,8 +107,8 @@ static status_t cmd_callback_window_covering(zclIncomingAddrInfo_t *pAddrInfo,
u8 cmdId, void *cmdPayload) {
zclIncoming_t *pInMsg = cmd_incoming_from_addr_info(pAddrInfo);

return cmd_callback(pAddrInfo->dstEp, ZCL_CLUSTER_CLOSURES_WINDOW_COVERING,
cmdId, pInMsg->pData, pInMsg->dataLen);
return cmd_callback(pAddrInfo->dstEp, ZCL_CLUSTER_CLOSURES_WINDOW_COVERING, cmdId,
pInMsg->pData, pInMsg->dataLen);
}

static status_t cmd_callback_level_control(zclIncomingAddrInfo_t *pAddrInfo,
Expand Down
Loading
Loading