Hi all, I have this device installed in my home QIACHIP Upgrade Universal WIFI Ceiling Fan and I wanted to get local control over it using MQTT. Of course its a Tuya device. So, I have a NAS from QNAP which I run Container Station and created a MQTT server and used MQTT Client for local Tuya devices by Volker76 on github here. Follow the instructions there on setup. Here's the YML configuration file I used.
YML Code:
services:
tuya-mqtt:
image: volkerhaensel/tuya_mqtt.net:latest
container_name: tuya-mqtt
networks:
macvlan_net:
ipv4_address: YOUR.IP.ADDRESS.HERE # Assign a unique LAN IP for Tuya-MQTT (e.g., 192.168.1.230)
volumes:
- tuya.net_config:/app/DataDir # DataDir is case-sensitive
restart: unless-stopped
expose:
- "80/tcp" # Web UI → http://YOUR.IP.ADDRESS.HERE
- "6666/udp" # Tuya discovery (unencrypted)
- "6667/udp" # Tuya discovery (encrypted)
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:80 || exit 1"]
interval: 60s
timeout: 5s
retries: 5
start_period: 20s
mosquitto:
image: eclipse-mosquitto:2
container_name: tuya-mosquitto
networks:
macvlan_net:
ipv4_address: YOUR.IP.ADDRESS.HERE # Assign a DIFFERENT unique LAN IP for Mosquitto (e.g., 192.168.1.232)
expose:
- "1883/tcp" # Clients connect → tcp://YOUR.IP.ADDRESS.HERE:1883
volumes:
- ./mosquitto:/mosquitto # Place mosquitto.conf in ./mosquitto/config/
restart: unless-stopped
networks:
macvlan_net:
driver: macvlan
driver_opts:
parent: br0 # Change if your LAN interface is different
ipam:
config:
- subnet: 192.168.1.0/24 # Match your LAN subnet (change if needed)
gateway: 192.168.1.1 # Change to your router’s gateway IP
volumes:
tuya.net_config:
I entered into the Tuya-MQTT, set that up as per github page. I identified and connected to the device. Of note, you may need a Tuya IOT login for this, again, see the github page for details.
After the fact I created this Driver using our AI overlord's help. Installed the driver into Hubitat, added a Virtual Device using the driver and coifgured the device under the device's preferences. It will ask for your mqtt Mosquitto server IP address (not the MQTT-Tuya IP address) and the Device ID, IP or Name.
You initialize and then you will then be able to control your Tuya device.
Driver code:
/**
* Tuya MQTT Fan+Light (Local)
* ------------------------------------------------------------
* Local Hubitat driver using MQTT. Supports:
* - Fan power (DP1) -> capability "Switch"
* - Fan speed (DP3: 1/2/3) -> "FanControl" (low/medium/high)
* - Light (DP9) as a child "Generic Component Switch"
* - Presence + lastSeen (based on inbound MQTT updates)
* - Topic styles:
* DP : <base>/<identifier>/DP1 , DP3 , DP9
* command: <base>/<identifier>/DP1/command (payload: true/false or 1/2/3)
* dps : <base>/<identifier>/dps/1 , /3 , /9
* command: <base>/<identifier>/command/1 (payload: true/false or "1"/"2"/"3")
* - Identifier Mode:
* Auto : probes both ID/Name and IP roots; locks onto whichever emits state
* ID/Name : uses provided Device ID/Name
* IP : uses provided Device IP
*
* Race handling improvements:
* - Ensure power-on first, delay initial DP3 publish by ~500ms
* - Quote DP3 payloads even for DP-style topics (better bridge compatibility)
* - Stabilization window (~5s) that reasserts medium/high if device reports low
*
* License: MIT
* Author: you
*/
import groovy.transform.Field
@Field static final List<String> SPEED_ORDER = ["off","low","medium","high"]
metadata {
definition(name: "Tuya MQTT Fan+Light (Local)", namespace: "public", author: "you") {
capability "Initialize"
capability "Switch" // DP1
capability "FanControl" // DP3 (1/2/3 -> low/medium/high)
capability "Refresh"
capability "PresenceSensor"
attribute "supportedFanSpeeds", "JSON_OBJECT"
attribute "speed", "STRING"
attribute "lastSeen", "STRING"
attribute "mqttStatus", "STRING"
attribute "activeRoot", "STRING" // resolved <base>/<identifier> used for pub/sub
}
preferences {
// MQTT
input name: "brokerHost", type: "text", title: "MQTT Broker Host", defaultValue: "192.168.1.10", required: true
input name: "brokerPort", type: "text", title: "MQTT Broker Port (e.g., 1883)", defaultValue: "1883", required: true
input name: "username", type: "text", title: "MQTT Username (optional)"
input name: "password", type: "password", title: "MQTT Password (optional)"
// Topics
input name: "baseTopic", type: "text", title: "Base topic (blank = none, e.g., 'tuya')", defaultValue: ""
input name: "topicStyle", type: "enum", title: "Topic style", options: ["DP","dps"], defaultValue: "DP"
input name: "commandSegment", type: "text", title: "Command segment", defaultValue: "command"
// Identifier strategy
input name: "identifierMode", type: "enum", title: "Identifier Mode",
options: ["Auto","ID/Name","IP"], defaultValue: "Auto"
input name: "deviceId", type: "text", title: "Device ID or Name (as seen in MQTT)", required: false
input name: "deviceIp", type: "text", title: "Device IP (optional, e.g., 192.168.1.50)", required: false
// Behavior
input name: "presenceTimeoutSec", type: "number", title: "Presence timeout (seconds)", defaultValue: 120
input name: "resendDelaySec", type: "number", title: "Re-assert speed delay (seconds)", defaultValue: 1
input name: "createLightChild", type: "bool", title: "Expose DP9 as child light", defaultValue: true
// Logging
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
}
/* ========================= Lifecycle ========================= */
def installed() { initialize() }
def updated() { initialize() }
def initialize() {
unschedule()
try { interfaces.mqtt.disconnect() } catch(e) {}
state.lastSeenEpoch = 0L
state.activeRoot = null
state.remove("pendingSpeedRaw")
state.remove("stabilizeUntil")
validatePrefs()
connectMqtt()
runIn(2, "subscribeTopics")
runIn(3, "publishSupportedSpeeds")
runIn(4, "ensureLightChild")
runEvery1Minute("presenceTick")
if (logEnable) log.debug "Initialized"
}
def refresh() {
if (logEnable) log.debug "Refresh (no-op; waiting for MQTT updates)"
}
/* ========================= MQTT ========================= */
private connectMqtt() {
def portInt = (brokerPort?.toString()?.replaceAll(",", "") ?: "1883") as Integer
def uri = "tcp://${brokerHost}:${portInt}"
def clientId = "hubitat-tuya-fan-" + device.id
if (logEnable) log.debug "Connecting MQTT ${uri} as ${clientId}"
interfaces.mqtt.connect(uri, clientId, username ?: null, password ?: null)
sendEvent(name: "mqttStatus", value: "connecting")
}
private String rootFor(String ident) {
def b = (baseTopic?.trim() ?: "")
return b ? "${b}/${ident}" : "${ident}"
}
private List<String> candidateIdentifiers() {
def ids = []
switch ((identifierMode ?: "Auto")) {
case "ID/Name":
if (deviceId?.trim()) ids << deviceId.trim()
break
case "IP":
if (deviceIp?.trim()) ids << deviceIp.trim()
break
default: // Auto
if (deviceId?.trim()) ids << deviceId.trim()
if (deviceIp?.trim()) ids << deviceIp.trim()
break
}
if (ids.isEmpty() && deviceId?.trim()) ids << deviceId.trim()
return ids.unique()
}
private String activeRoot() {
return (state.activeRoot ?: (candidateIdentifiers().with { it ? rootFor(it[0]) : null }))
}
private String tState(String root, int dp) {
(topicStyle == "DP") ? "${root}/DP${dp}" : "${root}/dps/${dp}"
}
private String tCmd(String root, int dp) {
(topicStyle == "DP") ? "${root}/DP${dp}/${commandSegment ?: 'command'}"
: "${root}/${commandSegment ?: 'command'}/${dp}"
}
def subscribeTopics() {
def roots = candidateIdentifiers().collect { rootFor(it) }
if (roots.isEmpty()) {
log.warn "No identifiers provided; set Device ID/Name or Device IP"
return
}
roots.each { r -> [1,3,9].each { dp -> interfaces.mqtt.subscribe(tState(r, dp)) } }
if (logEnable) log.debug "Subscribed DP topics under roots: ${roots}"
}
def mqttClientStatus(String status) {
sendEvent(name: "mqttStatus", value: status)
if (logEnable) log.debug "MQTT status: ${status}"
def s = status?.toLowerCase() ?: ""
if (s.contains("succeed")) {
presenceMark(true)
runIn(1, "subscribeTopics")
} else if (s.startsWith("error") || s.contains("lost")) {
presenceMark(false)
runIn(10, "connectMqtt")
}
}
def parse(String description) {
def msg = interfaces.mqtt.parseMessage(description)
if (!msg?.topic) return
def topic = msg.topic.toString()
def payload = (msg.payload == null) ? "" : msg.payload.toString().trim()
// Learn/lock the active root when first state arrives
def roots = candidateIdentifiers().collect { rootFor(it) }
def learned = roots.find { topic.startsWith("${it}/") }
if (!state.activeRoot && learned) {
state.activeRoot = learned
sendEvent(name: "activeRoot", value: state.activeRoot)
if (logEnable) log.debug "Active root detected: ${state.activeRoot}"
}
markSeen()
if (topic == tState(activeRoot(), 1)) {
def onNow = asBool(payload)
sendEvent(name: "switch", value: onNow ? "on" : "off")
if (!onNow) sendEvent(name: "speed", value: "off")
if (logEnable) log.debug "DP1 -> ${onNow}"
}
else if (topic == tState(activeRoot(), 3)) {
def raw = payload.replaceAll("\"","")
def name = (raw=="1") ? "low" : (raw=="2") ? "medium" : (raw=="3") ? "high" : "off"
// Stabilization window: if we want 2/3 and see a 1 (low), stomp it again.
def nowMs = now()
if (state.pendingSpeedRaw && state.pendingSpeedRaw in ["2","3"] && raw == "1") {
def until = (state.stabilizeUntil ?: 0L)
if (nowMs <= until) {
if (logEnable) log.debug "DP3 reported low during stabilization window; reasserting ${state.pendingSpeedRaw}"
publishDP3(state.pendingSpeedRaw as String)
return
}
}
sendEvent(name: "speed", value: name)
if (name != "off") sendEvent(name: "switch", value: "on")
if (logEnable) log.debug "DP3 -> ${name} (raw ${raw})"
// Clear stabilization when target is reached
if (state.pendingSpeedRaw && raw == state.pendingSpeedRaw) {
state.remove("pendingSpeedRaw")
state.remove("stabilizeUntil")
}
}
else if (topic == tState(activeRoot(), 9)) {
def onNow = asBool(payload)
childLight()?.sendEvent(name: "switch", value: onNow ? "on":"off")
if (logEnable) log.debug "DP9 -> ${onNow}"
}
}
/* ========================= Capabilities ========================= */
def on() { publishDP1(true) }
def off() { publishDP1(false); sendEvent(name: "speed", value: "off") }
private publishDP1(boolean val){
def root = activeRoot()
if (!root) {
log.warn "No active root to publish DP1"
return
}
interfaces.mqtt.publish(tCmd(root, 1), val ? "true" : "false")
if (logEnable) log.debug "Publish DP1 ${val} -> ${tCmd(root,1)}"
}
def setSpeed(String speed) {
speed = (speed ?: "off").toLowerCase()
if (logEnable) log.debug "setSpeed requested: ${speed}"
switch (speed) {
case "off":
off()
state.remove("pendingSpeedRaw")
state.remove("stabilizeUntil")
return
case "low":
case "medium":
case "high":
String v = (speed == "low") ? "1" : (speed == "medium") ? "2" : "3"
// 1) Ensure power on first (publish DP1 true), then delay a touch.
ensureFanPowerOn()
// 2) Schedule initial speed publish after 500 ms to avoid racing device's default-low
state.pendingSpeedRaw = v
state.stabilizeUntil = now() + 5000 // 5s stabilization window
runInMillis(500, 'publishInitialSpeed')
// 3) Also schedule one timed reassert
Integer d = (resendDelaySec ?: 1) as Integer
if (d < 1) d = 1
runIn(d, "reassertPendingSpeed")
sendEvent(name:"switch", value:"on")
sendEvent(name:"speed", value:speed)
return
default:
if (logEnable) log.debug "Unsupported speed '${speed}', defaulting to medium"
setSpeed("medium")
return
}
}
def cycleSpeed() {
def cur = (device.currentValue("speed") ?: "off").toLowerCase()
def idx = SPEED_ORDER.indexOf(cur); if (idx < 0) idx = 0
setSpeed(SPEED_ORDER[(idx + 1) % SPEED_ORDER.size()])
}
private ensureFanPowerOn() {
if ((device.currentValue("switch") ?: "off") != "on") {
publishDP1(true)
} else {
// Some bridges internally gate speed changes by a power edge; bump once anyway
publishDP1(true)
}
}
// helper invoked by runInMillis from setSpeed
def publishInitialSpeed() {
def v = state.pendingSpeedRaw
if (!v) return
publishDP3(v)
}
def reassertPendingSpeed() {
def v = state.pendingSpeedRaw
if (!v) return
if (logEnable) log.debug "Re-asserting DP3 ${v} after delay to override default-low"
publishDP3(v)
// keep pending during stabilization window; will clear when target is reported
}
private publishDP3(String v) {
def root = activeRoot()
if (!root) {
log.warn "No active root to publish DP3"
return
}
// Use QUOTED payload even for DP style for maximum bridge compatibility
def payload = "\"${v.replaceAll('\"','')}\""
interfaces.mqtt.publish(tCmd(root, 3), payload)
if (logEnable) log.debug "Publish DP3 ${payload} -> ${tCmd(root,3)}"
}
private publishSupportedSpeeds() {
sendEvent(name: "supportedFanSpeeds", value: '["off","low","medium","high"]', isStateChange: true)
}
/* ========================= Child light (DP9) ========================= */
private ensureLightChild() {
if (!createLightChild) return
if (!childLight()) {
def dni = "${device.deviceNetworkId}-light"
try {
def cd = addChildDevice("hubitat", "Generic Component Switch", dni,
[name: "${device.displayName} Light", label: "${device.displayName} Light", isComponent: true])
if (logEnable) log.debug "Created child light: ${cd?.deviceNetworkId}"
} catch (e) {
log.warn "Cannot create child light: ${e}"
}
}
}
private childLight() { getChildDevice("${device.deviceNetworkId}-light") }
def componentOn(cd) {
def root = activeRoot(); if (!root) return
interfaces.mqtt.publish(tCmd(root, 9), "true")
if (logEnable) log.debug "Publish DP9 true -> ${tCmd(root,9)}"
}
def componentOff(cd) {
def root = activeRoot(); if (!root) return
interfaces.mqtt.publish(tCmd(root, 9), "false")
if (logEnable) log.debug "Publish DP9 false -> ${tCmd(root,9)}"
}
def componentRefresh(cd) { if (logEnable) log.debug "componentRefresh (no-op)" }
/* ========================= Presence ========================= */
private markSeen() {
state.lastSeenEpoch = now()
sendEvent(name: "lastSeen", value: new Date(state.lastSeenEpoch).format("yyyy-MM-dd HH:mm:ss"))
presenceMark(true)
}
private presenceTick() {
def last = (state.lastSeenEpoch ?: 0L)
def age = (now() - last) / 1000L
presenceMark(age <= (presenceTimeoutSec ?: 120))
}
private presenceMark(boolean present) {
def want = present ? "present" : "not present"
if (device.currentValue("presence") != want) sendEvent(name: "presence", value: want)
}
/* ========================= Validation & helpers ========================= */
private validatePrefs() {
if (!brokerHost?.trim()) throw new IllegalArgumentException("Broker host is required")
if (!topicStyle) topicStyle = "DP"
if (!commandSegment) commandSegment = "command"
if ((identifierMode ?: "Auto") == "ID/Name" && !deviceId?.trim())
log.warn "Identifier Mode is ID/Name but Device ID/Name is blank"
if ((identifierMode ?: "Auto") == "IP" && !deviceIp?.trim())
log.warn "Identifier Mode is IP but Device IP is blank"
}
private static boolean asBool(def v) {
def s = (v==null ? "" : v.toString().trim().toLowerCase())
return (s in ["true","on","1","\"true\"","\"on\"","\"1\""])
}
Hope this helps someone out there. Please modify as need be.