diff --git a/device_db.yaml b/device_db.yaml
index 8149e6ac60..4ad4a13617 100644
--- a/device_db.yaml
+++ b/device_db.yaml
@@ -1562,6 +1562,32 @@ MODULE_IHSENO_B_TS0001:
info: Supported
threads: https://github.com/romasku/tuya-zigbee-switch/pull/386
store: null
+MODULE_LORATAP_TS130F_1GANG:
+ human_name: LoraTap 1-gang curtains
+ category: module
+ power: mains
+ neutral: required
+ output: relay
+ device_type: router
+ stock_model_name: SC500ZB-v4
+ stock_manufacturer_name: _TZ3000_5iixzdo7
+ stock_converter_manufacturer: Tuya
+ stock_converter_model: SC500ZB-v4
+ override_z2m_device: SC500ZB-v4
+ tuya_module: ZT2S_real
+ mcu_family: Telink
+ mcu: TLSR8258
+ config_str: 5iixzdo7;SC500ZB-v4;BD2u;LB7i;XC3C2f;CB5B4;
+ old_manufacturer_names: null
+ old_zb_models: null
+ stock_manufacturer_id: 4417
+ stock_image_type: 54179
+ firmware_image_type: 43616
+ build: yes
+ status: in_progress
+ info: Curtains not implemented!
+ threads: https://github.com/romasku/tuya-zigbee-switch/issues/270
+ store: https://www.aliexpress.com/item/1005003864471089.html
MODULE_LVGESS_PM_TSOOO1:
human_name: LVGESS 1-gang PM
category: module
diff --git a/docs/contribute/porting.md b/docs/contribute/porting.md
index 7a29df5daa..b8e212126b 100644
--- a/docs/contribute/porting.md
+++ b/docs/contribute/porting.md
@@ -90,9 +90,9 @@ The pinout is stored in the **device config string**.
| **`B`** | Reset button | • Puts device in pairing |
| **`L`** | Network led | • Blinks while pairing
• Is the backlight sometimes |
| **`S`** | Switch | • User input
• Tactile/touch button or external switch
• Spam to put in pairing mode |
-| **`R`** | Relay / Triac | • Output
• Non-latching: `RC1` - 1 pin: on when high
• Latching: `RC2C3` - 2 pins: pulse on, pulse off |
+| **`R`** | Relay / Triac | • Output
• Non-latching: `RC1` - 1 pin: on when high
• Latching: `RC2C3` - 2 pins: pulse on, pulse off
• Add `i` to invert (active-low): `RC1i` |
| **`X`** | Cover Switch | • User input for cover control
• Format: `XA2B3u` - 2 pins + pull resistor: open button, close button |
-| **`C`** | Cover | • Motor control for curtains/blinds/shades
• Format: `CA2B3` - 2 pins: open relay, close relay |
+| **`C`** | Cover | • Motor control for curtains/blinds/shades
• Format: `CA2B3` - 2 pins: open relay, close relay
• Add `i` to invert both relays (active-low): `CA2B3i` |
| **`I`** | Indicator LED | • 1 per relay, follows state
• Briefly flashes on button press (binding confirmation)
• Blinks while pairing if there is no network led |
For buttons (`B`), switches (`S`), and cover switches (`X`), the next character chooses the internal pull-up/down resistor:
@@ -101,7 +101,7 @@ For buttons (`B`), switches (`S`), and cover switches (`X`), the next character
Usually, pressing the button bridges the GPIO pin to Ground (active low).
⤷ So we need a pull-up resistor `u`: to hold it at VCC (high) while not-pressed.
-For LEDs, add `i` to invert the state.
+For LEDs (`L`, `I`), relays (`R`), and cover relays (`C`), add `i` to invert the output (active-low).
Additional options:
| Format | Option | Function |
diff --git a/docs/updating.md b/docs/updating.md
index 48a84ad9a4..cda33edec0 100644
--- a/docs/updating.md
+++ b/docs/updating.md
@@ -57,11 +57,11 @@ This page describes **converting** and **updating** supported devices **wireless
8. **Start** the update (red download button)
9. **Re-download** the custom [# Quirks / Converters / Extensions](#quirks--converters--extensions) and restart ZHA / Z2M
10. **Interview** the device **`i`**
-⤷ option missing from ZHA, remove and re-pair if needed
-(updates endpoints, clusters and identifiers)
-11. **Reconfigure** the device **`🗘`**
-(resets reporting and stuff?, keeps user binds and settings)
-12. Re-do user settings if needed
+ ⤷ option missing from ZHA, remove and re-pair if needed
+ (updates endpoints, clusters and identifiers)
+11. **Reconfigure** the device **`🗘`**
+ (resets reporting intervals, keeps user binds and settings)
+12. Re-do user settings if needed
> *If your device is several versions behind, it will update directly to the latest version.*
@@ -120,7 +120,7 @@ zha:
> - e.g. 10W dumb bulb is safe
> - estimated values: >4W for EndDevice, >8W for Router
> - **not recommended: no-Neutral switch + smart bulb ( <1W when brightness=0 )**
-> ⤷ dummy load (capactior) may be required
+> ⤷ dummy load (capacitor) may be required
Index link format
diff --git a/helper_scripts/templates/switch_custom.js.jinja b/helper_scripts/templates/switch_custom.js.jinja
index a70452ca03..f2e3b40138 100644
--- a/helper_scripts/templates/switch_custom.js.jinja
+++ b/helper_scripts/templates/switch_custom.js.jinja
@@ -34,7 +34,7 @@ const romasku = {
endpointName,
lookup: { on_off: 0, off_on: 1, toggle_simple: 2, toggle_smart_sync: 3, toggle_smart_opposite: 4 },
cluster: "genOnOffSwitchCfg",
- attribute: {ID: 0x0010, type: 0x30, required: true, write: true, min: 0, max: 4}, // Enum8
+ attribute: {ID: 0x0010, type: Zcl.DataType.ENUM8, required: true, write: true, min: 0, max: 4},
description: `Select how switch should work:
- on_off: When switch physically moved to position 1 it always generates ON command, and when moved to position 2 it generates OFF command
- off_on: Same as on_off, but positions are swapped
@@ -49,7 +49,7 @@ const romasku = {
endpointName,
lookup: { toggle: 0, momentary: 1, momentary_nc: 2 },
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff00, type: 0x30 }, // Enum8
+ attribute: { ID: 0xff00, type: Zcl.DataType.ENUM8 },
description: "Select the type of switch connected to the device",
entityCategory: "config",
}),
@@ -59,7 +59,7 @@ const romasku = {
endpointName,
lookup: { detached: 0, press_start: 1, short_press: 3, long_press: 2},
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff01, type: 0x30 }, // Enum8
+ attribute: { ID: 0xff01, type: Zcl.DataType.ENUM8 },
description: "When to turn on/off internal relay",
entityCategory: "config",
}),
@@ -71,7 +71,7 @@ const romasku = {
Array.from({ length: relay_cnt || 2 }, (_, i) => [`relay_${i + 1}`, i + 1])
),
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff02, type: 0x20 }, // uint8
+ attribute: { ID: 0xff02, type: Zcl.DataType.UINT8 },
description: "Which internal relay it should trigger",
entityCategory: "config",
}),
@@ -81,7 +81,7 @@ const romasku = {
endpointName,
lookup: { press_start: 1, short_press: 3, long_press: 2},
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff05, type: 0x30 }, // Enum8
+ attribute: { ID: 0xff05, type: Zcl.DataType.ENUM8 },
description: "When turn on/off binded device",
entityCategory: "config",
}),
@@ -90,7 +90,7 @@ const romasku = {
name,
endpointNames: [endpointName],
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff03, type: 0x21 }, // uint16
+ attribute: { ID: 0xff03, type: Zcl.DataType.UINT16 },
description: "What duration is considerd to be long press",
valueMin: 0,
valueMax: 5000,
@@ -101,7 +101,7 @@ const romasku = {
name,
endpointNames: [endpointName],
cluster: "genOnOffSwitchCfg",
- attribute: { ID: 0xff04, type: 0x20 }, // uint8
+ attribute: { ID: 0xff04, type: Zcl.DataType.UINT8 },
description: "Level (dim) move rate in steps per ms",
valueMin: 1,
valueMax: 255,
@@ -124,7 +124,7 @@ const romasku = {
endpointName,
lookup: { same: 0, opposite: 1, manual: 2 },
cluster: "genOnOff",
- attribute: { ID: 0xff01, type: 0x30 }, // Enum8
+ attribute: { ID: 0xff01, type: Zcl.DataType.ENUM8 },
description: "Mode for the relay indicator LED",
entityCategory: "config",
}),
@@ -135,7 +135,7 @@ const romasku = {
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "genOnOff",
- attribute: {ID: 0xff02, type: 0x10}, // Boolean
+ attribute: {ID: 0xff02, type: Zcl.DataType.BOOLEAN},
description: "State of the relay indicator LED",
access: "ALL",
entityCategory: "config",
@@ -170,17 +170,18 @@ const romasku = {
valueOn: ["ON", 1],
valueOff: ["OFF", 0],
cluster: "genBasic",
- attribute: {ID: 0xff01, type: 0x10}, // Boolean
+ attribute: {ID: 0xff01, type: Zcl.DataType.BOOLEAN},
description: "State of the network indicator LED",
access: "ALL",
entityCategory: "config",
}),
+
multiPressResetCount: (name, endpointName) =>
numeric({
name,
endpointNames: [endpointName],
cluster: "genBasic",
- attribute: { ID: 0xff02, type: 0x20 }, // uint8
+ attribute: { ID: 0xff02, type: Zcl.DataType.UINT8 },
description: "Number of consecutive presses to trigger factory reset (0 = disabled)",
valueMin: 0,
valueMax: 255,
@@ -192,7 +193,7 @@ const romasku = {
endpointName,
access: "ALL",
cluster: "genBasic",
- attribute: { ID: 0xff00, type: 0x44 }, // long str
+ attribute: { ID: 0xff00, type: Zcl.DataType.LONG_CHAR_STR },
description: "Current configuration of the device",
zigbeeCommandOptions: {timeout: 30_000},
validate: (value) => {
@@ -343,7 +344,7 @@ const romasku = {
closing: 2
},
cluster: "closuresWindowCovering",
- attribute: "moving",
+ attribute: { ID: 0xff00, type: Zcl.DataType.ENUM8 },
description: "Cover movement status",
entityCategory: "diagnostic",
}),
@@ -354,10 +355,34 @@ 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,
+ endpointName,
+ cluster: "closuresWindowCovering",
+ attribute: { ID: 0xff02, type: Zcl.DataType.UINT16 },
+ description: "Full open (up) travel time in milliseconds. Set to 0 to disable position tracking",
+ valueMin: 0,
+ valueMax: 60000,
+ unit: "ms",
+ entityCategory: "config",
+ }),
+ coverCloseTime: (name, endpointName) =>
+ numeric({
+ name,
+ endpointName,
+ cluster: "closuresWindowCovering",
+ attribute: { ID: 0xff03, type: Zcl.DataType.UINT16 },
+ description: "Full close (down) travel time in milliseconds. Set to 0 to disable position tracking",
+ valueMin: 0,
+ valueMax: 60000,
+ unit: "ms",
+ entityCategory: "config",
+ }),
};
const definitions = [
@@ -392,15 +417,6 @@ const definitions = [
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}},{{" "}}
@@ -441,12 +457,14 @@ const definitions = [
romasku.relayIndicator("{{relayName}}_indicator", "{{relayName}}"),
{% endfor %}
{% for coverName in device.coverNames %}
- windowCovering({
+ windowCovering({
controls: ["lift"],
coverInverted: true,
configureReporting: false,
endpointNames: ["{{coverName}}"]
}),
+ romasku.coverOpenTime("{{coverName}}_open_time", "{{coverName}}"),
+ romasku.coverCloseTime("{{coverName}}_close_time", "{{coverName}}"),
romasku.coverMoving("{{coverName}}_moving", "{{coverName}}"),
romasku.coverMotorReversal("{{coverName}}_motor_reversal", "{{coverName}}"),
{% endfor %}
@@ -470,7 +488,7 @@ const definitions = [
// switch action:
await endpoint{{loop.index}}.configureReporting("genMultistateInput", [
{
- attribute: {ID: 0x0055 /* presentValue */, type: 0x21}, // uint16
+ attribute: {ID: 0x0055 /* presentValue */, type: Zcl.DataType.UINT16},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.MAX,
reportableChange: 1,
@@ -483,7 +501,7 @@ const definitions = [
await reporting.bind(batteryEndpoint, coordinatorEndpoint, ["genPowerCfg"]);
await batteryEndpoint.configureReporting("genPowerCfg", [
{
- attribute: {ID: 0x0021, type: 0x20}, // BatteryPercentageRemaining
+ attribute: {ID: 0x0021, type: Zcl.DataType.UINT8}, // BatteryPercentageRemaining
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.HOUR,
reportableChange: 2, // 1% (2 in ZCL 0-200 format)
@@ -503,7 +521,7 @@ const definitions = [
{% for relayName in device.relayIndicatorNames %}
await endpoint{{loop.index + (device.switchNames | length)}}.configureReporting("genOnOff", [
{
- attribute: {ID: 0xff02, type: 0x10}, // Boolean
+ attribute: {ID: 0xff02, type: Zcl.DataType.BOOLEAN},
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.MAX,
reportableChange: 1,
@@ -529,7 +547,13 @@ 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}, // moving
+ minimumReportInterval: 0,
+ maximumReportInterval: constants.repInterval.MAX,
+ reportableChange: 1,
+ },
+ {
+ attribute: {ID: 0x0008, type: Zcl.DataType.UINT8}, // currentPositionLiftPercentage
minimumReportInterval: 0,
maximumReportInterval: constants.repInterval.MAX,
reportableChange: 1,
diff --git a/src/device_config/config_parser.c b/src/device_config/config_parser.c
index 16007aac7e..29d3b7a539 100644
--- a/src/device_config/config_parser.c
+++ b/src/device_config/config_parser.c
@@ -133,12 +133,14 @@ void parse_config() {
hal_gpio_pin_t pin = hal_gpio_parse_pin(entry + 1);
hal_gpio_pull_t pull = hal_gpio_parse_pull(entry + 3);
hal_gpio_init(pin, 1, pull);
+ bool pressed_when_high = (pull == HAL_GPIO_PULL_DOWN) ? 1 : 0;
buttons[buttons_cnt].pin = pin;
buttons[buttons_cnt].long_press_duration_ms = 2000;
buttons[buttons_cnt].multi_press_duration_ms = 800;
buttons[buttons_cnt].debounce_delay_ms = debounce_ms;
buttons[buttons_cnt].on_long_press = on_reset_clicked;
+ buttons[buttons_cnt].pressed_when_high = pressed_when_high;
buttons_cnt++;
} else if (entry[0] == 'L') {
hal_gpio_pin_t pin = hal_gpio_parse_pin(entry + 1);
@@ -188,15 +190,14 @@ void parse_config() {
hal_gpio_pin_t pin = hal_gpio_parse_pin(entry + 1);
hal_gpio_pull_t pull = hal_gpio_parse_pull(entry + 3);
hal_gpio_init(pin, 1, pull);
+ bool pressed_when_high = (pull == HAL_GPIO_PULL_DOWN) ? 1 : 0;
buttons[buttons_cnt].pin = pin;
- buttons[buttons_cnt].long_press_duration_ms = 800;
- buttons[buttons_cnt].multi_press_duration_ms = 800;
- buttons[buttons_cnt].debounce_delay_ms = debounce_ms;
- buttons[buttons_cnt].on_multi_press = on_multi_press_reset;
-
- if (entry[3] == 'd')
- buttons[buttons_cnt].pressed_when_high = 1;
+ buttons[buttons_cnt].long_press_duration_ms = 800;
+ buttons[buttons_cnt].multi_press_duration_ms = 800;
+ buttons[buttons_cnt].debounce_delay_ms = debounce_ms;
+ buttons[buttons_cnt].on_multi_press = on_multi_press_reset;
+ buttons[buttons_cnt].pressed_when_high = pressed_when_high;
switch_clusters[switch_clusters_cnt].switch_idx = switch_clusters_cnt;
switch_clusters[switch_clusters_cnt].mode =
ZCL_ONOFF_CONFIGURATION_SWITCH_TYPE_TOGGLE;
@@ -212,11 +213,12 @@ void parse_config() {
buttons_cnt++;
switch_clusters_cnt++;
} else if (entry[0] == 'R') {
- hal_gpio_pin_t pin = hal_gpio_parse_pin(entry + 1);
+ hal_gpio_pin_t pin = hal_gpio_parse_pin(entry + 1);
+ bool on_high = entry[3] != 'i';
hal_gpio_init(pin, 0, HAL_GPIO_PULL_NONE);
relays[relays_cnt].pin = pin;
- relays[relays_cnt].on_high = 1;
+ relays[relays_cnt].on_high = on_high;
if (entry[3] != '\0') {
pin = hal_gpio_parse_pin(entry + 3);
@@ -231,9 +233,10 @@ void parse_config() {
relays_cnt++;
relay_clusters_cnt++;
} else if (entry[0] == 'X') {
- hal_gpio_pin_t open_pin = hal_gpio_parse_pin(entry + 1);
- hal_gpio_pin_t close_pin = hal_gpio_parse_pin(entry + 3);
- hal_gpio_pull_t pull = hal_gpio_parse_pull(entry + 5);
+ hal_gpio_pin_t open_pin = hal_gpio_parse_pin(entry + 1);
+ hal_gpio_pin_t close_pin = hal_gpio_parse_pin(entry + 3);
+ hal_gpio_pull_t pull = hal_gpio_parse_pull(entry + 5);
+ bool pressed_when_high = (pull == HAL_GPIO_PULL_DOWN) ? 1 : 0;
hal_gpio_init(open_pin, 1, pull);
hal_gpio_init(close_pin, 1, pull);
@@ -243,6 +246,7 @@ void parse_config() {
buttons[buttons_cnt].multi_press_duration_ms = 800;
buttons[buttons_cnt].debounce_delay_ms = debounce_ms;
buttons[buttons_cnt].on_multi_press = on_multi_press_reset;
+ buttons[buttons_cnt].pressed_when_high = pressed_when_high;
button_t *open_button = &buttons[buttons_cnt++];
buttons[buttons_cnt].pin = close_pin;
@@ -250,6 +254,7 @@ void parse_config() {
buttons[buttons_cnt].multi_press_duration_ms = 800;
buttons[buttons_cnt].debounce_delay_ms = debounce_ms;
buttons[buttons_cnt].on_multi_press = on_multi_press_reset;
+ buttons[buttons_cnt].pressed_when_high = pressed_when_high;
button_t *close_button = &buttons[buttons_cnt++];
cover_switch_clusters[cover_switch_clusters_cnt].open_button =
@@ -262,17 +267,18 @@ void parse_config() {
} else if (entry[0] == 'C') {
hal_gpio_pin_t open_pin = hal_gpio_parse_pin(entry + 1);
hal_gpio_pin_t close_pin = hal_gpio_parse_pin(entry + 3);
+ bool on_high = entry[5] != 'i';
hal_gpio_init(open_pin, 0, HAL_GPIO_PULL_NONE);
hal_gpio_init(close_pin, 0, HAL_GPIO_PULL_NONE);
relays[relays_cnt].pin = open_pin;
- relays[relays_cnt].on_high = 1;
+ relays[relays_cnt].on_high = on_high;
relays[relays_cnt].is_latching = 0;
relay_t *open_relay = &relays[relays_cnt++];
relays[relays_cnt].pin = close_pin;
- relays[relays_cnt].on_high = 1;
+ relays[relays_cnt].on_high = on_high;
relays[relays_cnt].is_latching = 0;
relay_t *close_relay = &relays[relays_cnt++];
diff --git a/src/telink/hal/zigbee_zcl.c b/src/telink/hal/zigbee_zcl.c
index d0e6850ec7..1f2e2ac215 100644
--- a/src/telink/hal/zigbee_zcl.c
+++ b/src/telink/hal/zigbee_zcl.c
@@ -9,6 +9,7 @@
#include "telink_size_t_hack.h"
+#include "hal/printf_selector.h"
#include "hal/zigbee.h"
#include "telink_zigbee_hal.h"
#include "zigbee/battery_cluster.h"
@@ -56,7 +57,7 @@ static cluster_registerFunc_t get_register_func_by_cluster_id(u16 cluster_id) {
if (cluster_id == ZCL_CLUSTER_CLOSURES_WINDOW_COVERING) {
return zcl_windowCovering_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) {
@@ -231,6 +232,17 @@ void telink_zigbee_hal_zcl_init(hal_zigbee_endpoint *endpoints,
u8 cluster_count = cluster_info_ptr - endpoint_first_cluster_ptr;
zcl_register(endpoint->endpoint, cluster_count, endpoint_first_cluster_ptr);
+ printf("[zcl] ep=%d profile=0x%04x dev=0x%04x in_cnt=%d out_cnt=%d\r\n",
+ endpoint_desc_ptr->endpoint, endpoint_desc_ptr->app_profile_id,
+ endpoint_desc_ptr->app_dev_id, endpoint_desc_ptr->app_in_cluster_count,
+ endpoint_desc_ptr->app_out_cluster_count);
+ for (u8 i = 0; i < endpoint_desc_ptr->app_in_cluster_count; i++) {
+ printf("[zcl] in[%d]=0x%04x\r\n", i, endpoint_desc_ptr->app_in_cluster_lst[i]);
+ }
+ for (u8 i = 0; i < endpoint_desc_ptr->app_out_cluster_count; i++) {
+ printf("[zcl] out[%d]=0x%04x\r\n", i, endpoint_desc_ptr->app_out_cluster_lst[i]);
+ }
+
endpoint_desc_ptr++;
}
}
diff --git a/src/zigbee/consts.h b/src/zigbee/consts.h
index 14f117c30e..72c2c9dd18 100644
--- a/src/zigbee/consts.h
+++ b/src/zigbee/consts.h
@@ -93,6 +93,8 @@
#define ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE 0x0008
#define ZCL_ATTR_WINDOW_COVERING_MOVING 0xff00
#define ZCL_ATTR_WINDOW_COVERING_MOTOR_REVERSAL 0xff01
+#define ZCL_ATTR_WINDOW_COVERING_OPEN_TIME 0xff02
+#define ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME 0xff03
// Cover Switch Configuration cluster
#define ZCL_ATTR_COVER_SWITCH_CONFIG_SWITCH_TYPE 0x0000
@@ -205,9 +207,10 @@
// WindowCovering Cluster
-#define ZCL_CMD_WINDOW_COVERING_UP_OPEN 0x00
-#define ZCL_CMD_WINDOW_COVERING_DOWN_CLOSE 0x01
-#define ZCL_CMD_WINDOW_COVERING_STOP 0x02
+#define ZCL_CMD_WINDOW_COVERING_UP_OPEN 0x00
+#define ZCL_CMD_WINDOW_COVERING_DOWN_CLOSE 0x01
+#define ZCL_CMD_WINDOW_COVERING_STOP 0x02
+#define ZCL_CMD_WINDOW_COVERING_GO_TO_LIFT_PERCENTAGE 0x05
// OTA Cluster
diff --git a/src/zigbee/cover_cluster.c b/src/zigbee/cover_cluster.c
index 30ed262e62..88a633e30f 100644
--- a/src/zigbee/cover_cluster.c
+++ b/src/zigbee/cover_cluster.c
@@ -25,9 +25,57 @@ static zigbee_cover_cluster_config nv_config_buffer;
// Value 0 = Rollershade (liftable cover, not tiltable).
static uint8_t window_covering_type = 0;
-// Current lift position percentage - required by ZCL spec for liftable covers.
-// Hardcoded to 50 until position calculation/control is implemented.
-static uint8_t cover_position = 50;
+// ============================================================================
+// Position Tracking Helpers
+// ============================================================================
+
+static int cover_has_position_tracking(zigbee_cover_cluster *cluster) {
+ return cluster->open_time_ms > 0 && cluster->close_time_ms > 0;
+}
+
+static uint8_t cover_get_current_position(zigbee_cover_cluster *cluster) {
+ if (!cover_has_position_tracking(cluster)) {
+ return cluster->cover_position;
+ }
+ if (cluster->moving == ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED) {
+ return cluster->cover_position;
+ }
+
+ uint32_t elapsed = hal_millis() - cluster->movement_start_ms;
+ int32_t delta;
+ int32_t pos;
+
+ if (cluster->moving == ZCL_ATTR_WINDOW_COVERING_MOVING_OPENING) {
+ delta = (int32_t)elapsed * 100 / cluster->open_time_ms;
+ pos = (int32_t)cluster->movement_start_position - delta;
+ } else {
+ delta = (int32_t)elapsed * 100 / cluster->close_time_ms;
+ pos = (int32_t)cluster->movement_start_position + delta;
+ }
+
+ if (pos < 0) {
+ return 0;
+ } else if (pos > 100) {
+ return 100;
+ } else {
+ return (uint8_t)pos;
+ }
+}
+
+static void cover_update_position(zigbee_cover_cluster *cluster) {
+ if (!cover_has_position_tracking(cluster)) {
+ return;
+ }
+
+ // Prefer exact target when a scheduled stop fires; fall back to time interpolation for manual stops.
+ cluster->cover_position = (cluster->target_position != 0xFF)
+ ? cluster->target_position
+ : cover_get_current_position(cluster);
+
+ hal_zigbee_notify_attribute_changed(cluster->endpoint,
+ ZCL_CLUSTER_WINDOW_COVERING,
+ ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE);
+}
// ============================================================================
// Movement Control
@@ -41,10 +89,19 @@ static uint8_t cover_position = 50;
* after verifying timing constraints are satisfied.
*/
void cover_apply_movement(zigbee_cover_cluster *cluster, uint8_t moving) {
+ uint8_t old_moving = cluster->moving;
relay_t *open_relay = cluster->motor_reversal ? cluster->close_relay : cluster->open_relay;
relay_t *close_relay = cluster->motor_reversal ? cluster->open_relay : cluster->close_relay;
- cluster->last_switch_time = hal_millis();
+ // Position tracking: update stored position when movement stops
+ // (must be called BEFORE cluster->moving is changed so cover_get_current_position works)
+ if (moving == ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED &&
+ old_moving != ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED) {
+ cover_update_position(cluster);
+ }
+
+ uint32_t now = hal_millis();
+ cluster->last_switch_time = now;
if (moving == ZCL_ATTR_WINDOW_COVERING_MOVING_OPENING) {
relay_on(open_relay);
relay_off(close_relay);
@@ -59,6 +116,13 @@ void cover_apply_movement(zigbee_cover_cluster *cluster, uint8_t moving) {
cluster->moving = ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED;
}
+ // Position tracking: capture start position and time when movement begins
+ if (old_moving == ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED &&
+ moving != ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED) {
+ cluster->movement_start_ms = now;
+ cluster->movement_start_position = cluster->cover_position;
+ }
+
hal_zigbee_notify_attribute_changed(cluster->endpoint,
ZCL_CLUSTER_WINDOW_COVERING,
ZCL_ATTR_WINDOW_COVERING_MOVING);
@@ -109,15 +173,23 @@ void cover_request_movement(zigbee_cover_cluster *cluster, uint8_t moving) {
}
}
+static void cover_cancel_target(zigbee_cover_cluster *cluster) {
+ hal_tasks_unschedule(&cluster->position_task);
+ cluster->target_position = 0xFF;
+}
+
void cover_open(zigbee_cover_cluster *cluster) {
+ cover_cancel_target(cluster);
cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_OPENING);
}
void cover_close(zigbee_cover_cluster *cluster) {
+ cover_cancel_target(cluster);
cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_CLOSING);
}
void cover_stop(zigbee_cover_cluster *cluster) {
+ cover_cancel_target(cluster);
cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED);
}
@@ -127,12 +199,69 @@ void cover_delay_handler(void *arg) {
cover_request_movement(cluster, cluster->pending_movement);
}
+// Does not call cover_stop() to avoid unnecessary target cancellation.
+void cover_position_task_handler(void *arg) {
+ zigbee_cover_cluster *cluster = (zigbee_cover_cluster *)arg;
+
+ // Leave target_position set so cover_update_position uses the exact target
+ cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED);
+ cluster->target_position = 0xFF;
+}
+
+void cover_go_to_lift_percentage(zigbee_cover_cluster *cluster, uint8_t target) {
+ printf("[cover] go_to_lift target=%d open=%d close=%d pos=%d moving=%d\r\n",
+ target, cluster->open_time_ms, cluster->close_time_ms,
+ cluster->cover_position, cluster->moving);
+
+ if (!cover_has_position_tracking(cluster)) {
+ printf("[cover] go_to_lift: tracking disabled, ignoring\r\n");
+ return;
+ }
+
+ // Clamp target to valid range
+ if (target > 100) {
+ target = 100;
+ }
+
+ uint8_t current = cover_get_current_position(cluster);
+
+ // Cancel any previous GoToLiftPercentage timer
+ hal_tasks_unschedule(&cluster->position_task);
+
+ // If already at target, just stop and return
+ if (target == current) {
+ printf("[cover] go_to_lift: already at target=%d, stopping\r\n", target);
+ cover_cancel_target(cluster);
+ cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED);
+ return;
+ }
+
+ cluster->target_position = target;
+
+ uint8_t dir = (target < current) ? ZCL_ATTR_WINDOW_COVERING_MOVING_OPENING
+ : ZCL_ATTR_WINDOW_COVERING_MOVING_CLOSING;
+ uint16_t travel_time = (target < current) ? cluster->open_time_ms : cluster->close_time_ms;
+ uint8_t distance = (target <
+ current) ? (uint8_t)(current - target) : (uint8_t)(target - current);
+ uint32_t run_ms = (uint32_t)distance * travel_time / 100;
+ printf("[cover] go_to_lift: %s current=%d -> target=%d run_ms=%d\r\n",
+ (dir == ZCL_ATTR_WINDOW_COVERING_MOVING_OPENING) ? "OPEN" : "CLOSE",
+ current, target, (unsigned)run_ms);
+ cover_request_movement(cluster, dir);
+ // Ensure at least RELAY_MIN_SWITCH_TIME_MS so the stop isn't deferred by motor protection
+ uint32_t schedule_ms = run_ms > RELAY_MIN_SWITCH_TIME_MS ? run_ms : RELAY_MIN_SWITCH_TIME_MS;
+ hal_tasks_schedule(&cluster->position_task, schedule_ms);
+}
+
// ============================================================================
// NVM Persistence
// ============================================================================
void cover_cluster_store_attrs_to_nv(zigbee_cover_cluster *cluster) {
nv_config_buffer.motor_reversal = cluster->motor_reversal;
+ nv_config_buffer.open_time_ms = cluster->open_time_ms;
+ nv_config_buffer.close_time_ms = cluster->close_time_ms;
+ nv_config_buffer.last_position = cluster->cover_position;
hal_nvm_write(NV_ITEM_COVER_CONFIG(cluster->cover_idx),
sizeof(zigbee_cover_cluster_config),
@@ -145,10 +274,31 @@ void cover_cluster_load_attrs_from_nv(zigbee_cover_cluster *cluster) {
(uint8_t *)&nv_config_buffer);
if (st != HAL_NVM_SUCCESS) {
+ printf("[cover] load_nv idx=%d: no record, keeping defaults open=%d close=%d pos=%d\r\n",
+ cluster->cover_idx, cluster->open_time_ms, cluster->close_time_ms,
+ cluster->cover_position);
return;
}
+ printf("[cover] load_nv idx=%d: raw reversal=%d open=%d close=%d last_pos=%d\r\n",
+ cluster->cover_idx, nv_config_buffer.motor_reversal,
+ nv_config_buffer.open_time_ms, nv_config_buffer.close_time_ms,
+ nv_config_buffer.last_position);
+
cluster->motor_reversal = nv_config_buffer.motor_reversal;
+ // Treat 0 in NVM as "unset" and keep the init default - earlier firmware
+ // persisted 0 here, which would otherwise permanently disable position tracking.
+ if (nv_config_buffer.open_time_ms != 0) {
+ cluster->open_time_ms = nv_config_buffer.open_time_ms;
+ }
+ if (nv_config_buffer.close_time_ms != 0) {
+ cluster->close_time_ms = nv_config_buffer.close_time_ms;
+ }
+ cluster->cover_position = nv_config_buffer.last_position;
+
+ printf("[cover] load_nv idx=%d: applied open=%d close=%d pos=%d tracking=%d\r\n",
+ cluster->cover_idx, cluster->open_time_ms, cluster->close_time_ms,
+ cluster->cover_position, cover_has_position_tracking(cluster));
}
// ============================================================================
@@ -156,14 +306,25 @@ void cover_cluster_load_attrs_from_nv(zigbee_cover_cluster *cluster) {
// ============================================================================
void cover_cluster_on_write_attr(zigbee_cover_cluster *cluster, uint16_t attribute_id) {
- if (attribute_id == ZCL_ATTR_WINDOW_COVERING_MOTOR_REVERSAL) {
+ printf("[cover] write_attr ep=%d attr=0x%04x reversal=%d open=%d close=%d\r\n",
+ cluster->endpoint, attribute_id, cluster->motor_reversal,
+ cluster->open_time_ms, cluster->close_time_ms);
+ switch (attribute_id) {
+ case ZCL_ATTR_WINDOW_COVERING_MOTOR_REVERSAL:
cover_request_movement(cluster, ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED);
+ cover_cluster_store_attrs_to_nv(cluster);
+ break;
+ case ZCL_ATTR_WINDOW_COVERING_OPEN_TIME:
+ case ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME:
+ cover_cluster_store_attrs_to_nv(cluster);
+ break;
}
-
- cover_cluster_store_attrs_to_nv(cluster);
}
void cover_cluster_callback_attr_write_trampoline(uint8_t endpoint, uint16_t attribute_id) {
+ if (cover_cluster_by_endpoint[endpoint] == NULL) {
+ return;
+ }
cover_cluster_on_write_attr(cover_cluster_by_endpoint[endpoint], attribute_id);
}
@@ -171,6 +332,8 @@ hal_zigbee_cmd_result_t cover_cluster_callback(zigbee_cover_cluster *cluster,
uint8_t command_id,
void *cmd_payload,
uint16_t cmd_payload_len) {
+ printf("[cover] cmd cb ep=%d cmd=0x%02x len=%d\r\n",
+ cluster->endpoint, command_id, cmd_payload_len);
switch (command_id) {
case ZCL_CMD_WINDOW_COVERING_UP_OPEN:
cover_open(cluster);
@@ -181,6 +344,12 @@ hal_zigbee_cmd_result_t cover_cluster_callback(zigbee_cover_cluster *cluster,
case ZCL_CMD_WINDOW_COVERING_STOP:
cover_stop(cluster);
break;
+ case ZCL_CMD_WINDOW_COVERING_GO_TO_LIFT_PERCENTAGE:
+ if (cmd_payload_len < 1) {
+ return HAL_ZIGBEE_CMD_SKIPPED;
+ }
+ cover_go_to_lift_percentage(cluster, ((uint8_t *)cmd_payload)[0]);
+ break;
default:
printf("Unknown cover command: %d\r\n", command_id);
return(HAL_ZIGBEE_CMD_SKIPPED);
@@ -204,20 +373,32 @@ hal_zigbee_cmd_result_t cover_cluster_callback_trampoline(uint8_t endpoint,
void cover_cluster_init(zigbee_cover_cluster *cluster) {
// Attributes
+ cluster->cover_position = 50; // unknown default
cluster->moving = ZCL_ATTR_WINDOW_COVERING_MOVING_STOPPED;
cluster->motor_reversal = 0;
+ cluster->open_time_ms = 0;
+ cluster->close_time_ms = 0;
// State
- cluster->last_switch_time = 0;
- cluster->pending_movement = 0;
- cluster->has_pending_movement = 0;
+ cluster->last_switch_time = 0;
+ cluster->pending_movement = 0;
+ cluster->has_pending_movement = 0;
+ cluster->movement_start_ms = 0;
+ cluster->movement_start_position = 50;
+ cluster->target_position = 0xFF;
hal_tasks_init(&cluster->delay_task);
cluster->delay_task.handler = cover_delay_handler;
cluster->delay_task.arg = cluster;
+
+ hal_tasks_init(&cluster->position_task);
+ cluster->position_task.handler = cover_position_task_handler;
+ cluster->position_task.arg = cluster;
}
void cover_cluster_add_to_endpoint(zigbee_cover_cluster *cluster, hal_zigbee_endpoint *endpoint) {
+ printf("[cover] add_to_endpoint ep=%d idx=%d cluster_count_before=%d\r\n",
+ endpoint->endpoint, cluster->cover_idx, endpoint->cluster_count);
cover_cluster_by_endpoint[endpoint->endpoint] = cluster;
cluster->endpoint = endpoint->endpoint;
cover_cluster_init(cluster);
@@ -232,7 +413,7 @@ void cover_cluster_add_to_endpoint(zigbee_cover_cluster *cluster, hal_zigbee_end
ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE,
ZCL_DATA_TYPE_UINT8,
ATTR_READONLY,
- cover_position);
+ cluster->cover_position);
SETUP_ATTR(2,
ZCL_ATTR_WINDOW_COVERING_MOVING,
ZCL_DATA_TYPE_ENUM8,
@@ -243,11 +424,24 @@ void cover_cluster_add_to_endpoint(zigbee_cover_cluster *cluster, hal_zigbee_end
ZCL_DATA_TYPE_BOOLEAN,
ATTR_WRITABLE,
cluster->motor_reversal);
+ SETUP_ATTR(4,
+ ZCL_ATTR_WINDOW_COVERING_OPEN_TIME,
+ ZCL_DATA_TYPE_UINT16,
+ ATTR_WRITABLE,
+ cluster->open_time_ms);
+ SETUP_ATTR(5,
+ ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME,
+ ZCL_DATA_TYPE_UINT16,
+ ATTR_WRITABLE,
+ cluster->close_time_ms);
endpoint->clusters[endpoint->cluster_count].cluster_id = ZCL_CLUSTER_WINDOW_COVERING;
- endpoint->clusters[endpoint->cluster_count].attribute_count = 4;
+ endpoint->clusters[endpoint->cluster_count].attribute_count = 6;
endpoint->clusters[endpoint->cluster_count].attributes = cluster->attr_infos;
endpoint->clusters[endpoint->cluster_count].is_server = 1;
endpoint->clusters[endpoint->cluster_count].cmd_callback = cover_cluster_callback_trampoline;
endpoint->cluster_count++;
+ printf(
+ "[cover] add_to_endpoint ep=%d registered cluster=0x%04x is_server=1 cluster_count_after=%d\r\n",
+ endpoint->endpoint, ZCL_CLUSTER_WINDOW_COVERING, endpoint->cluster_count);
}
diff --git a/src/zigbee/cover_cluster.h b/src/zigbee/cover_cluster.h
index a135cb664b..a6b91b40f5 100644
--- a/src/zigbee/cover_cluster.h
+++ b/src/zigbee/cover_cluster.h
@@ -7,7 +7,10 @@
#include
typedef struct {
- uint8_t motor_reversal;
+ uint8_t motor_reversal;
+ uint16_t open_time_ms; // 0 in NVM means unset - init default is kept
+ uint16_t close_time_ms; // 0 in NVM means unset - init default is kept
+ uint8_t last_position; // last known position saved on stop
} zigbee_cover_cluster_config;
typedef struct {
@@ -18,15 +21,22 @@ typedef struct {
relay_t * close_relay;
// Attributes
+ uint8_t cover_position; // 0=open, 100=closed
uint8_t moving;
uint8_t motor_reversal;
- hal_zigbee_attribute attr_infos[4];
+ uint16_t open_time_ms;
+ uint16_t close_time_ms;
+ hal_zigbee_attribute attr_infos[6];
// State
uint32_t last_switch_time;
uint8_t has_pending_movement;
uint8_t pending_movement;
hal_task_t delay_task;
+ uint32_t movement_start_ms;
+ uint8_t movement_start_position;
+ uint8_t target_position; // 0xFF = no active target
+ hal_task_t position_task;
} zigbee_cover_cluster;
void cover_cluster_add_to_endpoint(zigbee_cover_cluster *cluster, hal_zigbee_endpoint *endpoint);
@@ -34,6 +44,7 @@ void cover_cluster_add_to_endpoint(zigbee_cover_cluster *cluster, hal_zigbee_end
void cover_open(zigbee_cover_cluster *cluster);
void cover_close(zigbee_cover_cluster *cluster);
void cover_stop(zigbee_cover_cluster *cluster);
+void cover_go_to_lift_percentage(zigbee_cover_cluster *cluster, uint8_t target);
void cover_cluster_callback_attr_write_trampoline(uint8_t endpoint, uint16_t attribute_id);
diff --git a/src/zigbee/cover_switch_cluster.c b/src/zigbee/cover_switch_cluster.c
index f732a71961..cc1a53a3bc 100644
--- a/src/zigbee/cover_switch_cluster.c
+++ b/src/zigbee/cover_switch_cluster.c
@@ -324,6 +324,9 @@ void cover_switch_cluster_on_write_attr(zigbee_cover_switch_cluster *cluster,
void cover_switch_cluster_callback_attr_write_trampoline(uint8_t endpoint,
uint16_t attribute_id) {
+ if (cover_switch_cluster_by_endpoint[endpoint] == NULL) {
+ return;
+ }
cover_switch_cluster_on_write_attr(cover_switch_cluster_by_endpoint[endpoint],
attribute_id);
}
diff --git a/src/zigbee/switch_cluster.c b/src/zigbee/switch_cluster.c
index bf3117f3b2..b68beb307c 100644
--- a/src/zigbee/switch_cluster.c
+++ b/src/zigbee/switch_cluster.c
@@ -82,6 +82,9 @@ void switch_cluster_report_action(zigbee_switch_cluster *cluster);
void switch_cluster_callback_attr_write_trampoline(uint8_t endpoint,
uint16_t attribute_id) {
+ if (switch_cluster_by_endpoint[endpoint] == NULL) {
+ return;
+ }
switch_cluster_on_write_attr(switch_cluster_by_endpoint[endpoint],
attribute_id);
}
diff --git a/tests/conftest.py b/tests/conftest.py
index 5d52a79ee7..c2ecfa1e7d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,8 +18,11 @@
ZCL_ATTR_ONOFF_CONFIGURATION_SWITCH_BINDING_MODE,
ZCL_ATTR_ONOFF_CONFIGURATION_SWITCH_MODE,
ZCL_ATTR_ONOFF_CONFIGURATION_SWITCH_RELAY_MODE,
+ ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME,
+ ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE,
ZCL_ATTR_WINDOW_COVERING_MOTOR_REVERSAL,
ZCL_ATTR_WINDOW_COVERING_MOVING,
+ ZCL_ATTR_WINDOW_COVERING_OPEN_TIME,
ZCL_CLUSTER_COVER_SWITCH_CONFIG,
ZCL_CLUSTER_MULTISTATE_INPUT_BASIC,
ZCL_CLUSTER_ON_OFF,
@@ -28,6 +31,7 @@
ZCL_CMD_ONOFF_OFF,
ZCL_CMD_ONOFF_ON,
ZCL_CMD_WINDOW_COVERING_DOWN_CLOSE,
+ ZCL_CMD_WINDOW_COVERING_GO_TO_LIFT_PERCENTAGE,
ZCL_CMD_WINDOW_COVERING_STOP,
ZCL_CMD_WINDOW_COVERING_UP_OPEN,
)
@@ -496,6 +500,26 @@ def zcl_cover_stop(self, endpoint: int) -> None:
endpoint, ZCL_CLUSTER_WINDOW_COVERING, ZCL_CMD_WINDOW_COVERING_STOP
)
+ def zcl_cover_get_position(self, endpoint: int) -> int:
+ return int(
+ self.read_zigbee_attr(
+ endpoint, ZCL_CLUSTER_WINDOW_COVERING,
+ ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE))
+
+ def zcl_cover_set_open_time(self, endpoint: int, ms: int) -> None:
+ self.write_zigbee_attr(endpoint, ZCL_CLUSTER_WINDOW_COVERING,
+ ZCL_ATTR_WINDOW_COVERING_OPEN_TIME, ms)
+
+ def zcl_cover_set_close_time(self, endpoint: int, ms: int) -> None:
+ self.write_zigbee_attr(endpoint, ZCL_CLUSTER_WINDOW_COVERING,
+ ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME, ms)
+
+ def zcl_cover_go_to_lift_percentage(self, endpoint: int, position: int) -> None:
+ self.call_zigbee_cmd(
+ endpoint, ZCL_CLUSTER_WINDOW_COVERING,
+ ZCL_CMD_WINDOW_COVERING_GO_TO_LIFT_PERCENTAGE,
+ payload=bytes([position]))
+
def wait_for(
condition_fn: Callable[[], bool], timeout: float = 2.0, interval: float = 0.1
diff --git a/tests/test_cover_cluster.py b/tests/test_cover_cluster.py
index ca28f97d84..efc72c1c90 100644
--- a/tests/test_cover_cluster.py
+++ b/tests/test_cover_cluster.py
@@ -9,6 +9,9 @@
ZCL_WINDOW_COVERING_MOVING_CLOSING,
)
+# Travel times for calibrated covers (easy math: 1% per 100ms)
+TRAVEL_TIME_MS = 10000
+
@pytest.fixture
def cover_device() -> Device:
@@ -23,6 +26,22 @@ def cover_device() -> Device:
p.stop()
+@pytest.fixture
+def calibrated_cover_device() -> Device:
+ """Cover device with position tracking enabled (travel_time = 10000ms each direction)."""
+ p = StubProc(device_config="X;Y;CA0A1;").start()
+ try:
+ d = Device(p)
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ endpoint = 1
+ # Enable position tracking
+ d.zcl_cover_set_open_time(endpoint, TRAVEL_TIME_MS)
+ d.zcl_cover_set_close_time(endpoint, TRAVEL_TIME_MS)
+ yield d
+ finally:
+ p.stop()
+
+
def test_cover_open(cover_device: Device):
d = cover_device
endpoint = 1
@@ -132,3 +151,197 @@ def test_cover_immediate_reversal(cover_device: Device):
assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
d.step_time(MINIMUM_SWITCH_TIME_MS)
assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_CLOSING
+
+
+# Position tracking tests
+def test_position_default_is_50(calibrated_cover_device: Device):
+ """Position should default to 50 (unknown)."""
+ d = calibrated_cover_device
+ endpoint = 1
+ assert d.zcl_cover_get_position(endpoint) == 50
+
+
+def test_position_updates_after_full_open(calibrated_cover_device: Device):
+ """After opening for full travel time from position 50, position should be 0 (fully open)."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Act: Open for the full travel time, then stop
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+
+ # Assert: Position should be fully open (0)
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ assert d.zcl_cover_get_position(endpoint) == 0
+
+
+def test_position_updates_after_full_close(calibrated_cover_device: Device):
+ """After closing for full travel time from position 0, position should be 100 (fully closed)."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: First open fully to get position to 0
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+ assert d.zcl_cover_get_position(endpoint) == 0
+
+ # Act: Close for the full travel time
+ d.step_time(MINIMUM_SWITCH_TIME_MS) # Motor protection delay
+ d.zcl_cover_close(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+
+ # Assert: Position should be fully closed (100)
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ assert d.zcl_cover_get_position(endpoint) == 100
+
+
+def test_position_partial_open(calibrated_cover_device: Device):
+ """Opening for 50% of travel time from position 100 should result in position 50."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: First close fully to get position to 100
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_close(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+ assert d.zcl_cover_get_position(endpoint) == 100
+
+ # Act: Open for 50% of travel time
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS // 2)
+ d.zcl_cover_stop(endpoint)
+
+ # Assert: Position should be ~50 (within 1 due to integer math)
+ pos = d.zcl_cover_get_position(endpoint)
+ assert 49 <= pos <= 51
+
+
+def test_go_to_lift_percentage_from_closed(calibrated_cover_device: Device):
+ """GoToLiftPercentage(30) from fully closed position should open to 30."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: Close fully
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_close(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+ assert d.zcl_cover_get_position(endpoint) == 100
+
+ # Act: Go to 30%
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_go_to_lift_percentage(endpoint, 30)
+ expected_run_time = (100 - 30) * TRAVEL_TIME_MS // 100
+ d.step_time(expected_run_time + MINIMUM_SWITCH_TIME_MS)
+
+ # Assert: Position should be exactly 30
+ pos = d.zcl_cover_get_position(endpoint)
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ assert pos == 30
+
+
+def test_go_to_lift_percentage_from_open(calibrated_cover_device: Device):
+ """GoToLiftPercentage(70) from fully open position should close to 70."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: Open fully (starting position is 50, so open first)
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+ assert d.zcl_cover_get_position(endpoint) == 0
+
+ # Act: Go to 70%
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_go_to_lift_percentage(endpoint, 70)
+ expected_run_time = 70 * TRAVEL_TIME_MS // 100
+ d.step_time(expected_run_time + MINIMUM_SWITCH_TIME_MS)
+
+ # Assert: Position should be exactly 70
+ pos = d.zcl_cover_get_position(endpoint)
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ assert pos == 70
+
+
+def test_go_to_lift_percentage_already_there(calibrated_cover_device: Device):
+ """GoToLiftPercentage to current position should stop immediately."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Act: Go to 50 (already there)
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_go_to_lift_percentage(endpoint, 50)
+
+ # Assert: Motor should stop immediately
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ assert d.zcl_cover_get_position(endpoint) == 50
+
+
+def test_go_to_lift_percentage_stops_at_target(calibrated_cover_device: Device):
+ """GoToLiftPercentage should stop the motor exactly when reaching target."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: Start from fully open
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+
+ # Act: Go to 50%
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_go_to_lift_percentage(endpoint, 50)
+
+ # Assert: Motor is moving
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_CLOSING
+
+ # Step to target time and verify it stops
+ expected_run_time = 50 * TRAVEL_TIME_MS // 100
+ d.step_time(expected_run_time + MINIMUM_SWITCH_TIME_MS)
+
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ pos = d.zcl_cover_get_position(endpoint)
+ assert pos == 50
+
+
+def test_go_to_lift_percentage_cancelled_by_stop(calibrated_cover_device: Device):
+ """Manual STOP should cancel GoToLiftPercentage and preserve position."""
+ d = calibrated_cover_device
+ endpoint = 1
+
+ # Arrange: Start from fully open
+ d.zcl_cover_open(endpoint)
+ d.step_time(TRAVEL_TIME_MS)
+ d.zcl_cover_stop(endpoint)
+
+ # Act: Go to 30%, but stop halfway through the run
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+ d.zcl_cover_go_to_lift_percentage(endpoint, 30)
+
+ # run_ms = 30 * TRAVEL_TIME_MS / 100 = 3000ms; stop at the midpoint (1500ms)
+ run_ms = 30 * TRAVEL_TIME_MS // 100
+ d.step_time(run_ms // 2)
+ d.zcl_cover_stop(endpoint)
+
+ # Assert: Motor stopped halfway to target — position is 15 (0 + 15)
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
+ pos = d.zcl_cover_get_position(endpoint)
+ assert pos == 15
+
+
+def test_go_to_lift_percentage_no_tracking(cover_device: Device):
+ """GoToLiftPercentage should be ignored when position tracking is not configured."""
+ d = cover_device
+ endpoint = 1
+ # This device has no travel times configured, so GoToLiftPercentage should have no effect
+
+ # Act: Try to go to 70%
+ d.zcl_cover_go_to_lift_percentage(endpoint, 70)
+ d.step_time(MINIMUM_SWITCH_TIME_MS)
+
+ # Assert: Motor should not move
+ assert d.zcl_cover_get_moving(endpoint) == ZCL_WINDOW_COVERING_MOVING_STOPPED
diff --git a/tests/zcl_consts.py b/tests/zcl_consts.py
index 2181f84d60..471b0a48fb 100644
--- a/tests/zcl_consts.py
+++ b/tests/zcl_consts.py
@@ -118,6 +118,13 @@
ZCL_LEVEL_MOVE_UP = 0x00
ZCL_LEVEL_MOVE_DOWN = 0x01
+# Attributes - Window Covering cluster
+ZCL_ATTR_WINDOW_COVERING_CURRENT_POSITION_LIFT_PERCENTAGE = 0x0008
+ZCL_ATTR_WINDOW_COVERING_MOVING = 0xff00
+ZCL_ATTR_WINDOW_COVERING_MOTOR_REVERSAL = 0xff01
+ZCL_ATTR_WINDOW_COVERING_OPEN_TIME = 0xff02
+ZCL_ATTR_WINDOW_COVERING_CLOSE_TIME = 0xff03
+
# Enum values - Window Covering cluster
ZCL_WINDOW_COVERING_MOVING_STOPPED = 0
ZCL_WINDOW_COVERING_MOVING_OPENING = 1
@@ -150,8 +157,11 @@
ZCL_CMD_LEVEL_STEP_WITH_ON_OFF = 0x06
ZCL_CMD_LEVEL_STOP_WITH_ON_OFF = 0x07
-ZCL_LEVEL_MOVE_UP = 0x00
-ZCL_LEVEL_MOVE_DOWN = 0x01
+# Commands - Window Covering Cluster
+ZCL_CMD_WINDOW_COVERING_UP_OPEN = 0x00
+ZCL_CMD_WINDOW_COVERING_DOWN_CLOSE = 0x01
+ZCL_CMD_WINDOW_COVERING_STOP = 0x02
+ZCL_CMD_WINDOW_COVERING_GO_TO_LIFT_PERCENTAGE = 0x05
# Commands - Poll Control Cluster (client -> server)
ZCL_CMD_POLL_CTRL_CHECK_IN_RSP = 0x00
@@ -159,11 +169,6 @@
ZCL_CMD_POLL_CTRL_SET_LONG_POLL_INTERVAL = 0x02
ZCL_CMD_POLL_CTRL_SET_SHORT_POLL_INTERVAL = 0x03
-# Commands - Window Covering Cluster
-ZCL_CMD_WINDOW_COVERING_UP_OPEN = 0x00
-ZCL_CMD_WINDOW_COVERING_DOWN_CLOSE = 0x01
-ZCL_CMD_WINDOW_COVERING_STOP = 0x02
-
# Data types (not currently used in tests, provided for completeness)
ZCL_DATA_TYPE_NO_DATA = 0x00
ZCL_DATA_TYPE_DATA8 = 0x08