MQTT Tuya WiFi Fan/Light Driver for Local Control

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.