diff --git a/Config.lua b/Config.lua new file mode 100644 index 0000000..67300df --- /dev/null +++ b/Config.lua @@ -0,0 +1,69 @@ +--[[ +Configuration handler +@author ikubicki +]] +class 'Config' + +function Config:new(app) + self.app = app + self:init() + return self +end + +function Config:getUsername() + if self.username and self.username:len() > 3 then + return self.username + end + return nil +end + +function Config:getPassword() + return self.password +end + +function Config:getDeviceID() + return self.device_id +end + +function Config:setDeviceID(device_id) + self.device_id = device_id +end + +function Config:getInterval() + return tonumber(self.interval) * 1000 +end + +--[[ +This function takes variables and sets as global variables if those are not set already. +This way, adding other devices might be optional and leaves option for users, +what they want to add into HC3 virtual devices. +]] +function Config:init() + self.username = self.app:getVariable('Username') + self.password = self.app:getVariable('Password') + self.device_id = self.app:getVariable('DeviceID') + self.interval = self.app:getVariable('Interval') + + local storedUsername = Globals:get('salus_username', '') + local storedPassword = Globals:get('salus_password', '') + + -- handling username + if string.len(self.username) < 4 and string.len(storedUsername) > 3 then + self.app:setVariable("Username", storedUsername) + self.username = storedUsername + elseif (storedUsername == '' and self.username) then + Globals:set('salus_username', self.username) + end + -- handling password + if string.len(self.password) < 4 and string.len(storedPassword) > 3 then + self.app:setVariable("Password", storedPassword) + self.password = storedPassword + elseif (storedPassword == '' and self.password) then + Globals:set('salus_password', self.password) + end + -- handling interval + if not self.interval or self.interval == "" then + self.app:setVariable("Interval", 30) + self.interval = 30 + end +end \ No newline at end of file diff --git a/Globals.lua b/Globals.lua new file mode 100644 index 0000000..6a6267b --- /dev/null +++ b/Globals.lua @@ -0,0 +1,36 @@ +--[[ +Global variables handler +@author ikubicki +]] +class 'Globals' + +function Globals:get(name, alternative) + local response = api.get('/globalVariables/' .. name) + if response then + local char = string.sub(response.value, 1, 1) + if char == '{' or char == '"' then + return json.decode(response.value) + end + return response.value + end + return alternative +end + +function Globals:set(name, value) + local response = api.put('/globalVariables/' .. name, { + name = name, + value = json.encode(value) + }) + if not response then + response = api.post('/globalVariables', { + name = name, + value = json.encode(value) + }) + + end + if response ~= nil then + if response.type == 'ERROR' then + QuickApp:error('GLOBALS ERROR[' .. response.reason .. ']:', response.message) + end + end +end \ No newline at end of file diff --git a/HTTPClient.lua b/HTTPClient.lua new file mode 100644 index 0000000..d8147db --- /dev/null +++ b/HTTPClient.lua @@ -0,0 +1,97 @@ +--[[ +HTTPClient wrapper +@author ikubicki +]] +class 'HTTPClient' + +function HTTPClient:new(options) + if not options then + options = {} + end + self.options = options + return self +end + +function HTTPClient:get(url, success, error, headers) + local client = net.HTTPClient({timeout = 10000}) + if not headers then + headers = {} + end + client:request(self:url(url), self:requestOptions(success, error, 'GET', nil, headers)) +end + +function HTTPClient:post(url, data, success, error, headers) + local client = net.HTTPClient({timeout = 10000}) + if not headers then + headers = {} + end + client:request(self:url(url), self:requestOptions(success, error, 'POST', data, headers)) +end + +function HTTPClient:postForm(url, data, success, error, headers) + local client = net.HTTPClient({timeout = 10000}) + if not headers then + headers = {} + end + headers["Content-Type"] = 'application/x-www-form-urlencoded;charset=UTF-8' + client:request(self:url(url), self:requestOptions(success, error, 'POST', data, headers, true)) +end + +function HTTPClient:put(url, data, success, error, headers) + local client = net.HTTPClient({timeout = 10000}) + client:request(self:url(url), self:requestOptions(success, error, 'PUT', data, headers)) +end + +function HTTPClient:delete(url, success, error, headers) + local client = net.HTTPClient({timeout = 10000}) + if not headers then + headers = {} + end + client:request(self:url(url), self:requestOptions(success, error, 'DELETE', nil, headers)) +end + +function HTTPClient:url(url) + if (string.sub(url, 0, 4) == 'http') then + return url + end + if not self.options.baseUrl then + self.options.baseUrl = 'http://localhost' + end + return self.options.baseUrl .. tostring(url) +end + +function HTTPClient:requestOptions(success, error, method, data, headers, isFormData) + if error == nil then + error = function (error) + QuickApp:error(json.encode(error)) + end + end + if method == nil then + method = 'GET' + end + local options = { + checkCertificate = false, + method = method, + headers = headers, + } + if data ~= nil then + if isFormData then + options.data = '' + for key, value in pairs(data) do + if string.len(options.data) > 0 then + options.data = options.data .. '&' + end + options.data = options.data .. key .. '=' .. value + end + elseif type(data) == 'string' then + options.data = data + else + options.data = json.encode(data) + end + end + return { + options = options, + success = success, + error = error + } +end \ No newline at end of file diff --git a/README.md b/README.md index 543cfc4..a5aa568 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ -# fibaro-salus-it600 - +# Salus IT600 thermostats integration + +Virtual device that allow to control Salus IT600 thermostats. It creates three child devices that show current temperature, humidity and heating status in form of binary switch. + +## Configuration + +`Username` - Velux username + +`Password` - Velux password + +`DeviceID` - ID of the device + +`Interval` - Update interval expressed in seconds (30s by default) + + +## Installation + +Follow regular installation process. After virtual device will be added to your Home Center unit, click on Variables and provide `Username` and `Password`. +Then, click on `Search devices` button which will pull all information from your Salus account that includes Gateways and associated Devices. + +If you're installing another device, your Username and Password will be automatically populated from previous device. + +To access pulled information, go to logs of the device, review detected devices and use proper IDs as variables of the QuickApp. + +To change update interval add Interval property or replace existing one (if there's no edit botton). + +## Notes + +Salus API is locking account for 30 minutes after few invalid login attempts. If you'll change your password, virtual devices may lock your account. \ No newline at end of file diff --git a/Salus.lua b/Salus.lua new file mode 100644 index 0000000..b55a8ed --- /dev/null +++ b/Salus.lua @@ -0,0 +1,353 @@ +--[[ +Salus IT600 SDK +@author ikubicki +]] +class 'Salus' + +function Salus:new(config) + self.user = config:getUsername() + self.pass = config:getPassword() + self.device_id = config:getDeviceID() + self.token = Globals:get('salus_token', '') + self.http = HTTPClient:new({ + baseUrl = 'https://eu.salusconnect.io' + }) + return self +end + +function Salus:getProperties(callback) + local properties = {} + local holdtypeCallback = function(response) + properties["holdtype"] = response.value + callback(properties) + end + local runningCallback = function(response) + properties["running"] = response.value + Salus:holdtype(holdtypeCallback) + end + local humidityCallback = function(response) + properties["humidity"] = response.value + Salus:running(runningCallback) + end + local heatingSetpointCallback = function(response) + properties["heatingSetpoint"] = response.value / 100 + Salus:humidity(humidityCallback) + end + local temperatureCallback = function(response) + properties["temperature"] = response.value / 100 + Salus:heatingSetpoint(heatingSetpointCallback) + end + local authCallback = function(response) + Salus:temperature(temperatureCallback) + end + Salus:auth(authCallback) +end + +function Salus:searchDevices(callback) + local buildGateway = function(data) + return { + id = data.dsn, + name = data.product_name, + ip = data.lan_ip, + devices = {} + } + end + local buildDevice = function(data) + return { + id = data.dsn, + name = data.product_name, + model = data.oem_model, + } + end + local listDevicesCallback = function(response) + QuickApp:debug('OK'); + local gateways = {} + -- gateways + for _, d in ipairs(response) do + if d.device.device_type == 'Gateway' then + gateways[d.device.dsn] = buildGateway(d.device) + end + end + -- devices + for _, d in ipairs(response) do + if d.device.dsn ~= d.device.product_name and d.device.device_type == 'Node' and gateways[d.device.gateway_dsn] ~= nil then + table.insert(gateways[d.device.gateway_dsn].devices, buildDevice(d.device)) + end + end + callback(gateways) + end + local authCallback = function(response) + Salus:listDevices(listDevicesCallback) + end + Salus:auth(authCallback) +end + +function Salus:temperature(callback, attempt) + if attempt == nil then + attempt = 1 + end + local fail = function(response) + QuickApp:error('Unable to pull temperature') + Salus:setToken('') + QuickApp:debug(json.encode(response)) + + if attempt < 2 then + attempt = attempt + 1 + fibaro.setTimeout(3000, function() + QuickApp:debug('Salus:temperature - Retry attempt #' .. attempt) + local authCallback = function(response) + self:temperature(callback, attempt) + end + Salus:auth(authCallback) + end) + end + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:LocalTemperature_x100.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:heatingSetpoint(callback) + local fail = function(response) + QuickApp:error('Unable to pull heating setpoint') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:HeatingSetpoint_x100.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:setHeatingSetpoint(heatingSetpoint, callback) + local fail = function(response) + QuickApp:error('Unable to update heatingSetpoint') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:SetHeatingSetpoint_x100/datapoints.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken(), + ["Content-Type"] = "application/json", + } + local data = { + datapoint = { + value = heatingSetpoint * 100 + } + } + self.http:post(url, data, success, fail, headers) +end + +function Salus:humidity(callback) + local fail = function(response) + QuickApp:error('Unable to pull humidity') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:SunnySetpoint_x100.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:running(callback) + local fail = function(response) + QuickApp:error('Unable to pull mode') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:RunningState.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:holdtype(callback) + local fail = function(response) + QuickApp:error('Unable to pull mode') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:HoldType.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:setHoldtype(holdtype, callback) + local fail = function(response) + QuickApp:error('Unable to update holdtype') + Salus:setToken('') + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data.property) + end + end + local url = "/apiv1/dsns/" .. self.device_id .. "/properties/ep_9:sIT600TH:SetHoldType/datapoints.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken(), + ["Content-Type"] = "application/json", + } + local data = { + datapoint = { + value = holdtype + } + } + self.http:post(url, data, success, fail, headers) +end + +function Salus:listDevices(callback, fail, attempt) + if attempt == nil then + attempt = 1 + end + if fail == nil then + local fail = function(response) + QuickApp:error('Unable to pull devices') + Salus:setToken('') + + if attempt < 2 then + attempt = attempt + 1 + fibaro.setTimeout(3000, function() + QuickApp:debug('Salus:listDevices - Retry attempt #' .. attempt) + local authCallback = function(response) + self:listDevices(callback, nil, attempt) + end + Salus:auth(authCallback) + end) + end + end + end + local success = function(response) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + if callback ~= nil then + callback(data) + end + end + local url = "/apiv1/devices.json" + local headers = { + Authorization = "Bearer " .. Salus:getToken() + } + self.http:get(url, success, fail, headers) +end + +function Salus:auth(callback) + if string.len(self.token) > 1 then + -- QuickApp:debug('Already authenticated') + if callback ~= nil then + callback({}) + end + return + end + local fail = function(response) + QuickApp:error('Unable to authenticate') + Salus:setToken('') + end + local success = function(response) + QuickApp:debug(json.encode(response)) + if response.status > 299 then + fail(response) + return + end + local data = json.decode(response.data) + Salus:setToken(data.access_token) + if callback ~= nil then + callback(data) + end + end + local url = "/users/sign_in.json" + local headers = { + ["Content-Type"] = "application/json" + } + local data = { + user = { + email = self.user, + password = self.pass, + } + } + self.http:post(url, data, success, fail, headers) +end + +function Salus:setToken(token) + self.token = token + Globals:set('salus_token', token) +end + +function Salus:getToken() + if string.len(self.token) > 10 then + return self.token + elseif string.len(Globals:get('salus_token', '')) > 10 then + return Globals:get('salus_token', '') + end + return nil +end \ No newline at end of file diff --git a/SalusChildDevice.lua b/SalusChildDevice.lua new file mode 100644 index 0000000..e59dd52 --- /dev/null +++ b/SalusChildDevice.lua @@ -0,0 +1,13 @@ +class 'SalusChildDevice' (QuickAppChild) + +function SalusChildDevice:__init(device) + QuickAppChild.__init(self, device) +end + +function SalusChildDevice:setValue(value) + self:updateProperty("value", value) +end + +function SalusChildDevice:setState(value) + self:updateProperty("state", value > 0) +end \ No newline at end of file diff --git a/Salus_It600.fqa b/Salus_It600.fqa new file mode 100644 index 0000000..a0a2a89 --- /dev/null +++ b/Salus_It600.fqa @@ -0,0 +1,168 @@ +{ + "name": "Salus IT600", + "type": "com.fibaro.hvacSystemHeat", + "apiVersion": "1.2", + "initialProperties": { + "viewLayout": { + "$jason": { + "body": { + "header": { + "style": { + "height": "0" + }, + "title": "Salus_It600" + }, + "sections": { + "items": [ + { + "components": [ + { + "name": "label1", + "style": { + "weight": "1.2" + }, + "text": "Salus IT600", + "type": "label", + "visible": true + }, + { + "style": { + "weight": "0.5" + }, + "type": "space" + } + ], + "style": { + "weight": "1.2" + }, + "type": "vertical" + }, + { + "components": [ + { + "components": [ + { + "name": "button2_1", + "style": { + "weight": "0.50" + }, + "text": "Szukaj urządzeń", + "type": "button", + "visible": true + }, + { + "name": "button2_2", + "style": { + "weight": "0.50" + }, + "text": "Odśwież dane", + "type": "button", + "visible": true + } + ], + "style": { + "weight": "1.2" + }, + "type": "horizontal" + }, + { + "style": { + "weight": "0.5" + }, + "type": "space" + } + ], + "style": { + "weight": "1.2" + }, + "type": "vertical" + } + ] + } + }, + "head": { + "title": "Salus_It600" + } + } + }, + "uiCallbacks": [ + { + "callback": "searchEvent", + "eventType": "onReleased", + "name": "button2_1" + }, + { + "callback": "refreshEvent", + "eventType": "onReleased", + "name": "button2_2" + } + ], + "quickAppVariables": [ + { + "name": "Username", + "type": "string", + "value": "" + }, + { + "name": "Password", + "type": "password", + "value": "" + }, + { + "name": "DeviceID", + "type": "string", + "value": "" + }, + { + "name": "Interval", + "type": "string", + "value": "30" + } + ], + "typeTemplateInitialized": true + }, + "files": [ + { + "name": "main", + "isMain": true, + "isOpen": true, + "content": "--[[\nSalus IT600 thermostats integration v 1.0.0\n@author ikubicki\n]]\n\nfunction QuickApp:onInit()\n self.config = Config:new(self)\n self.salus = Salus:new(self.config)\n self.i18n = i18n:new(api.get(\"/settings/info\").defaultLanguage)\n self:trace('')\n self:trace(string.format(self.i18n:get('name'), self.name))\n self:updateProperty('manufacturer', 'Salus')\n self:updateProperty('model', 'IT600')\n self.childrenIds = {}\n self:updateProperty(\"supportedThermostatModes\", {\"Off\", \"Heat\", \"Auto\"})\n self:updateProperty(\"heatingThermostatSetpointCapabilitiesMax\", 35)\n self:updateProperty(\"heatingThermostatSetpointCapabilitiesMin\", 10)\n\n self:updateView(\"label1\", \"text\", string.format(self.i18n:get('name'), self.name))\n self:updateView(\"button2_1\", \"text\", self.i18n:get('search-devices'))\n self:updateView(\"button2_2\", \"text\", self.i18n:get('refresh'))\n\n self:initChildDevices({\n [\"com.fibaro.temperatureSensor\"] = SalusChildDevice,\n [\"com.fibaro.humiditySensor\"] = SalusChildDevice,\n [\"com.fibaro.binarySwitch\"] = SalusChildDevice,\n })\n for id, device in pairs(self.childDevices) do\n self.childrenIds[device.type] = id\n end\n\n if string.len(self.config:getDeviceID()) > 10 then\n if self.childrenIds[\"com.fibaro.temperatureSensor\"] == nil then\n local child = self:createChildDevice({\n name = self.name .. ' Temperature',\n type = \"com.fibaro.temperatureSensor\",\n roomID = roomID,\n }, SalusChildDevice)\n end\n if self.childrenIds[\"com.fibaro.humiditySensor\"] == nil then\n local child = self:createChildDevice({\n name = self.name .. ' Humidity',\n type = \"com.fibaro.humiditySensor\",\n roomID = roomID,\n }, SalusChildDevice)\n end\n if self.childrenIds[\"com.fibaro.binarySwitch\"] == nil then\n local child = self:createChildDevice({\n name = self.name .. ' Heating',\n type = \"com.fibaro.binarySwitch\",\n }, SalusChildDevice)\n child:updateProperty('deviceRole', 'Valve')\n child:updateProperty('categories', {'climate'})\n child:updateProperty('deviceControlType', 26)\n child:updateProperty('deviceIcon', 215)\n\n end\n for id, device in pairs(self.childDevices) do\n self.childrenIds[device.type] = id\n end\n self:run()\n else \n self:updateView(\"label1\", \"text\", self.i18n:get('not-configured'))\n end\nend\n\nfunction QuickApp:setThermostatMode(mode)\n self:updateProperty(\"thermostatMode\", mode)\n local holdtype = 0\n if mode == 'Off' then\n holdtype = 7\n elseif mode == 'Heat' then\n holdtype = 2\n end\n local setHoldtypeCallback = function(response)\n self:pullDataFromCloud()\n end\n self.salus:setHoldtype(holdtype, setHoldtypeCallback)\nend\n\nfunction QuickApp:setHeatingThermostatSetpoint(value) \n self:updateProperty(\"heatingThermostatSetpoint\", value)\n local setHeatingSetpointCallback = function(response)\n self:pullDataFromCloud()\n end\n self.salus:setHeatingSetpoint(value, setHeatingSetpointCallback)\nend\n\nfunction QuickApp:refreshEvent(event)\n self:updateView(\"label\", \"text\", self.i18n:get('refreshing'))\n self:pullDataFromCloud()\nend\n\nfunction QuickApp:run()\n self:pullDataFromCloud()\n local interval = self.config:getInterval()\n if (interval > 0) then\n fibaro.setTimeout(interval, function() self:run() end)\n end\nend\n\nfunction QuickApp:pullDataFromCloud()\n local getPropertiesCallback = function(properties)\n self:updateView(\"button2_2\", \"text\", self.i18n:get('refresh'))\n if self.childrenIds[\"com.fibaro.temperatureSensor\"] ~= nil then\n self.childDevices[self.childrenIds[\"com.fibaro.temperatureSensor\"]]:setValue(properties.temperature)\n end\n if self.childrenIds[\"com.fibaro.humiditySensor\"] ~= nil then\n self.childDevices[self.childrenIds[\"com.fibaro.humiditySensor\"]]:setValue(properties.humidity)\n end\n if self.childrenIds[\"com.fibaro.binarySwitch\"] ~= nil then\n local isRunningValue = 0\n if properties.running and properties.running > 0 then\n isRunningValue = 1\n end\n self.childDevices[self.childrenIds[\"com.fibaro.binarySwitch\"]]:setValue(isRunningValue > 0)\n end\n local mode = 'Auto' -- 0 or 1\n if properties.holdtype == 2 then\n mode = 'Heat'\n elseif properties.holdtype == 7 then\n mode = 'Off'\n end\n self:updateProperty(\"thermostatMode\", mode)\n self:updateProperty(\"heatingThermostatSetpoint\", properties.heatingSetpoint)\n self:updateView(\"label1\", \"text\", string.format(self.i18n:get('last-update'), os.date('%Y-%m-%d %H:%M:%S')))\n -- QuickApp:debug(json.encode(properties))\n end\n self:updateView(\"button2_2\", \"text\", self.i18n:get('refreshing'))\n self.salus:getProperties(getPropertiesCallback)\nend\n\nfunction QuickApp:searchEvent(param)\n self:debug(self.i18n:get('searching-devices'))\n self:updateView(\"button2_1\", \"text\", self.i18n:get('searching-devices'))\n local searchDevicesCallback = function(gateways)\n QuickApp:debug(json.encode(gateways))\n self:updateView(\"button2_1\", \"text\", self.i18n:get('search-devices'))\n -- printing results\n for _, gateway in pairs(gateways) do\n QuickApp:trace(string.format(self.i18n:get('search-row-gateway'), gateway.name, gateway.id))\n QuickApp:trace(string.format(self.i18n:get('search-row-gateway-devices'), #gateway.devices))\n for __, device in ipairs(gateway.devices) do\n QuickApp:trace(string.format(self.i18n:get('search-row-device'), device.name, device.id, device.model))\n end\n end\n self:updateView(\"label2\", \"text\", string.format(self.i18n:get('check-logs'), 'QUICKAPP' .. self.id))\n end\n self.salus:searchDevices(searchDevicesCallback)\nend\n" + }, + { + "name": "Config", + "isMain": false, + "isOpen": false, + "content": "--[[\nConfiguration handler\n@author ikubicki\n]]\nclass 'Config'\n\nfunction Config:new(app)\n self.app = app\n self:init()\n return self\nend\n\nfunction Config:getUsername()\n if self.username and self.username:len() > 3 then\n return self.username\n end\n return nil\nend\n\nfunction Config:getPassword()\n return self.password\nend\n\nfunction Config:getDeviceID()\n return self.device_id\nend\n\nfunction Config:setDeviceID(device_id)\n self.device_id = device_id\nend\n\nfunction Config:getInterval()\n return tonumber(self.interval) * 1000\nend\n\n--[[\nThis function takes variables and sets as global variables if those are not set already.\nThis way, adding other devices might be optional and leaves option for users, \nwhat they want to add into HC3 virtual devices.\n]]\nfunction Config:init()\n self.username = self.app:getVariable('Username')\n self.password = self.app:getVariable('Password')\n self.device_id = self.app:getVariable('DeviceID')\n self.interval = self.app:getVariable('Interval')\n\n local storedUsername = Globals:get('salus_username', '')\n local storedPassword = Globals:get('salus_password', '')\n\n -- handling username\n if string.len(self.username) < 4 and string.len(storedUsername) > 3 then\n self.app:setVariable(\"Username\", storedUsername)\n self.username = storedUsername\n elseif (storedUsername == '' and self.username) then\n Globals:set('salus_username', self.username)\n end\n -- handling password\n if string.len(self.password) < 4 and string.len(storedPassword) > 3 then\n self.app:setVariable(\"Password\", storedPassword)\n self.password = storedPassword\n elseif (storedPassword == '' and self.password) then\n Globals:set('salus_password', self.password)\n end\n -- handling interval\n if not self.interval or self.interval == \"\" then\n self.app:setVariable(\"Interval\", 30)\n self.interval = 30\n end\nend" + }, + { + "name": "HTTPClient", + "isMain": false, + "isOpen": false, + "content": "--[[\nHTTPClient wrapper\n@author ikubicki\n]]\nclass 'HTTPClient'\n\nfunction HTTPClient:new(options)\n if not options then\n options = {}\n end\n self.options = options\n return self\nend\n\nfunction HTTPClient:get(url, success, error, headers)\n local client = net.HTTPClient({timeout = 10000})\n if not headers then\n headers = {}\n end\n client:request(self:url(url), self:requestOptions(success, error, 'GET', nil, headers)) \nend\n\nfunction HTTPClient:post(url, data, success, error, headers)\n local client = net.HTTPClient({timeout = 10000})\n if not headers then\n headers = {}\n end\n client:request(self:url(url), self:requestOptions(success, error, 'POST', data, headers)) \nend\n\nfunction HTTPClient:postForm(url, data, success, error, headers)\n local client = net.HTTPClient({timeout = 10000})\n if not headers then\n headers = {}\n end\n headers[\"Content-Type\"] = 'application/x-www-form-urlencoded;charset=UTF-8'\n client:request(self:url(url), self:requestOptions(success, error, 'POST', data, headers, true)) \nend\n\nfunction HTTPClient:put(url, data, success, error, headers)\n local client = net.HTTPClient({timeout = 10000})\n client:request(self:url(url), self:requestOptions(success, error, 'PUT', data, headers)) \nend\n\nfunction HTTPClient:delete(url, success, error, headers)\n local client = net.HTTPClient({timeout = 10000})\n if not headers then\n headers = {}\n end\n client:request(self:url(url), self:requestOptions(success, error, 'DELETE', nil, headers)) \nend\n\nfunction HTTPClient:url(url)\n if (string.sub(url, 0, 4) == 'http') then\n return url\n end\n if not self.options.baseUrl then\n self.options.baseUrl = 'http://localhost'\n end\n return self.options.baseUrl .. tostring(url)\nend\n\nfunction HTTPClient:requestOptions(success, error, method, data, headers, isFormData)\n if error == nil then\n error = function (error)\n QuickApp:error(json.encode(error))\n end\n end\n if method == nil then\n method = 'GET'\n end\n local options = {\n checkCertificate = false,\n method = method,\n headers = headers,\n }\n if data ~= nil then\n if isFormData then\n options.data = ''\n for key, value in pairs(data) do\n if string.len(options.data) > 0 then \n options.data = options.data .. '&'\n end\n options.data = options.data .. key .. '=' .. value\n end\n elseif type(data) == 'string' then\n options.data = data\n else\n options.data = json.encode(data)\n end\n end\n return {\n options = options,\n success = success,\n error = error\n }\nend" + }, + { + "name": "Globals", + "isMain": false, + "isOpen": false, + "content": "--[[\nGlobal variables handler\n@author ikubicki\n]]\nclass 'Globals'\n\nfunction Globals:get(name, alternative)\n local response = api.get('/globalVariables/' .. name)\n if response then\n local char = string.sub(response.value, 1, 1)\n if char == '{' or char == '\"' then\n return json.decode(response.value)\n end\n return response.value\n end\n return alternative\nend\n\nfunction Globals:set(name, value)\n local response = api.put('/globalVariables/' .. name, {\n name = name,\n value = json.encode(value)\n })\n if not response then\n response = api.post('/globalVariables', {\n name = name,\n value = json.encode(value)\n })\n \n end\n if response ~= nil then\n if response.type == 'ERROR' then\n QuickApp:error('GLOBALS ERROR[' .. response.reason .. ']:', response.message)\n end\n end\nend" + }, + { + "name": "i18n", + "isMain": false, + "isOpen": false, + "content": "--[[\nInternationalization tool\n@author ikubicki\n]]\nclass 'i18n'\n\nfunction i18n:new(langCode)\n if phrases[langCode] == nil then\n langCode = 'en'\n end\n self.phrases = phrases[langCode]\n return self\nend\n\nfunction i18n:get(key)\n if self.phrases[key] then\n return self.phrases[key]\n end\n return key\nend\n\nphrases = {\n pl = {\n ['name'] = 'Salus IT600 - %s',\n ['search-devices'] = 'Szukaj urządzeń',\n ['searching-devices'] = 'Szukam...',\n ['refresh'] = 'Odśwież dane',\n ['refreshing'] = 'Odświeżam...',\n ['device-updated'] = 'Zaktualizowano dane urządzenia',\n ['last-update'] = 'Ostatnia aktualizacja: %s',\n ['not-configured'] = 'Urządzenie nie skonfigurowane',\n ['check-logs'] = 'Zakończono wyszukiwanie. Sprawdź logi tego urządzenia: %s',\n ['search-row-gateway'] = '__ BRAMKA %s (# %s)',\n ['search-row-gateway-devices'] = '__ Wykryto %d urządzeń',\n ['search-row-device'] = '____ URZĄDZENIE %s (DeviceID: %s, Model: %s)',\n },\n en = {\n ['name'] = 'Salus IT600 - %s',\n ['search-devices'] = 'Search devices',\n ['searching-devices'] = 'Searching...',\n ['refresh'] = 'Update data',\n ['refreshing'] = 'Updating...',\n ['device-updated'] = 'Device updates',\n ['last-update'] = 'Last update: %s',\n ['not-configured'] = 'Device not configured',\n ['check-logs'] = 'Check device logs (%s) for search results',\n ['search-row-gateway'] = '__ GATEWAY %s (# %s)',\n ['search-row-gateway-devices'] = '__ %d devices found',\n ['search-row-device'] = '____ DEVICE %s (DeviceID: %s, Model: %s)',\n },\n}" + }, + { + "name": "Salus", + "isMain": false, + "isOpen": false, + "content": "--[[\nSalus IT600 SDK\n@author ikubicki\n]]\nclass 'Salus'\n\nfunction Salus:new(config)\n self.user = config:getUsername()\n self.pass = config:getPassword()\n self.device_id = config:getDeviceID()\n self.token = Globals:get('salus_token', '')\n self.http = HTTPClient:new({\n baseUrl = 'https://eu.salusconnect.io'\n })\n return self\nend\n\nfunction Salus:getProperties(callback)\n local properties = {}\n local holdtypeCallback = function(response)\n properties[\"holdtype\"] = response.value\n callback(properties)\n end\n local runningCallback = function(response)\n properties[\"running\"] = response.value\n Salus:holdtype(holdtypeCallback)\n end\n local humidityCallback = function(response)\n properties[\"humidity\"] = response.value\n Salus:running(runningCallback)\n end\n local heatingSetpointCallback = function(response)\n properties[\"heatingSetpoint\"] = response.value / 100\n Salus:humidity(humidityCallback)\n end\n local temperatureCallback = function(response)\n properties[\"temperature\"] = response.value / 100\n Salus:heatingSetpoint(heatingSetpointCallback)\n end\n local authCallback = function(response)\n Salus:temperature(temperatureCallback)\n end\n Salus:auth(authCallback)\nend\n\nfunction Salus:searchDevices(callback)\n local buildGateway = function(data) \n return {\n id = data.dsn,\n name = data.product_name,\n ip = data.lan_ip,\n devices = {}\n }\n end\n local buildDevice = function(data)\n return {\n id = data.dsn,\n name = data.product_name,\n model = data.oem_model,\n }\n end\n local listDevicesCallback = function(response)\n QuickApp:debug('OK');\n local gateways = {}\n -- gateways\n for _, d in ipairs(response) do\n if d.device.device_type == 'Gateway' then\n gateways[d.device.dsn] = buildGateway(d.device)\n end\n end\n -- devices\n for _, d in ipairs(response) do\n if d.device.dsn ~= d.device.product_name and d.device.device_type == 'Node' and gateways[d.device.gateway_dsn] ~= nil then\n table.insert(gateways[d.device.gateway_dsn].devices, buildDevice(d.device))\n end\n end\n callback(gateways)\n end\n local authCallback = function(response)\n Salus:listDevices(listDevicesCallback)\n end\n Salus:auth(authCallback)\nend\n\nfunction Salus:temperature(callback, attempt)\n if attempt == nil then\n attempt = 1\n end\n local fail = function(response)\n QuickApp:error('Unable to pull temperature')\n Salus:setToken('')\n QuickApp:debug(json.encode(response))\n \n if attempt < 2 then\n attempt = attempt + 1\n fibaro.setTimeout(3000, function()\n QuickApp:debug('Salus:temperature - Retry attempt #' .. attempt)\n local authCallback = function(response)\n self:temperature(callback, attempt)\n end\n Salus:auth(authCallback)\n end)\n end\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:LocalTemperature_x100.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:heatingSetpoint(callback)\n local fail = function(response)\n QuickApp:error('Unable to pull heating setpoint')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:HeatingSetpoint_x100.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:setHeatingSetpoint(heatingSetpoint, callback)\n local fail = function(response)\n QuickApp:error('Unable to update heatingSetpoint')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:SetHeatingSetpoint_x100/datapoints.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken(),\n [\"Content-Type\"] = \"application/json\",\n }\n local data = {\n datapoint = {\n value = heatingSetpoint * 100\n }\n }\n self.http:post(url, data, success, fail, headers)\nend\n\nfunction Salus:humidity(callback)\n local fail = function(response)\n QuickApp:error('Unable to pull humidity')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:SunnySetpoint_x100.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:running(callback)\n local fail = function(response)\n QuickApp:error('Unable to pull mode')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:RunningState.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:holdtype(callback)\n local fail = function(response)\n QuickApp:error('Unable to pull mode')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:HoldType.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:setHoldtype(holdtype, callback)\n local fail = function(response)\n QuickApp:error('Unable to update holdtype')\n Salus:setToken('')\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data.property)\n end\n end\n local url = \"/apiv1/dsns/\" .. self.device_id .. \"/properties/ep_9:sIT600TH:SetHoldType/datapoints.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken(),\n [\"Content-Type\"] = \"application/json\",\n }\n local data = {\n datapoint = {\n value = holdtype\n }\n }\n self.http:post(url, data, success, fail, headers)\nend\n\nfunction Salus:listDevices(callback, fail, attempt)\n if attempt == nil then\n attempt = 1\n end\n if fail == nil then\n local fail = function(response)\n QuickApp:error('Unable to pull devices')\n Salus:setToken('')\n \n if attempt < 2 then\n attempt = attempt + 1\n fibaro.setTimeout(3000, function()\n QuickApp:debug('Salus:listDevices - Retry attempt #' .. attempt)\n local authCallback = function(response)\n self:listDevices(callback, nil, attempt)\n end\n Salus:auth(authCallback)\n end)\n end\n end\n end\n local success = function(response)\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n if callback ~= nil then\n callback(data)\n end\n end\n local url = \"/apiv1/devices.json\"\n local headers = {\n Authorization = \"Bearer \" .. Salus:getToken()\n }\n self.http:get(url, success, fail, headers)\nend\n\nfunction Salus:auth(callback)\n if string.len(self.token) > 1 then\n -- QuickApp:debug('Already authenticated')\n if callback ~= nil then\n callback({})\n end\n return\n end\n local fail = function(response)\n QuickApp:error('Unable to authenticate')\n Salus:setToken('')\n end\n local success = function(response)\n QuickApp:debug(json.encode(response))\n if response.status > 299 then\n fail(response)\n return\n end\n local data = json.decode(response.data)\n Salus:setToken(data.access_token)\n if callback ~= nil then\n callback(data)\n end\n end\n local url = \"/users/sign_in.json\"\n local headers = {\n [\"Content-Type\"] = \"application/json\"\n }\n local data = {\n user = {\n email = self.user,\n password = self.pass,\n }\n }\n self.http:post(url, data, success, fail, headers)\nend\n\nfunction Salus:setToken(token)\n self.token = token\n Globals:set('salus_token', token)\nend\n\nfunction Salus:getToken()\n if string.len(self.token) > 10 then\n return self.token\n elseif string.len(Globals:get('salus_token', '')) > 10 then\n return Globals:get('salus_token', '')\n end\n return nil\nend" + }, + { + "name": "SalusChildDevice", + "isMain": false, + "isOpen": false, + "content": "class 'SalusChildDevice' (QuickAppChild)\n\nfunction SalusChildDevice:__init(device)\n QuickAppChild.__init(self, device)\nend\n\nfunction SalusChildDevice:setValue(value)\n self:updateProperty(\"value\", value)\nend\n\nfunction SalusChildDevice:setState(value)\n self:updateProperty(\"state\", value > 0)\nend" + } + ] +} \ No newline at end of file diff --git a/i18n.lua b/i18n.lua new file mode 100644 index 0000000..f98f7c4 --- /dev/null +++ b/i18n.lua @@ -0,0 +1,51 @@ +--[[ +Internationalization tool +@author ikubicki +]] +class 'i18n' + +function i18n:new(langCode) + if phrases[langCode] == nil then + langCode = 'en' + end + self.phrases = phrases[langCode] + return self +end + +function i18n:get(key) + if self.phrases[key] then + return self.phrases[key] + end + return key +end + +phrases = { + pl = { + ['name'] = 'Salus IT600 - %s', + ['search-devices'] = 'Szukaj urządzeń', + ['searching-devices'] = 'Szukam...', + ['refresh'] = 'Odśwież dane', + ['refreshing'] = 'Odświeżam...', + ['device-updated'] = 'Zaktualizowano dane urządzenia', + ['last-update'] = 'Ostatnia aktualizacja: %s', + ['not-configured'] = 'Urządzenie nie skonfigurowane', + ['check-logs'] = 'Zakończono wyszukiwanie. Sprawdź logi tego urządzenia: %s', + ['search-row-gateway'] = '__ BRAMKA %s (# %s)', + ['search-row-gateway-devices'] = '__ Wykryto %d urządzeń', + ['search-row-device'] = '____ URZĄDZENIE %s (DeviceID: %s, Model: %s)', + }, + en = { + ['name'] = 'Salus IT600 - %s', + ['search-devices'] = 'Search devices', + ['searching-devices'] = 'Searching...', + ['refresh'] = 'Update data', + ['refreshing'] = 'Updating...', + ['device-updated'] = 'Device updates', + ['last-update'] = 'Last update: %s', + ['not-configured'] = 'Device not configured', + ['check-logs'] = 'Check device logs (%s) for search results', + ['search-row-gateway'] = '__ GATEWAY %s (# %s)', + ['search-row-gateway-devices'] = '__ %d devices found', + ['search-row-device'] = '____ DEVICE %s (DeviceID: %s, Model: %s)', + }, +} \ No newline at end of file diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..ec5ecc4 --- /dev/null +++ b/main.lua @@ -0,0 +1,143 @@ +--[[ +Salus IT600 thermostats integration v 1.0.0 +@author ikubicki +]] + +function QuickApp:onInit() + self.config = Config:new(self) + self.salus = Salus:new(self.config) + self.i18n = i18n:new(api.get("/settings/info").defaultLanguage) + self:trace('') + self:trace(string.format(self.i18n:get('name'), self.name)) + self:updateProperty('manufacturer', 'Salus') + self:updateProperty('model', 'IT600') + self.childrenIds = {} + + self:updateProperty("supportedThermostatModes", {"Off", "Heat", "Auto"}) + self:updateProperty("heatingThermostatSetpointCapabilitiesMax", 35) + self:updateProperty("heatingThermostatSetpointCapabilitiesMin", 10) + + self:updateView("label1", "text", string.format(self.i18n:get('name'), self.name)) + self:updateView("button2_1", "text", self.i18n:get('search-devices')) + self:updateView("button2_2", "text", self.i18n:get('refresh')) + + self:initChildDevices({ + ["com.fibaro.temperatureSensor"] = SalusChildDevice, + ["com.fibaro.humiditySensor"] = SalusChildDevice, + ["com.fibaro.binarySwitch"] = SalusChildDevice, + }) + for id, device in pairs(self.childDevices) do + self.childrenIds[device.type] = id + end + + if string.len(self.config:getDeviceID()) > 10 then + if self.childrenIds["com.fibaro.temperatureSensor"] == nil then + local child = self:createChildDevice({ + name = self.name .. ' Temperature', + type = "com.fibaro.temperatureSensor", + }, SalusChildDevice) + end + if self.childrenIds["com.fibaro.humiditySensor"] == nil then + local child = self:createChildDevice({ + name = self.name .. ' Humidity', + type = "com.fibaro.humiditySensor", + }, SalusChildDevice) + end + if self.childrenIds["com.fibaro.binarySwitch"] == nil then + local child = self:createChildDevice({ + name = self.name .. ' Heating', + type = "com.fibaro.binarySwitch", + deviceRole = 'Valve', + isLight = false, + }, SalusChildDevice) + end + self:run() + else + self:updateView("label1", "text", self.i18n:get('not-configured')) + end +end + +function QuickApp:setThermostatMode(mode) + self:updateProperty("thermostatMode", mode) + local holdtype = 0 + if mode == 'Off' then + holdtype = 7 + elseif mode == 'Heat' then + holdtype = 2 + end + local setHoldtypeCallback = function(response) + self:pullDataFromCloud() + end + self.salus:setHoldtype(holdtype, setHoldtypeCallback) +end + +function QuickApp:setHeatingThermostatSetpoint(value) + self:updateProperty("heatingThermostatSetpoint", value) + local setHeatingSetpointCallback = function(response) + self:pullDataFromCloud() + end + self.salus:setHeatingSetpoint(value, setHeatingSetpointCallback) +end + +function QuickApp:refreshEvent(event) + self:updateView("label", "text", self.i18n:get('refreshing')) + self:pullDataFromCloud() +end + +function QuickApp:run() + self:pullDataFromCloud() + local interval = self.config:getInterval() + if (interval > 0) then + fibaro.setTimeout(interval, function() self:run() end) + end +end + +function QuickApp:pullDataFromCloud() + local getPropertiesCallback = function(properties) + self:updateView("button2_2", "text", self.i18n:get('refresh')) + if self.childrenIds["com.fibaro.temperatureSensor"] ~= nil then + self.childDevices[self.childrenIds["com.fibaro.temperatureSensor"]]:setValue(properties.temperature) + end + if self.childrenIds["com.fibaro.humiditySensor"] ~= nil then + self.childDevices[self.childrenIds["com.fibaro.humiditySensor"]]:setValue(properties.humidity) + end + if self.childrenIds["com.fibaro.binarySwitch"] ~= nil then + local isRunningValue = 0 + if properties.running and properties.running > 0 then + isRunningValue = 1 + end + self.childDevices[self.childrenIds["com.fibaro.binarySwitch"]]:setValue(isRunningValue > 0) + end + local mode = 'Auto' -- 0 or 1 + if properties.holdtype == 2 then + mode = 'Heat' + elseif properties.holdtype == 7 then + mode = 'Off' + end + self:updateProperty("thermostatMode", mode) + self:updateProperty("heatingThermostatSetpoint", properties.heatingSetpoint) + self:updateView("label1", "text", string.format(self.i18n:get('last-update'), os.date('%Y-%m-%d %H:%M:%S'))) + -- QuickApp:debug(json.encode(properties)) + end + self:updateView("button2_2", "text", self.i18n:get('refreshing')) + self.salus:getProperties(getPropertiesCallback) +end + +function QuickApp:searchEvent(param) + self:debug(self.i18n:get('searching-devices')) + self:updateView("button2_1", "text", self.i18n:get('searching-devices')) + local searchDevicesCallback = function(gateways) + QuickApp:debug(json.encode(gateways)) + self:updateView("button2_1", "text", self.i18n:get('search-devices')) + -- printing results + for _, gateway in pairs(gateways) do + QuickApp:trace(string.format(self.i18n:get('search-row-gateway'), gateway.name, gateway.id)) + QuickApp:trace(string.format(self.i18n:get('search-row-gateway-devices'), #gateway.devices)) + for __, device in ipairs(gateway.devices) do + QuickApp:trace(string.format(self.i18n:get('search-row-device'), device.name, device.id, device.model)) + end + end + self:updateView("label2", "text", string.format(self.i18n:get('check-logs'), 'QUICKAPP' .. self.id)) + end + self.salus:searchDevices(searchDevicesCallback) +end