From 154c89c632eeb930fd0fd54120c181be1180e3ff Mon Sep 17 00:00:00 2001 From: Byron <1749192+RealByron@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:53:49 +0200 Subject: [PATCH 1/2] cover position control --- device_db.yaml | 26 +++ .../templates/switch_custom.js.jinja | 80 ++++--- src/device_config/config_parser.c | 34 +-- src/telink/hal/zigbee_zcl.c | 14 +- src/zigbee/consts.h | 9 +- src/zigbee/cover_cluster.c | 218 +++++++++++++++++- src/zigbee/cover_cluster.h | 15 +- src/zigbee/cover_switch_cluster.c | 3 + src/zigbee/switch_cluster.c | 3 + tests/conftest.py | 24 ++ tests/test_cover_cluster.py | 213 +++++++++++++++++ tests/zcl_consts.py | 19 +- 12 files changed, 591 insertions(+), 67 deletions(-) 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/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 From e362676f5ceb1b2c0dccf88f797a47be1e9997e5 Mon Sep 17 00:00:00 2001 From: Byron <1749192+RealByron@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:16:11 +0200 Subject: [PATCH 2/2] some doc updates --- docs/contribute/porting.md | 6 +++--- docs/updating.md | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) 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