Skip to main content

Mitsubishi MelCloud Plugin

About

This plugin integrates Mitsubishi Electronic's MELCloud enabled devices into Butler.

Installation guide

Plugin installation

Follow generic installation instructions until you paste, plugin code in text area. Do not press Save button yet.

You need to replace the following constants in the plugin code:

  • MEL_USERNAME - with your username for MELCloud
  • MEL_PASSWORD - with your password for MELCloud

so it looks like this but with your values:

local MEL_USERNAME = "sample@example.com"
local MEL_PASSWORD = "password"

Continue on with generic installation instructions

Source code

Plugin source code
local os = require("os")
local json = require("json")
local timer = require("timer")
local http = require("http")
local https = require("https")
local querystring = require("querystring")
local plugin = {}

--[[
Configuration constants:
]]
local MEL_USERNAME = "sample@example.com"
local MEL_PASSWORD = "password"

--[[============================================]]
--[[ ]]
--[[ NO NEED TO CHANGE ANYTHING BELOW THIS LINE ]]
--[[ ]]
--[[============================================]]


-- MEL Cloud API constants
local MEL_CLOUD_HOST = "https://app.melcloud.com/"
local MEL_CLOUD_API_URL = MEL_CLOUD_HOST .. "Mitsubishi.Wifi.Client/"
local MEL_CLOUD_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
local MEL_CLOUD_POLLING_INTERVAL = 360 -- 6 minutes
local MEL_OPERATING_MODES = {
Heat = 1,
Dry = 2,
Cool = 3,
Fan = 7,
Auto = 8
}

local MEL_ATA_FLAGS_POWER = 1
local MEL_ATA_FLAGS_MODE = 2
local MEL_ATA_FLAGS_SETPOINT = 4
local MEL_ATA_FLAGS_FAN_SPEED = 8
local MEL_ATA_FLAGS_VANE_VERTICAL = 16
local MEL_ATA_FLAGS_VANE_HORIZONTAL = 256

local MODE_TO_STATE = {
Off = "Idle",
Heat = "Heating",
Dry = "Idle",
Cool = "Cooling",
Fan = "FanOnly",
Auto = "Idle"
}
local MODE_TO_SETPOINT = {
Off = nil,
Heat = "Heat",
Dry = "DryAir",
Cool = "Cool",
Fan = nil,
Auto = "Auto"
}



-- Plugin definition
plugin.name = "MEL Cloud"
plugin.version = { 0, 0, 3 }
plugin.settings = {
context_key = {
value = nil,
default = "",
type = "string",
description = {
ru = "Ключ контекста",
en = "Context key",
ua = "Ключ контексту"
}
},
context_key_expiry = {
value = nil,
default = "never",
type = "string",
description = {
ru = "Время истечения ключа контекста",
en = "Context key expiry time",
ua = "Час закінчення ключа контексту"
}
}

}

plugin.provided_interfaces = {
MelCloudDevice = {
actions = {
},
parameters = {
identity = {
type = "integer",
value = "MEL Cloud Device ID"
},
device_name = {
type = "string",
value = "MEL Cloud Device Name"
},
building_id = {
type = "integer",
value = 0
},
building = {
type = "string",
value = "MEL Cloud Building Name"
},
floor_id = {
type = "integer",
value = 0
},
floor = {
type = "string",
value = "MEL Cloud Floor Name"
},
area_id = {
type = "integer",
value = 0
},
area = {
type = "string",
value = "MEL Cloud Area Name"
},
version = {
type = "string",
value = "1"
},
manufacturer = {
type = "string",
value = "Mitsubishi Electric"
},
}
}
}

plugin.overridden_interfaces = {
ThermostatMode = {
actions = {
setMode = {}
}
},
ThermostatSetpoint = {
actions = {
setSetpoint = {}
}
},
}

plugin.devices = {
HVAC = {
type = "DevThermostat",
role = "ClimateThermostat",
icon = "ch-lightbulb",
interfaces = { "MelCloudDevice", "ThermostatMode", "ThermostatSetpoint", "ThermostatOperationState", "SensorMultilevel" },
parameters = {
--[[ MelCloud Device interface parameters ]] --
identity = {
type = "integer",
value = 1,
role = "edit",
locale = {
en = "MEL Cloud Device ID",
ru = "ID устройства MEL Cloud",
ua = "ID пристрою MEL Cloud"
}
},
device_name = {
type = "string",
value = "HVAC",
role = "show",
locale = {
en = "MEL Cloud Device Name",
ru = "Имя устройства MEL Cloud",
ua = "Ім'я пристрою MEL Cloud"
}
},
building_id = {
type = "integer",
value = 0
},
building = {
type = "string",
value = "Building",
role = "show",
locale = {
en = "Building",
ru = "Здание",
ua = "Будівля"
}
},
floor_id = {
type = "integer",
value = 0
},
floor = {
type = "string",
value = "Floor",
role = "show",
locale = {
en = "Floor",
ru = "Этаж",
ua = "Поверх"
}
},
area_id = {
type = "integer",
value = 0
},
area = {
type = "string",
value = "Area",
role = "show",
locale = {
en = "Area",
ru = "Площадь",
ua = "Площа"
}
},
version = {
type = "string",
value = "1"
},
manufacturer = {
type = "string",
value = "Mitsubishi Electric",
role = "show",
locale = {
en = "Manufacturer",
ru = "Производитель",
ua = "Виробник"
}
},

--[[ Thermostat Mode interface parameters ]] --
availableModes = {
type = "json",
value = { "Off", "Auto", "Dry", "Heat", "Fan", "Cool" }
},
currentMode = {
type = "string",
value = "Off",
},
autoModeIsActive = {
type = "boolean",
value = false,
},

--[[ Thermostat Setpoint interface parameters ]] --
setpointCapabilities = {
type = "json",
value = {
Cool = {
minimum = 16,
maximum = 30,
precision = 0.5,
scale = 0,
size = 4 -- Doesn't matter
},
Heat = {
minimum = 16,
maximum = 30,
precision = 0.5,
scale = 0,
size = 4 -- Doesn't matter
},
Auto = {
minimum = 16,
maximum = 30,
precision = 0.5,
scale = 0,
size = 4 -- Doesn't matter
},
DryAir = {
minimum = 16,
maximum = 30,
precision = 0.5,
scale = 0,
size = 4 -- Doesn't matter
},
}
},
currentSetpoints = {
type = "json",
value = {
Cool = 24,
Heat = 24,
Auto = 24,
DryAir = 24,
}
},
step = {
type = "double",
value = 0.5
},

--[[ Thermostat Operation State interface parameters ]] --
currentOperationState = {
type = "string",
value = "Idle"
},

--[[ Sensor Multilevel interface parameters ]] --
Value = {
type = "double",
value = 0
},
sensorType = {
type = "string",
value = "AirTemperature"
},
valueUnit = {
type = "string",
value = "°C"
},
valueScale = {
type = "string",
value = "Celcius",
}
}
}
}

-- Saved device discovery result
plugin.previous_discovery_result = {}

-- Registered devices list
plugin.registered_devices_list = {}

-- Saved device data
plugin.devices_data = {}

-- Polling timers
plugin.timers = {}

--[[ API wrapper functions ]]

--- @brief Generic MEL Cloud HTTP wrapper
--- @param method string - request method
--- @param url string - reqquest url
--- @param body ?string - request body
--- @param content_type ?string - request content type
--- @param with_auth ?boolean - add authorization header
--- @param callback ?function - callback function
---
--- @return boolean
function plugin:mel_cloud_generic_request(method, url, body, content_type, with_auth, callback)
local opts = http.parseUrl(url)
opts.method = method
opts.headers = {
["User-Agent"] = MEL_CLOUD_USER_AGENT,
["Accept"] = "application/json, text/javascript, */*l q=0.01",
["Accept-Language"] = "en-US,en;q=0.5",
["Accept-Encoding"] = "gzip, deflate, br",
["X-Requested-With"] = "XMLHttpRequest",
["Referer"] = MEL_CLOUD_HOST,
["Origin"] = MEL_CLOUD_HOST,
["Cookie"] = "policyaccepted=true"
}

if with_auth then
opts.headers["X-Mitscontextkey"] = self.settings.context_key.value
end
if content_type then
opts.headers["Content-Type"] = content_type
if body then
opts.headers["Content-Length"] = #body
else
opts.headers["Content-Length"] = 0
end
end

local request = https.request(opts, function(res)
local data = ""
res:on("data", function(chunk)
data = data .. chunk
end)
res:on("end", function()
if callback then
callback(res, data)
end
end)
end)
print("request created")
if body then
request:write(body)
print("body written")
end
request:done()
print("request done")

return true
end

--- @brief Login to MEL Cloud
--- @param username string
--- @param password string
---
--- @return boolean - true if request sent successfully
function plugin:mel_cloud_http_login(username, password)
local req_body = json.stringify({
Email = username,
Password = password,
Language = 0,
AppVersion = "1.32.0.0",
Persist = true,
CaptchaResponse = nil,
})

self:mel_cloud_generic_request(
"POST",
MEL_CLOUD_API_URL .. "Login/ClientLogin",
req_body,
"application/json",
false,
function(res, body)
if res.statusCode ~= 200 then
p("Login error: " .. res)
return false
end

body = json.parse(body)

if body == nil then
p("Error parsing JSON response")
return false
end

if body.ErrorId then -- Login error
if body.ErrorMessage then
p("Login error ", body.ErrorId, body.ErrorMessage)
else
p("Login error ", body.ErrorId)
end

return false
else
self.settings.context_key.value = body.LoginData.ContextKey
self.settings.context_key_expiry.value = body.LoginData.Expiry
self:save_settings(self.settings)
print("Login successful. Expiry at " .. self.settings.context_key_expiry.value)
end
end
)


return true
end

--- @brief Get devices list
--- @param callback ?function - callback function
---
--- @return boolean
function plugin:mel_cloud_get_devices_list(callback)
if self.settings.context_key.value == nil then
return nil
end

self:mel_cloud_generic_request("GET", MEL_CLOUD_API_URL .. "User/ListDevices", nil, nil, true, function(res, body)
-- TODO: Implement automatic reauth
p("res", res)
if res.statusCode == 200 and callback then
callback(json.parse(body))
end
end)

return true
end

--- @brief Get specific device data
--- @param device_id integer - device ID
--- @param building_id integer - parent building ID
--- @param callback ?function - callback function
---
--- @return boolean
function plugin:mel_cloud_get_device(device_id, building_id, callback)
local query = querystring.stringify({
id = device_id,
buildingID = building_id
})

self:mel_cloud_generic_request(
"GET",
MEL_CLOUD_API_URL .. "Device/Get?" .. query,
nil,
nil,
true,
function(res, data)
if callback then
callback(json.parse(data))
end
end)
return true
end

--- @brief Set device state
--- @param identity integer - device ID
--- @param mode ?string - operating mode
--- @param setpoint ?number - target temperature
--- @param fan_speed ?number - fan speed
--- @param callback ?function - callback function
function plugin:mel_cloud_set_device_state(identity, mode, setpoint, fan_speed, callback)
local flags = 0
local body = {}
local device_data = self.devices_data[identity]
local device_id = self.registered_devices_list[identity]
if device_data then
for k, v in pairs(device_data) do
body[k] = v
end

if mode then
if mode == "Off" then
if body.Power then
flags = flags + MEL_ATA_FLAGS_POWER
body.Power = false
end
else
flags = flags + MEL_ATA_FLAGS_MODE
body.OperationMode = MEL_OPERATING_MODES[mode]
if not body.Power then
flags = flags + MEL_ATA_FLAGS_POWER
body.Power = true
end
end
end

if setpoint then
flags = flags + MEL_ATA_FLAGS_SETPOINT
body.SetTemperature = setpoint
end

body.EffectiveFlags = flags
p(body)

self:mel_cloud_generic_request(
"POST",
MEL_CLOUD_API_URL .. "Device/SetAta",
json.stringify(body),
"application/json",
true,
function(res, data)
local building_id = self:db_get_param(device_id, "building_id")
print("callback set")
self:mel_cloud_get_device(identity, building_id, function(data)
print("callback get")
if callback then
callback(data)
end
end)
end
)
end
end

-- Plugin initialization
function plugin:init(reason)
print("MEL Cloud plugin init")
local settings = self:load_settings()
if settings.context_key == nil or settings.context_key == "" then
p("No auth data")
self:mel_cloud_http_login(MEL_USERNAME, MEL_PASSWORD)
else
p("Auth data found")
self.settings.context_key.value = settings.context_key
end
end

-- Plugin registered
function plugin:register(uuid)
print("MEL Cloud plugin registered")
end

-- Plugin unregistered
function plugin:unregister(topic, payload)
print("MEL Cloud plugin unregistered")
end

-- Discover devices
function plugin:discover_devices(topic, payload)
print("MEL Cloud discover devices")


self:mel_cloud_get_devices_list(function(data)
local discovery_result = {
}
local devices = {}
if data then
print(type(data))

for _, building in pairs(data) do
for _, device in pairs(building.Structure.Devices) do
device.BuildingName = building.Name
table.insert(devices, device)
end
end
end

for _, device in pairs(devices) do
table.insert(discovery_result, {
identity = device.DeviceID,
info = device.DeviceName .. " (" .. device.BuildingName .. ")",
type = "HVAC",
icon = "ch-thermostat",
params = {
model = "HVAC",
manufacturer = "Mitsubishi Electric",
}
})
end

self.previous_discovery_result = devices


self:reply(topic, payload.message_id, 200, true, {
devices = discovery_result
})
end)
end

--- @brief Create params table for new device, from whatever initial data
--- @param device table Device table (from plugin.devices)
--- @param identity integer Device identity (Google's device ID in this case)
local function get_params_name_value(device, identity)
local params = {}
for k, v in pairs(device.parameters) do
params[k] = v.value
end
if identity then
params.identity = identity
end
return params
end

--- @brief Merge two params tables
--- @param base_params table Base params table
--- @param overlay_params table Overlay params table
local function merge_params(base_params, overlay_params)
for k, v in pairs(overlay_params) do
base_params[k] = v
end
return base_params
end

--- @brief Creates new device
--- @param topic string - topic
--- @param payload table - payload
function plugin:device_create(topic, payload)
print("MEL Cloud create device")
local message_id = payload.message_id
local name = payload.body.params.name
local dev_type = payload.body.params.type or ""
local identity = payload.body.params.identity
local room_id = payload.body.params.room_id or 0
local params = {
setpointCapabilities = {
Cool = {},
Heat = {},
Auto = {},
DryAir = {},
},
currentSetpoints = {
Cool = 24,
Heat = 24,
Auto = 24,
DryAir = 24,
},
}


local new_device = plugin.devices and plugin.devices[dev_type]
if not new_device then
self:reply(topic, message_id, 400, true, {
error = "Unknown device type"
})
return
end

local discovery_devices_data = nil

if self.previous_discovery_result then
for _, device in pairs(self.previous_discovery_result) do
if device.DeviceID == identity then
discovery_devices_data = device
break
end
end
end

self:mel_cloud_get_device(discovery_devices_data.DeviceID, discovery_devices_data.BuildingID, function(data)
if data then
params.floor_id = discovery_devices_data.FloorId or 0
params.floor = discovery_devices_data.FloorName or ""
params.area_id = discovery_devices_data.AreaId or 0
params.area = discovery_devices_data.AreaName or ""
params.building_id = discovery_devices_data.BuildingID or 0
params.building = discovery_devices_data.BuildingName or ""

params.setpointCapabilities.Cool = {
minimum = discovery_devices_data.Device.MinTempCoolDry,
maximum = discovery_devices_data.Device.MaxTempCoolDry,
precision = discovery_devices_data.Device.TemperatureIncrement,
scale = 0,
size = 4
}
params.setpointCapabilities.DryAir = {
minimum = discovery_devices_data.Device.MinTempCoolDry,
maximum = discovery_devices_data.Device.MaxTempCoolDry,
precision = discovery_devices_data.Device.TemperatureIncrement,
scale = 0,
size = 4
}
params.setpointCapabilities.Heat = {
minimum = discovery_devices_data.Device.MinTempHeat,
maximum = discovery_devices_data.Device.MaxTempHeat,
precision = discovery_devices_data.Device.TemperatureIncrement,
scale = 0,
size = 4
}
params.setpointCapabilities.Auto = {
minimum = discovery_devices_data.Device.MinTempAutomatic,
maximum = discovery_devices_data.Device.MaxTempAutomatic,
precision = discovery_devices_data.Device.TemperatureIncrement,
scale = 0,
size = 4
}

if not data.Power then
params.currentMode = "Off"
params.currentOperationState = "Idle"
else
for mode, value in pairs(MEL_OPERATING_MODES) do
if value == data.OperationMode then
params.currentMode = mode
params.currentOperatingState = MODE_TO_STATE[mode]
end
end
end

params.currentSetpoints.Cool = data.SetTemperature or 24
params.currentSetpoints.Heat = data.SetTemperature or 24
params.currentSetpoints.Auto = data.SetTemperature or 24
params.currentSetpoints.DryAir = data.SetTemperature or 24

params.Value = data.RoomTemperature or 0

params = merge_params(get_params_name_value(new_device, identity), params)

self:pub {
topic = "internal/plugins_hub/" .. self.uuid .. "/devices/create",
payload = json.stringify({
version = "2",
method = "POST",
message_id = self.mqtt:pending_message_add(new_device),
token = self.token,
body = {
plugin_type = dev_type,
name = name,
type = new_device.type,
role = new_device.role,
alive = true,
hidden = false,
room_id = room_id,
interfaces = new_device.interfaces,
icon = new_device.icon,
params = params
}
}),
qos = 2
}

self:reply(topic, message_id, 200, true)
end
end)
end

--- @brief Polling function
--- @param device_id integer Device ID
function plugin:poll_device(device_id)
print("Polling device " .. device_id)
local identity = self:db_get_param(device_id, "identity")
local building_id = self:db_get_param(device_id, "building_id")
self:mel_cloud_get_device(identity, building_id, function(data)
if data then
self.devices_data[identity] = data
p("new device data", data)
local mode = nil

for k, v in pairs(MEL_OPERATING_MODES) do
if v == data.OperationMode then
mode = k
end
end

local currentMode = data.Power and mode or "Off"
local currentOperatingState = MODE_TO_STATE[currentMode]
local currentSetpoint = data.SetTemperature or 24
local roomTemperature = data.RoomTemperature or 0
print("Mode:", currentMode, "State:", currentOperatingState)
print("Setpoint:", currentSetpoint, "Room temperature:", roomTemperature)

self:custom_update_params(device_id, {
Value = roomTemperature,
currentMode = currentMode,
currentOperatingState = currentOperatingState,
currentSetpoints = {
Cool = currentSetpoint,
Heat = currentSetpoint,
Auto = currentSetpoint,
DryAir = currentSetpoint
},
})
end
if self.timers[device_id] then
timer.clearTimer(self.timers[device_id])
end
self.timers[device_id] = timer.setTimeout(MEL_CLOUD_POLLING_INTERVAL * 1000, function()
self:poll_device(device_id)
end)
end)
end

--- @brief Update device paramaeters in batches
--- @param device_id integer Device ID
--- @param params table Parameters to update (k = param name, v = new param value)
function plugin:custom_update_params(device_id, params)
self.mqtt:publish {
topic = "internal/plugins_hub/" .. self.uuid .. "/devices/parameters_set",
payload = json.stringify {
message_id = self.mqtt:pending_message_add {
uuid = self.uuid
},
token = self.token,
version = "2",
method = "POST",
body = {
device_id = device_id,
params = params
}
},
qos = 2,
retain = false
}
end

--- @brief Device created by hub
---
--- @param topic string - topic
--- @param payload table - payload
function plugin:device_created(_, payload)
print("MEL Cloud device created")

if not payload.body.success then
print("Device creation failed:", payload.status)
return
end

self:custom_update_params(payload.body.device_id, payload.body.params)

self.registered_devices_list[payload.body.params.identity] = payload.body.device_id

self:db_add_device(payload.body.device_id, payload.body.plugin_type)
self:db_add_default_params(payload.body.device_id, payload.body.interfaces, payload.body.params)

self:poll_device(payload.body.device_id)
end

--- @brief Device registered
--- @param device_id integer Device ID
--- @param name string Device name
--- @param params table Device parameters
function plugin:device_registered(device_id, name, params)
print("MEL Cloud device registered")
self.registered_devices_list[params.identity] = device_id
self:notify("Registered device " .. device_id .. "(" .. name .. ")")
self:poll_device(device_id)
end

--- @brief Device removed
--- @param topic string - topic
--- @param payload table - payload
function plugin:device_removed(topic, payload)
print("MEL Cloud device removed")
if payload.body.success ~= true then
print("Device removal failed:", payload.status)
return
end

local device_id = payload.body.device_id
local identity = self:db_get_param(device_id, "identity")

self.registered_devices_list[identity] = nil

-- removing device from db
self:db_delete_params(device_id)
self:db_remove_device(device_id)
end

--- @brief Device action handler
--- @param topic string - topic
--- @param payload table - payload
function plugin:action(topic, payload)
print("MEL Cloud action handler")
local device_id = tonumber(self:get_topic_segment(topic, 5))

if device_id == nil then
print("Device ID not found")
return
end

local identity = self:db_get_param(device_id, "identity")

if payload.body.interface == "ThermostatMode" then
if payload.body.method == "setMode" and payload.body.params then
local mode = payload.body.params[1] or payload.body.params["mode"] or nil
if mode then
self:mel_cloud_set_device_state(identity, mode, nil, nil, function(data)
self.devices_data[identity] = data
end)
end
end
elseif payload.body.interface == "ThermostatSetpoint" then
if payload.body.method == "setSetpoint" and payload.body.params then
local setpoint = payload.body.params[1] or payload.body.params["setpoint"] or nil
local mode = payload.body.params[2] or payload.body.params["setpointType"] or nil

if setpoint and mode then
local setpoint_number = tonumber(setpoint)
self:mel_cloud_set_device_state(identity, nil, setpoint_number, nil, function(data)
self.devices_data[identity] = data
end)
end
end
end
end

return plugin