BENEXMART Tuya Zigbee Electric Curtain Robot

Has anyone had any luck with this:

BENEXMART Tuya Zigbee Electric Curtain Robot Rechargeable Motor for Roman Rod I Type U Type Tracks Alexa Google Home Control (Robot one set) https://www.amazon.co.uk/dp/B09S5V6YSN/ref=cm_sw_r_apan_i_EMNV8CHZKWDVYKQNGJCJ?psc=1

Thanks

Joe

Do you have this device?
If yes, please post the Manufacturer value from the device page 'Data' section.

Yes I have 2 of these and AFAIK it is the best curtain retrofit robot out there. Switchbot is let down by their communication protocol IMO. The only thing you have to take into account is that it needs very particular rod dimensions (20mm-28mm) and in my experience, it cannot traverse the rod extension portion, so you may need to rebuy rods to be solid or get creative. Overall I love mine.

This is the actual one I got (much cheaper than amazon's listings):

or

1 Like

That's great. Thanks so much. Which driver did you use? Luckily I haven't put the rods up yet, so again, great to know.

With a few of my own modifications (robots report battery for instance that is unhandled). If you want I can send my driver over and you can compare and merge whatever changes you want. Technically the battery report is not "unhandled" it just reports as a warning iirc in the logs, and I wanted to use that data, so I added it modeling the other parameters.

Yes please do.

Modified Driver
/**
 *  Copyright 2021
 *
 *	Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *	in compliance with the License. You may obtain a copy of the License at:
 *
 *		http://www.apache.org/licenses/LICENSE-2.0
 *
 *	Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *	on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *	for the specific language governing permissions and limitations under the License.
 *
 * This DTH is coded based on iquix's tuya-window-shade DTH and these other files:
 * https://github.com/iquix/Smartthings/blob/master/devicetypes/iquix/tuya-window-shade.src/tuya-window-shade.groovy
 * https://raw.githubusercontent.com/shin4299/XiaomiSJ/master/devicetypes/shinjjang/zemismart-zigbee-blind.src/zemismart-zigbee-blind.groovy
 * https://templates.blakadder.com/zemismart_YH002.html
 * https://github.com/zigpy/zha-device-handlers/blob/f3302257fbb57f9f9f99ecbdffdd2e7862cc1fd7/zhaquirks/tuya/__init__.py#L846
 *
 * VERSION HISTORY
 * 3.0.0 (2021-06-18) [Amos Yuen] - Support new window shade command startPositionChange()
 *		- Rename stop() to stopPositionChange()
 *		- Handle ack and set time zigbee messages
 * 2.3.0 (2021-06-09) [Amos Yuen] - Add presence attribute to indicate whether device is responsive
 * 2.2.0 (2021-06-06) [Amos Yuen] - Add commands for stepping
*       - Fix push command not sending zigbee commands
 * 2.1.0 (2021-05-01) [Amos Yuen] - Add pushable button capability
 *		- Add configurable close and open position thresholds
 * 2.0.0 (2021-03-09) [Amos Yuen] - Change tilt mode open()/close() commands to use set position
 *			to open/close all the way.
 *		- Rename pause() to stop()
 *		- Remove superfluous setDirection() setMode() functions
 * 1.0.0 (2021-03-09) [Amos Yuen] - Initial Commit
 */

import groovy.json.JsonOutput
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
import hubitat.helper.HexUtils

private def textVersion() {
	return "3.0.0 - 2021-06-18"
}

private def textCopyright() {
	return "Copyright ©2021\nAmos Yuen, iquix, ShinJjang"
}

metadata {
	definition(name: "ZemiSmart Zigbee Blind", namespace: "amosyuen", author: "Amos Yuen",
			ocfDeviceType: "oic.d.blind", vid: "generic-shade") {
		capability "Actuator"
		capability "Configuration"
		capability "Presence Sensor"
		capability "PushableButton"
		capability "Window Shade"
        capability "Refresh"
        capability "Switch"
        capability "SwitchLevel"
        capability "Battery"

		attribute "speed", "integer"

		command "push", [[
			name: "button number*",
			type: "NUMBER",
			description: "1: Open, 2: Close, 3: Stop, 4: Step Open, 5: Step Close"]]
		command "stepClose", [[
			name: "step",
			type: "NUMBER",
			description: "Amount to change position towards close. Defaults to defaultStepAmount if not set."]]
		command "stepOpen", [[
			name: "step",
			type: "NUMBER",
			description: "Amount to change position towards open. Defaults to defaultStepAmount if not set."]]
		command "setSpeed", [[
			name: "speed*",
			type: "NUMBER",
			description: "Motor speed (0 to 100). Values below 5 may not work."]]

		fingerprint(endpointId: "01", profileId: "0104",
					inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
					manufacturer: "_TYST11_wmcdj3aq", model: "mcdj3aq",
					deviceJoinName: "Zemismart Zigbee Blind")
		fingerprint(endpointId: "01", profileId: "0104",
					inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
					manufacturer: "_TYST11_cowvfr", model: "owvfni3",
					deviceJoinName: "Zemismart Zigbee Blind")
		fingerprint(endpointId: "01", profileId: "0104",
					inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
					deviceJoinName: "Zemismart Zigbee Blind")
		// AM43-0.45/40-ES-EZ
		fingerprint(endpointId: "01", profileId: "0104",
					inClusters: "0000, 0004, 0005, EF00", outClusters: "0019, 000A",
					manufacturer: "_TZE200_zah67ekd", model: "TS0601",
					deviceJoinName: "Zemismart Zigbee Blind Motor")
	}

	preferences {
		input("mode", "enum", title: "Mode",
			description: "<li><b>lift</b> - motor moves until button pressed again</li>"
					+ "<li><b>tilt</b> - pressing button < 1.5s, movement stops on release"
					+ "; pressing button > 1.5s, motor moves until button pressed again</li>",
			options: MODE_MAP, required: true, defaultValue: "1")
		input("direction", "enum", title: "Direction",
			options: DIRECTION_MAP, required: true, defaultValue: 0)
		input("maxClosedPosition", "number", title: "Max Closed Position",
			description: "The max position value that window shade state should be set to closed",
			required: true, defaultValue: 1)
		input("minOpenPosition", "number", title: "Min Open Position",
			description: "The min position value that window shade state should be set to open",
			required: true, defaultValue: 99)
		input("defaultStepAmount", "number", title: "Default Step Amount",
			description: "The default step amount",
			required: true, defaultValue: 5)
		input("enableDebugLog", "bool", title: "Enable debug logging", required: true, defaultValue: false)
		input("enableTraceLog", "bool", title: "Enable trace logging", required: true, defaultValue: false)
		input("enableUnexpectedMessageLog", "bool", title: "Log unexpected messages", required: true, defaultValue: false)   
	}
}

@Field final String MODE_TILT = "0"
@Field final Map MODE_MAP = [1: "lift", 0: "tilt"]
@Field final Map MODE_MAP_REVERSE = MODE_MAP.collectEntries { [(it.value): it.key] }
@Field final List MODES = MODE_MAP.collect { it.value }
@Field final Map DIRECTION_MAP = [0: "forward", 1: "reverse"]
@Field final Map DIRECTION_MAP_REVERSE = DIRECTION_MAP.collectEntries { [(it.value): it.key] }
@Field final List DIRECTIONS = DIRECTION_MAP.collect { it.value }
@Field final int CHECK_FOR_RESPONSE_INTERVAL_SECONDS = 60
@Field final int HEARTBEAT_INTERVAL_SECONDS = 4000 // a little more than 1 hour

//
// Life Cycle
//

def installed() {
	configure()
}

def updated() {
	configure()
}

def configure() {
	logDebug("configure")
	state.version = textVersion()
    state.remove("copyright")
    sendEvent(name: "battery", value: 0)
	state.copyright = textCopyright()

	if (atomicState.lastHeardMillis == null) {
		atomicState.lastHeardMillis = 0
	}

	sendEvent(name: "numberOfButtons", value: 5)
	if (device.currentPosition != null
        && (device.currentWindowShade == "closed"
            || device.currentWindowShade == "open"
            || device.currentWindowShade == "partially open")) {
		updateWindowShadeArrived(device.currentPosition)
	}

	// Must run async otherwise, one will block the other
	runIn(1, setMode)
	runIn(2, setDirection)

	if (maxClosedPosition < 0 || maxClosedPosition > 100) {
		throw new Exception("Invalid maxClosedPosition \"${maxClosedPosition}\" should be between"
			+ " 0 and 100 inclusive.")
	}
	if (minOpenPosition < 0 || minOpenPosition > 100) {
		throw new Exception("Invalid minOpenPosition \"${minOpenPosition}\" should be between 0"
			+ " and 100 inclusive.")
	}
	if (maxClosedPosition >= minOpenPosition) {
		throw new Exception("maxClosedPosition \"${minOpenPosition}\" must be less than"
			+ " minOpenPosition \"${minOpenPosition}\".")
	}
}

def setDirection() {
	def directionValue = direction as int
	logDebug("setDirection: directionText=${DIRECTION_MAP[directionValue]}, directionValue=${directionValue}")
	sendTuyaCommand(DP_ID_DIRECTION, DP_TYPE_ENUM, directionValue, 2)
}

def setMode() {
	def modeValue = mode as int
	logDebug("setMode: modeText=${MODE_MAP[mode]}, modeValue=${modeValue}")
	sendTuyaCommand(DP_ID_MODE, DP_TYPE_ENUM, modeValue, 2)
}

//
// Messages
//

@Field final int CLUSTER_TUYA = 0xEF00

@Field final int ZIGBEE_COMMAND_SET_DATA = 0x00
@Field final int ZIGBEE_COMMAND_SET_DATA_RESPONSE = 0x02
@Field final int ZIGBEE_COMMAND_ACK = 0x0B
@Field final int ZIGBEE_COMMAND_SET_TIME = 0x24

@Field final int DP_ID_COMMAND = 0x01
@Field final int DP_ID_TARGET_POSITION = 0x02
@Field final int DP_ID_CURRENT_POSITION = 0x03
@Field final int DP_ID_DIRECTION = 0x05
@Field final int DP_ID_COMMAND_REMOTE = 0x07
@Field final int DP_ID_MODE = 0x65
@Field final int DP_ID_SPEED = 0x69
@Field final int DP_ID_BATTERY = 0x0D

@Field final int DP_TYPE_BOOL = 0x01
@Field final int DP_TYPE_VALUE = 0x02
@Field final int DP_TYPE_ENUM = 0x04

@Field final int DP_COMMAND_OPEN = 0x00
@Field final int DP_COMMAND_STOP = 0x01
@Field final int DP_COMMAND_CLOSE = 0x02
@Field final int DP_COMMAND_CONTINUE = 0x03

def parse(String description) {
	if (description == null || (!description.startsWith('catchall:') && !description.startsWith('read attr -'))) {
		logUnexpectedMessage("parse: Unhandled description=${description}")
		return
	}

	updatePresence(true)
	Map descMap = zigbee.parseDescriptionAsMap(description)
	if (descMap.clusterInt != CLUSTER_TUYA) {
		logUnexpectedMessage("parse: Not a Tuya Message descMap=${descMap}")
		return
	}
	def command = zigbee.convertHexToInt(descMap.command)
	switch (command) {
		case ZIGBEE_COMMAND_SET_DATA_RESPONSE: // 0x02
			if (!descMap?.data || descMap.data.size() < 7) {
                logUnexpectedMessage("parse: Invalid data size for SET_DATA_RESPONSE descMap=${descMap}")
				return
			}
			parseSetDataResponse(descMap)
			return
		case ZIGBEE_COMMAND_ACK: // 0x0B
			if (!descMap?.data || descMap.data.size() < 2) {
				logUnexpectedMessage("parse: Invalid data size for ACK descMap=${descMap}")
				return
			}
			def ackCommand = zigbee.convertHexToInt(descMap.data.join())
	        logTrace("parse: ACK command=${ackCommand}")
			return
		case ZIGBEE_COMMAND_SET_TIME: // 0x24
			// Data payload seems to increment every hour but doesn't seem to be an absolute value
	        logTrace("parse: SET_TIME data=${descMap.data}")
			return
		default:
			logUnexpectedMessage("parse: Unhandled command=${command} descMap=${descMap}")
			return
	}
}

/*
 * Data (sending and receiving) generally have this format:
 * [2 bytes] (packet id)
 * [1 byte] (dp ID)
 * [1 byte] (dp type)
 * [2 bytes] (fnCmd length in bytes)
 * [variable bytes] (fnCmd)
 */
def parseSetDataResponse(descMap) {
	logTrace("parseSetDataResponse: descMap=${descMap}")
	def data = descMap.data
	def dp = zigbee.convertHexToInt(data[2])
	def dataValue = zigbee.convertHexToInt(data[6..-1].join())
	switch (dp) {
		case DP_ID_COMMAND: // 0x01 Command
			switch (dataValue) {
				case DP_COMMAND_OPEN: // 0x00
					logDebug("parse: opening")
					updateWindowShadeOpening()
					return
				case DP_COMMAND_STOP: // 0x01
					logDebug("parse: stopping")
					return
				case DP_COMMAND_CLOSE: // 0x02
					logDebug("parse: closing")
					updateWindowShadeClosing()
					return
				case DP_COMMAND_CONTINUE: // 0x03
					logDebug("parse: continuing")
					return
				default:
					logUnexpectedMessage("parse: Unexpected DP_ID_COMMAND dataValue=${dataValue}")
					return
			}
		
		case DP_ID_TARGET_POSITION: // 0x02 Target position
			if (dataValue >= 0 && dataValue <= 100) {
				logDebug("parse: moving to position ${dataValue}")
				updateWindowShadeMoving(dataValue)
				updatePosition(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_TARGET_POSITION dataValue=${dataValue}")
			}
			return
		
		case DP_ID_CURRENT_POSITION: // 0x03 Current Position
			if (dataValue >= 0 && dataValue <= 100) {
				logDebug("parse: arrived at position ${dataValue}")
				updateWindowShadeArrived(dataValue)
				updatePosition(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_CURRENT_POSITION dataValue=${dataValue}")
			}
			return
		
		case DP_ID_DIRECTION: // 0x05 Direction
			def directionText = DIRECTION_MAP[dataValue]
			if (directionText != null) {
				logDebug("parse: direction=${directionText}")
				updateDirection(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_DIRECTION dataValue=${dataValue}")
			}
			return
		
		case DP_ID_COMMAND_REMOTE: // 0x07 Remote Command
			if (dataValue == 0) {
				logDebug("parse: opening from remote")
				updateWindowShadeOpening()
			} else if (dataValue == 1) {
				logDebug("parse: closing from remote")
				updateWindowShadeClosing()
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_COMMAND_REMOTE dataValue=${dataValue}")
			}
			return
		
		case DP_ID_MODE: // 0x65 Mode
			def modeText = MODE_MAP[dataValue]
			if (modeText != null) {
				logDebug("parse: mode=${modeText}")
				updateMode(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_MODE dataValue=${dataValue}")
			}
			return
		
		case DP_ID_SPEED: // 0x69 Motor speed
			if (dataValue >= 0 && dataValue <= 100) {
				logDebug("parse: speed=${dataValue}")
				updateSpeed(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_SPEED dataValue=${dataValue}")
			}
			return
        case DP_ID_BATTERY: // 0xOD Battery
			if (dataValue >= 0 && dataValue <= 100) {
				logDebug("parse: battery=${dataValue}")
				updateBattery(dataValue)
			} else {
				logUnexpectedMessage("parse: Unexpected DP_ID_BATTERY dataValue=${dataValue}")
			}
			return
		
		default:
			logUnexpectedMessage("parse: Unknown DP_ID dp=0x${data[2]}, dataType=0x${data[3]} dataValue=${dataValue}")
			return
	}
}

private ignorePositionReport(position) {
	def lastPosition = device.currentPosition
	logDebug("ignorePositionReport: position=${position}, lastPosition=${lastPosition}")
	if (lastPosition == "undefined" || isWithinOne(position)) {
		logTrace("Ignore invalid reports")
		return true
	}
	return false
}

private isWithinOne(position) {
	def lastPosition = device.currentPosition
	logTrace("isWithinOne: position=${position}, lastPosition=${lastPosition}")
	if (lastPosition != "undefined" && Math.abs(position - lastPosition) <= 1) {
		return true
	}
	return false
}

private updateDirection(directionValue) {
	def directionText = DIRECTION_MAP[directionValue]
	logDebug("updateDirection: directionText=${directionText}, directionValue=${directionValue}")
	if (directionValue != (direction as int)) {
		setDirection()
	}
}

private updateMode(modeValue) {
	def modeText = MODE_MAP[modeValue]
	logDebug("updateMode: modeText=${modeText}, modeValue=${modeValue}")
	if (modeValue != (mode as int)) {
		setMode()
	}
}

private updatePosition(position) {
	logDebug("updatePosition: position=${position}")
	if (isWithinOne(position)) {
		return
	}
	sendEvent(name: "position", value: position)
}

private updatePresence(present) {
	logDebug("updatePresence: present=${present}")
	if (present) {
		atomicState.lastHeardMillis = now()
		checkHeartbeat()
	}
	atomicState.waitingForResponseSinceMillis = null
	sendEvent(name: "presence", value: present ? "present" : "not present")
}

private updateSpeed(speed) {
	logDebug("updateSpeed: speed=${speed}")
	sendEvent(name: "speed", value: speed)
}

private updateBattery(battery) {
	logDebug("updateBattery: battery=${battery}")
	sendEvent(name: "battery", value: battery)
}

private updateWindowShadeMoving(position) {
	def lastPosition = device.currentPosition
	logDebug("updateWindowShadeMoving: position=${position}, lastPosition=${lastPosition}")

	if (lastPosition < position) {
		updateWindowShadeOpening()
	} else if (lastPosition > position) {
		updateWindowShadeClosing()
	}
}

private updateWindowShadeOpening() {
	logTrace("updateWindowShadeOpening")
	sendEvent(name:"windowShade", value: "opening")
}

private updateWindowShadeClosing() {
	logTrace("updateWindowShadeClosing")
	sendEvent(name:"windowShade", value: "closing")
}

private updateWindowShadeArrived(position) {
	logDebug("updateWindowShadeArrived: position=${position}")
	if (position < 0 || position > 100) {
		log.warn("updateWindowShadeArrived: Need to setup limits on device")
		sendEvent(name: "windowShade", value: "unknown")
	} else if (position <= maxClosedPosition) {
		sendEvent(name: "windowShade", value: "closed")
	} else if (position >= minOpenPosition) {
		sendEvent(name: "windowShade", value: "open")
	} else {
		sendEvent(name: "windowShade", value: "partially open")
	}
}

//
// Actions
//

def refresh()
{
	logTrace "Refresh called..."
	zigbee.onOffRefresh()
}

def close() {
	logDebug("close")
	if (mode == MODE_TILT) {
		setPosition(0)
	} else {
        sendEvent(name: "position", value: 0)
		sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_CLOSE, 2)
	}
}

def off()
{
    close()
}

def open() {
	logDebug("open")
	if (mode == MODE_TILT) {
		setPosition(100)
	} else {
        sendEvent(name: "position", value: 100)
		sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_OPEN, 2)
	}
}

def on()
{
    open()
}

def startPositionChange(state) {
	logDebug("startPositionChange")
	switch (state) {
		case "close":
			close()
			return
		case "open":
			open()
			return
		default:
			throw new Exception("Unsupported startPositionChange state \"${state}\"")
	}
}

def stopPositionChange() {
	logDebug("stopPositionChange")
	sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_STOP, 2)
}

def setPosition(position) {
	logDebug("setPosition: position=${position}")
	if (position < 0 || position > 100) {
		throw new Exception("Invalid position ${position}. Position must be between 0 and 100 inclusive.")
	}
	if (isWithinOne(position)) {
		// Motor is off by one sometimes, so set it to desired value if within one
		sendEvent(name: "position", value: position)
	}
	sendTuyaCommand(DP_ID_TARGET_POSITION, DP_TYPE_VALUE, position.intValue(), 8)
}

def setLevel(level, duration = null)
{
    setPosition(level)    
}

def stepClose(step) {
	if (!step) {
		step = defaultStepAmount
	}
	stepOpen(-step)
}

def stepOpen(step) {
	logDebug("stepOpen: step=${step}")
	if (!step) {
		step = defaultStepAmount
	}
	setPosition(Math.max( 0, Math.min(100, (device.currentPosition + step) as int)))
}


def setSpeed(speed) {
	logDebug("setSpeed: speed=${speed}")
	if (speed < 0 || speed > 100) {
		throw new Exception("Invalid speed ${speed}. Speed must be between 0 and 100 inclusive.")
	}
	sendTuyaCommand(DP_ID_SPEED, DP_TYPE_ENUM, speed.intValue(), 8)
}

def push(buttonNumber)		{
	logTrace("push: buttonNumber=${buttonNumber}")
	sendEvent(name: "pushed", value: buttonNumber, isStateChange: true)
	switch(buttonNumber) {
		case 1:
			open()
			break
		case 2:
			close()
			break
		case 3:
			stopPositionChange()
			break
		case 4:
			stepOpen()
			break
		case 5:
			stepClose()
			break
		default:
			throw new Exception("Unsupported buttonNumber \"${buttonNumber}\"")
	}
}

//
// Helpers
//

private sendTuyaCommand(int dp, int dpType, int fnCmd, int fnCmdLength) {
	atomicState.waitingForResponseSinceMillis = now()
	checkForResponse()
    
	def dpHex = zigbee.convertToHexString(dp, 2)
	def dpTypeHex = zigbee.convertToHexString(dpType, 2)
	def fnCmdHex = zigbee.convertToHexString(fnCmd, fnCmdLength)
	logTrace("sendTuyaCommand: dp=0x${dpHex}, dpType=0x${dpTypeHex}, fnCmd=0x${fnCmdHex}, fnCmdLength=${fnCmdLength}")
	def message = (randomPacketId().toString()
				   + dpHex
				   + dpTypeHex
				   + zigbee.convertToHexString((fnCmdLength / 2) as int, 4)
				   + fnCmdHex)
	logTrace("sendTuyaCommand: message=${message}")
	zigbee.command(CLUSTER_TUYA, ZIGBEE_COMMAND_SET_DATA, message)
}

private randomPacketId() {
	return zigbee.convertToHexString(new Random().nextInt(65536), 4)
}

// Must be non-private to use runInMillis
def checkForResponse() {
	logTrace("checkForResponse: waitingForResponseSinceMillis=${atomicState.waitingForResponseSinceMillis}")
	if (atomicState.waitingForResponseSinceMillis == null) {
		return
	}
	def waitMillis = (CHECK_FOR_RESPONSE_INTERVAL_SECONDS * 1000
			- (now() - atomicState.waitingForResponseSinceMillis))
	logTrace("checkForResponse: waitMillis=${waitMillis}")
	if (waitMillis <= 0) {
		updatePresence(false)
	} else {
		runInMillis(waitMillis, checkForResponse)
	}
}

// Must be non-private to use runInMillis
def checkHeartbeat() {
	def waitMillis = (HEARTBEAT_INTERVAL_SECONDS * 1000
			- (now() - atomicState.lastHeardMillis))
	logTrace("checkHeartbeat: waitMillis=${waitMillis}")
	if (waitMillis <= 0) {
		updatePresence(false)
	} else {
		runInMillis(waitMillis, checkHeartbeat)
	}
}

private logDebug(text) {
	if (!enableDebugLog) {
		return
	}
	log.debug(text)
}

private logTrace(text) {
	if (!enableTraceLog) {
		return
	}
	log.trace(text)
}

private logUnexpectedMessage(text) {
	if (!enableUnexpectedMessageLog) {
		return
	}
	log.warn(text)
}
2 Likes

Got it thanks so much, just need to wait for China delivery to the UK now.

Was actually cheaper here - Zemismart Tuya Zigbee Electric Curtain Robot Rechargeable Motor For Roman Rod I Type U Type Tracks Alexa Google Home Control

2 Likes