local device = { model = "TLSR8258_AC_CONTROLLER", vendor = "Custom", description = "TuyaOS Mitsubishi AC serial controller (stateless)", fingerprint = {{model = "TS0601", manufacturer = "_TZ3000_fcfbx2rz"}}, } local modes = {"cool", "dry", "fan_only", "auto"} local fans = {"quiet", "low", "powerful", "max"} local directions = {"position_1", "position_2", "position_3", "position_4", "position_5"} local faults = { "communication", "eeprom", "test_run", "discharge_temperature", "low_pressure", "high_pressure", "refrigerant_overcharge", "drain_or_water_level", "water_leak", "power_supply", "fan", "compressor_or_ipm", "overheat_or_overload", "function_or_damper_setting", "temperature_sensor", "float_switch", "configuration", "other_or_unknown", } local schema = { [1] = {1, 1}, [2] = {2, 4}, [3] = {2, 4}, [4] = {4, 1}, [5] = {4, 1}, [6] = {5, 4}, [101] = {4, 1}, } local function decode(msg) if msg.is_attribute or msg.cluster ~= 0xEF00 then return nil end local command = msg.cmd_or_attr_id -- dataResponse, dataReport, activeStatusReport only. if command ~= 0x01 and command ~= 0x02 and command ~= 0x06 then return nil end local bytes = msg.payload if not bytes or #bytes < 2 then return nil end local result = {} local pos = 3 -- Skip Tuya sequence (two bytes). while pos <= #bytes do if pos + 3 > #bytes then return nil end local dp, datatype = bytes[pos], bytes[pos + 1] local length = bytes[pos + 2] * 256 + bytes[pos + 3] pos = pos + 4 if pos + length - 1 > #bytes then return nil end local expected = schema[dp] if expected and datatype == expected[1] and length == expected[2] then local value = 0 for i = pos, pos + length - 1 do value = value * 256 + bytes[i] end if dp == 1 and value <= 1 then result.state = value == 1 and "ON" or "OFF" elseif dp == 2 and value >= 16 and value <= 31 then result.occupied_cooling_setpoint = value elseif dp == 3 and value ~= 255 then result.local_temperature = value elseif dp == 4 and modes[value + 1] then result.ac_mode = modes[value + 1] elseif dp == 5 and fans[value + 1] then result.fan_mode = fans[value + 1] elseif dp == 101 and directions[value + 1] then result.air_direction = directions[value + 1] elseif dp == 6 then result.fault_code = value -- DP6 bitmap, not the original AC error code. result.fault = value ~= 0 local names = {} for bit, name in ipairs(faults) do if math.floor(value / 2 ^ (bit - 1)) % 2 == 1 then names[#names + 1] = name end end if value >= 0x40000 then names[#names + 1] = "unknown_bits" end result.fault_description = #names > 0 and table.concat(names, ", ") or "none" end end pos = pos + length end -- Deliberately no per-device or shared state cache. OFF takes precedence -- within this frame only. A standalone DP4 report directly updates mode; -- a standalone DP1=ON cannot tell us which mode the firmware restored. if result.state == "OFF" then result.system_mode = "off" elseif result.ac_mode then result.system_mode = result.ac_mode end return next(result) and result or nil end local function send(dp, datatype, data, endpoint) -- Same sequence placeholder as the gateway's built-in Tuya DP encoder. local payload = {0, 0, dp, datatype, 0, #data} for _, byte in ipairs(data) do payload[#payload + 1] = byte end return {cluster = 0xEF00, endpoint = endpoint or 1, command = 0x00, payload = payload} end local function enumValue(values, value) for i, name in ipairs(values) do if name == value then return i - 1 end end error("Unsupported enum value: " .. tostring(value)) end device.fromZigbee = {{cluster = 0xEF00, convert = decode}} -- The gateway calls setters separately: no whole-message validation or -- optimistic derived state is possible through this Lua interface. Do not -- combine conflicting state/system_mode fields in one /set message. device.toZigbee = { {key = "state", convert = function(value, endpoint) assert(value == "ON" or value == "OFF", "state must be ON or OFF") return send(1, 1, {value == "ON" and 1 or 0}, endpoint) end}, {key = "system_mode", convert = function(value, endpoint) if value == "off" then return send(1, 1, {0}, endpoint) end -- Firmware DP4 selects the mode AND turns the AC on, even from OFF. return send(4, 4, {enumValue(modes, value)}, endpoint) end}, {key = "occupied_cooling_setpoint", convert = function(value, endpoint) assert(type(value) == "number" and value >= 16 and value <= 31 and value == math.floor(value), "Temperature must be an integer from 16 to 31 C") return send(2, 2, {0, 0, 0, value}, endpoint) end}, {key = "fan_mode", convert = function(value, endpoint) return send(5, 4, {enumValue(fans, value)}, endpoint) end}, {key = "air_direction", convert = function(value, endpoint) return send(101, 4, {enumValue(directions, value)}, endpoint) end}, } device.exposes = { -- Use the gateway's switch composite so HA exposes a controllable power -- switch, rather than treating a top-level binary expose as a sensor. {type = "switch", features = { {type = "binary", name = "state", property = "state", access = 3, value_on = "ON", value_off = "OFF", description = "Power; ON restores the firmware operating mode"}, }}, {type = "climate", features = { {type = "numeric", name = "occupied_cooling_setpoint", property = "occupied_cooling_setpoint", access = 3, min = 16, max = 31, value_step = 1, unit = "°C"}, {type = "numeric", name = "local_temperature", property = "local_temperature", access = 1, unit = "°C"}, {type = "enum", name = "system_mode", property = "system_mode", access = 3, values = {"off", "cool", "dry", "fan_only", "auto"}}, {type = "enum", name = "fan_mode", property = "fan_mode", access = 3, values = fans}, }}, {type = "enum", name = "air_direction", property = "air_direction", access = 3, values = directions, description = "Five fixed vane positions; swing is not supported"}, {type = "enum", name = "ac_mode", property = "ac_mode", access = 1, values = modes, description = "Reported DP4 mode; OFF takes precedence only within the same report"}, {type = "binary", name = "fault", property = "fault", access = 1, value_on = "true", value_off = "false", category = "diagnostic"}, {type = "numeric", name = "fault_code", property = "fault_code", access = 1, description = "DP6 fault bitmap, not the original AC error code", category = "diagnostic"}, {type = "text", name = "fault_description", property = "fault_description", access = 1, category = "diagnostic"}, } -- No configure, magic packet, polling or configuration writes: firmware reports -- on join, periodically, on AC state changes and after accepted commands. return device