Плагін Mitsubishi MelCloud
Про
Цей плагін інтегрує пристрої Mitsubishi Electronic з підтримкою MELCloud в Butler.
Інструкція з встановлення
Встановлення плагіна
Дотримуйтесь загальних інструкцій з встановлення до моменту, коли вставите код плагіна в текстову область. Не натискайте кнопку Зберегти ще.
Вам потрібно замінити наступні константи в коді плагіна:
- MEL_USERNAME - на ваше ім'я користувача для MELCloud
- MEL_PASSWORD - на ваш пароль для MELCloud
щоб це виглядало так, але з вашими значеннями:
local MEL_USERNAME = "sample@example.com"
local MEL_PASSWORD = "password"
Продовжуйте згідно загальних інструкцій з встановлення
Вихідний код
Вихідний код плагіна
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