From adf809a5e7e759b9b308cf1464109975a65856d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cliuzhonghua=E2=80=9D?= Date: Wed, 20 Mar 2024 17:32:35 +0800 Subject: [PATCH] Add DeepSmart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: “liuzhonghua” --- drivers/DeepSmart/deepsmart/config.yml | 5 + drivers/DeepSmart/deepsmart/profiles/Ac.yml | 22 + .../deepsmart/profiles/Deepsmart-bridge.yml | 8 + .../DeepSmart/deepsmart/profiles/Heater.yml | 20 + drivers/DeepSmart/deepsmart/profiles/Hue.yml | 18 + .../DeepSmart/deepsmart/profiles/Light.yml | 14 + .../DeepSmart/deepsmart/profiles/Newfan.yml | 12 + .../DeepSmart/deepsmart/profiles/Slider.yml | 16 + .../DeepSmart/deepsmart/search-parameters.yml | 2 + drivers/DeepSmart/deepsmart/src/commands.lua | 171 ++++ drivers/DeepSmart/deepsmart/src/config.lua | 70 ++ .../deepsmart/src/deepsmart/.wisers.lua.swp | Bin 0 -> 16384 bytes .../DeepSmart/deepsmart/src/deepsmart/api.lua | 227 +++++ .../deepsmart/src/deepsmart/devices.lua | 270 ++++++ .../deepsmart/src/deepsmart/dp2knx.lua | 163 ++++ .../deepsmart/src/deepsmart/dpenum.lua | 99 ++ .../deepsmart/src/deepsmart/wisers.lua | 899 ++++++++++++++++++ drivers/DeepSmart/deepsmart/src/discovery.lua | 45 + drivers/DeepSmart/deepsmart/src/init.lua | 158 +++ .../DeepSmart/deepsmart/src/lifecycles.lua | 89 ++ .../DeepSmart/deepsmart/src/lunchbox/init.lua | 4 + .../DeepSmart/deepsmart/src/lunchbox/rest.lua | 369 +++++++ .../src/lunchbox/sse/eventsource.lua | 511 ++++++++++ .../DeepSmart/deepsmart/src/lunchbox/util.lua | 46 + .../deepsmart/src/selfSignedRoot.crt | 22 + drivers/DeepSmart/deepsmart/src/ssdp.lua | 89 ++ drivers/DeepSmart/deepsmart/src/utils.lua | 235 +++++ 27 files changed, 3584 insertions(+) create mode 100644 drivers/DeepSmart/deepsmart/config.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Ac.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Heater.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Hue.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Light.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Newfan.yml create mode 100644 drivers/DeepSmart/deepsmart/profiles/Slider.yml create mode 100644 drivers/DeepSmart/deepsmart/search-parameters.yml create mode 100644 drivers/DeepSmart/deepsmart/src/commands.lua create mode 100644 drivers/DeepSmart/deepsmart/src/config.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/.wisers.lua.swp create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/api.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua create mode 100644 drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua create mode 100644 drivers/DeepSmart/deepsmart/src/discovery.lua create mode 100644 drivers/DeepSmart/deepsmart/src/init.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lifecycles.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/init.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua create mode 100644 drivers/DeepSmart/deepsmart/src/lunchbox/util.lua create mode 100644 drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt create mode 100644 drivers/DeepSmart/deepsmart/src/ssdp.lua create mode 100644 drivers/DeepSmart/deepsmart/src/utils.lua diff --git a/drivers/DeepSmart/deepsmart/config.yml b/drivers/DeepSmart/deepsmart/config.yml new file mode 100644 index 0000000000..697703917c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/config.yml @@ -0,0 +1,5 @@ +name: 'DEEPSMART KNX GATEWAY' +packageKey: 'DEEPSMART-KNX.GATEWAY' +permissions: + lan: {} + discovery: {} diff --git a/drivers/DeepSmart/deepsmart/profiles/Ac.yml b/drivers/DeepSmart/deepsmart/profiles/Ac.yml new file mode 100644 index 0000000000..8d1139908a --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Ac.yml @@ -0,0 +1,22 @@ +name: Ac.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [ 16, 30 ] + - id: airConditionerMode + version: 1 + - id: airConditionerFanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner diff --git a/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml b/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml new file mode 100644 index 0000000000..428b1b048b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Deepsmart-bridge.yml @@ -0,0 +1,8 @@ +name: Deepsmart.bridge +components: +- id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Bridges diff --git a/drivers/DeepSmart/deepsmart/profiles/Heater.yml b/drivers/DeepSmart/deepsmart/profiles/Heater.yml new file mode 100644 index 0000000000..74bb369dde --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Heater.yml @@ -0,0 +1,20 @@ +name: Heater.v1 +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatOperatingState + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [ 5, 35 ] + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/DeepSmart/deepsmart/profiles/Hue.yml b/drivers/DeepSmart/deepsmart/profiles/Hue.yml new file mode 100644 index 0000000000..c9f72b69a7 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Hue.yml @@ -0,0 +1,18 @@ +name: Hue.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: colorTemperature + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/profiles/Light.yml b/drivers/DeepSmart/deepsmart/profiles/Light.yml new file mode 100644 index 0000000000..7cea315f7a --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Light.yml @@ -0,0 +1,14 @@ +name: Light.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/profiles/Newfan.yml b/drivers/DeepSmart/deepsmart/profiles/Newfan.yml new file mode 100644 index 0000000000..7baa525ede --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Newfan.yml @@ -0,0 +1,12 @@ +name: Newfan.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: airConditionerFanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: Vent diff --git a/drivers/DeepSmart/deepsmart/profiles/Slider.yml b/drivers/DeepSmart/deepsmart/profiles/Slider.yml new file mode 100644 index 0000000000..0c66d62986 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Slider.yml @@ -0,0 +1,16 @@ +name: Slider.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +metadata: + deviceType: Light + ocfDeviceType: oic.d.light + deviceTypeId: Light diff --git a/drivers/DeepSmart/deepsmart/search-parameters.yml b/drivers/DeepSmart/deepsmart/search-parameters.yml new file mode 100644 index 0000000000..06f9f57e77 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/search-parameters.yml @@ -0,0 +1,2 @@ +ssdp: + - searchTerm: "DEEPSMART-ARM" diff --git a/drivers/DeepSmart/deepsmart/src/commands.lua b/drivers/DeepSmart/deepsmart/src/commands.lua new file mode 100644 index 0000000000..3515d15bc8 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/commands.lua @@ -0,0 +1,171 @@ +local caps = require('st.capabilities') +local log = require('log') + +local config = require('config') +local wisers = require('deepsmart.wisers') + +local command_handler = {} + +------------------ +-- Refresh command +function command_handler.refresh(_, device) + local bridge_id = device.device_network_id + local parent_assigned_child_key = device.parent_assigned_child_key + local success = false + local is_bridge = wisers.is_device_bridge(device) + log.info('refresh bridge '..bridge_id..' device') + if (not is_bridge) then + success = wisers.refresh(device) + log.info('hub refresh device '..parent_assigned_child_key) + else + -- reload wiser devices + wisers.refresh_wiser(bridge_id) + log.info('hub refresh device '..bridge_id) + end + -- Check success + if success then + -- Define online status + device:online() + else + log.error('failed to poll device state') + -- Set device as offline + device:offline() + end +end + +---------------- +-- Switch command +---------------- +function command_handler.set_switch(_, device, command) + local on_off = command.command + log.info('hub control device '..device.parent_assigned_child_key..' onoff '..on_off) + -- gt devtype + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + -- get devtype onoff addrtype + if (devtype == config.ENUM.AC or devtype == config.ENUM.HEATER or devtype == config.ENUM.NEWFAN or devtype == config.ENUM.SWITCH or devtype == config.ENUM.SLIDER or devtype == config.ENUM.HUE) then + addrtypes[1] = config.DEVICE.ONOFF + else + addrtypes[1] = 0 + end + -- send command + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + if on_off == 'off' then + return device:emit_event(caps.switch.switch.off()) + end + return device:emit_event(caps.switch.switch.on()) + end + log.error('no response from device') + return 0 +end + +---------------- +-- Switch level command +---------------- +function command_handler.set_level(_, device, command) + local lvl = command.args.level + local success = wisers.control(device, command, {config.SLIDER.BRIGHT}) + -- Check if success + if success then + if lvl == 0 then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + device:emit_event(caps.switchLevel.level(lvl)) + return + end + log.error('no response from device') +end + +---------------- +-- Color control command +---------------- +function command_handler.set_color(_, device, command) + local success = wisers.control(device, command, {config.HUE.HUE}) + + -- Check if success + if success then + device:emit_event(caps.switch.switch.on()) + device:emit_event(caps.colorTemperature.colorTemperature(command.args.temperature)) + return + end + log.error('no response from device') +end + +---------------- +-- fan mode command +---------------- +function command_handler.set_thermostat_fan_mode(driver, device, command) + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + if (devtype == config.ENUM.AC) then + addrtypes[1] = config.AC.FAN + elseif devtype == config.ENUM.NEWFAN then + addrtypes[1] = config.NEWFAN.FAN + end + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + return device:emit_event(caps.airConditionerFanMode.fanMode(command.args.fanMode)) + end + log.error('no response from device') + return 0 +end + +---------------- +-- mode command +---------------- +function command_handler.set_airconditioner_mode(driver, device, command) + local success = wisers.control(device, command, {config.AC.MODE}) + -- Check if success + if success then + return device:emit_event(caps.airConditionerMode.airConditionerMode(command.args.mode)) + end + log.error('no response from device') +end + +---------------- +-- heater mode command +---------------- +function command_handler.set_thermostat_mode(driver, device, command) + -- send command + local success = wisers.control(device, command, {config.HEATER.ONOFF}) + -- Check if success + if success then + if command.args.mode == 'off' then + device:emit_event(caps.thermostatMode.thermostatMode.off()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.idle()) + else + device:emit_event(caps.thermostatMode.thermostatMode.heat()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.heating()) + end + return + end + log.error('no response from device') + return 0 +end + +---------------- +-- set heating point command +---------------- +function command_handler.set_setheatingpoint(driver, device, command) + local devtype = wisers.get_dev_type(device) + local addrtypes = {} + if (devtype == config.ENUM.AC) then + addrtypes[1] = config.AC.SETTEMP + elseif devtype == config.ENUM.HEATER then + addrtypes[1] = config.HEATER.SETTEMP + end + local success = wisers.control(device, command, addrtypes) + -- Check if success + if success then + return device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=command.args.setpoint,unit='C'})) + end + log.error('no response from device') +end + + +return command_handler diff --git a/drivers/DeepSmart/deepsmart/src/config.lua b/drivers/DeepSmart/deepsmart/src/config.lua new file mode 100644 index 0000000000..ac0b133137 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/config.lua @@ -0,0 +1,70 @@ +local config = {} +-- device info +-- NOTE: In the future this information +-- may be submitted through the Developer +-- Workspace to avoid hardcoded values. +config.DEVICE_PROFILE={} +config.DEVICE_PROFILE[3]='Ac.v1' +config.DEVICE_PROFILE[4]='Heater.v1' +config.DEVICE_PROFILE[14]='Light.v1' +config.DEVICE_PROFILE[16]='Slider.v1' +config.DEVICE_PROFILE[39]='Slider.v1' +config.DEVICE_PROFILE[25]='Hue.v1' +config.DEVICE_PROFILE[38]='Hue.v1' +config.DEVICE_PROFILE[53]='Newfan.v1' +config.DEVICE_TYPE='LAN' + +-- SSDP Config +config.MC_ADDRESS='239.255.255.250' +config.MC_PORT=1900 +config.MC_TIMEOUT=6 + +config.ENUM = {} +config.ENUM.AC = 3 +config.ENUM.HEATER = 4 +config.ENUM.NEWFAN = 53 +config.ENUM.SWITCH = 14 +config.ENUM.SLIDER = 39 +config.ENUM.HUE = 38 + +--device addrtype +config.AC = {} +config.AC.ONOFF = 0 +config.AC.MODE = 1 +config.AC.FAN = 2 +config.AC.SETTEMP = 3 +config.AC.TEMP = 4 + +config.HEATER = {} +config.HEATER.ONOFF = 0 +config.HEATER.SETTEMP = 2 +config.HEATER.TEMP = 3 + +config.NEWFAN = {} +config.NEWFAN.ONOFF = 0 +config.NEWFAN.FAN = 1 + +config.SWITCH = {} +config.SWITCH.ONOFF = 0 + +config.SLIDER = {} +config.SLIDER.ONOFF = 0 +config.SLIDER.BRIGHT = 1 + +config.HUE = {} +config.HUE.ONOFF = 0 +config.HUE.BRIGHT = 1 +config.HUE.HUE = 2 + +config.DEVICE = {} +config.DEVICE.ONOFF = 0 + +config.FIELD = {} +config.FIELD.DP2KNX = "dp2knx" +config.FIELD.DPENUM = "dpenum" +config.FIELD.DEVICES = "devices" +config.FIELD.IP = "ip" +config.FIELD.INVALID = "invalid" + + +return config diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/.wisers.lua.swp b/drivers/DeepSmart/deepsmart/src/deepsmart/.wisers.lua.swp new file mode 100644 index 0000000000000000000000000000000000000000..dbea0cf7d93ef66da0172fe6c675d9c58b4d524a GIT binary patch literal 16384 zcmeHOZ>Sts6(1Y5jWvy=6$CBbq|)72cjvu*A&_qKDy5|wVxVc07W0hb?9AQWn>RZ% znLGRbSYJ{R@q-8wYw(L7nxs+arr?z#8=&bjBFGkI-IJ-T;4+ig!-c;07OweMUIXLjFy%WIxxB~g@8 z^IDHM+=?3w+3N+1b3*Rw?Ap2eXrpm>Y}jNs>hVdR^H}!WB&|!gSMT;)!zc)4E- zLh?k~LEjxyRW_6ilniW`fi2d~>4rM$ckI~CZryfh!y3u~B?Bb`B?Bb`B?Bb`B?Bb` zB?Bb`Z(jz|c(Zi^%fG>FVcvYcW90L)c_P!kFw+0uk^VoA^uKBPkD2yEW`b;{MJE05sIq*Di7I++p zfuq1Ia6fPn?UEazcEV+s$$rb{ z^TOjyrf%AoRwg9ctUAwTW>}>%!O|`d2R#^#A&mFpbcqSc)Sqpb-gcBQr#8<-$YNI{ zvU*h7zMHx>M#u@~M@BA)I(C}49{?Kzr) z$a4b_0rfL1#`gd-MIl(A9o4|T)awP8v71QFGz&g4t8I{DD2QCYAV4fY1u+u^zOr17 z88W1zgEpA(bg(YHDzwxv7z;C0MN#a=qDiwjW*M}K>dH*gy0PkGeV!~87Ny%eJRLX$ z4}C>6X6z^0e{#E0>KSGx9}1%sJ|j}@S7-uSd0}^`>;-Rzwz9?i`oM&_bZ33-nhCQbL;0c^Ls!8uT=lc^@yC1^GqcD?I za!vr7tsrb^S~7B!?CcH_2F*K;z~uO}y+H6Vt@iuEuQ3s8M>Mi)%4ND)(R+;2rtJ1p zKUxT@TI;wS^=TW}k+D&Q!xgTs3^Lj)^-WW@$tR zs)}!jUI=$Z4@5H-4W6osEasy=WfC+NM^70`AQTd?I?aTyKt8f!a1AGeeVB zD#sjD4Wq>j)J<6R{DWUiP>_HQr7YwNMj3OHs88yF?1IA8!&-D3_*w{Iq8+gXTu9k( z$)Ye2AvcPXekd8IeXyNVa%673Yi&8!b_2<=tD-?cT%TIWcXjNr5u$No99K%w2{}bc z5%|s=Un(B&(X%R~qZK%KCnx3XbFmeS_o-bVUdsN7@@pd&Z>`GfH*S7 z|CNFX=6y)rHgOWAkrxF;cIx%w)L5E>7!d=)U(6Ae5ylw$5LINFh_GWLmoQs&LY^)} z$s7%5jT(z1S#JzO0)CF4UmY6JRda?a$w8`6Ut^V~imZ%jIkXL>R+$*fgu0KaiOHHR z-Vxynb7$=}Jz%I;&2Cu87GRdC_Kh1UcPPL`jw3JzMQIfFdo7;O9`ydNZuyM-1Z)o1 zw#uGCcD5bO_?0%U*OJMvv3*DJPlvb79DUbG6%rY3Ro#7*xoDkw_l?2t!Rqk^w{gxt z#vEAXL)pO@`q8*&<>Lw+DO~sJcr;;%rtqDR)$e6t+BHXPC6Q?okx0rAaP-Pnl4_7t z8`*4{6XJ*+2s|83=I@(LCxbRbALOeMRO1^mLHyOT>CI6_X%FWJY&Dt`tgfYv`iGi^ z+(QxKQj~C?6pgN@BF)q}(ka~d9PW*%IzrnlylnAEjnM~*MKPrxE`0l6TPApkn9|M1mj}Q7o*Nfhba>NEmZg1257uqw)Zkvi?}Q)giv*E$Z2gWOZEkrI~`m7-&y(Q_4D zgQ;a|qseWFK%_z?vRWBc6nSlsqo#o#bAOW^as0f6fG3#iGde*Y=(1K>DtJ8%Q=66)|LfI~nP_#^7>Ujr`y=Yc1HBfzZy z)#E3CM}hYP=TTQb3p@qHz+u1!sNUWNyo!4IBp`r2z-!>-Z@|mIi@!kqprfVRL`ktsYiXu zR!c9WL`@AEypxjP(g{$j=Uzx<^wMgyrWr=*3q6!Vc~P&2vcz_ijrORCu z{8?d0vN|+p8m262qf|lZ$fCHa8!D)5l3qk_YxYB5QO&hBVqy7`r*Q;R-_@9!+K6FS zRibuDJH7tKUUf|yb$~+Vl;Y(k5;wfI;*#z`xS>Ij(3AbJ zlOLk>D3LY&;S+Ex6jYBGJ8i%<@@{-pr@ifl1(#U!v}1tTH|)QD2e^jk9V^I;&DeU+ zJDo=i94|w&BTSvyD^i^uHmS5mZQ;P!PHTxu6#7bqiH(BV?|bQ9l)FAeY6y3^cmZ8> z#Pupyu|D3G-;Xi`DIU`N+Qv*zBi>@$(-N-E_%w2L>#N>tW&YtJ*Jb)fBmoYxAvwk-mt}sITxbw_T#SUUArbHr|-sN+_Vtn@%{_RL2d|z=&3pe4W;qF zEUQ4iqHLqSa|(}=Ud6I%+`-{(QrcDhtKdo*SkbP9_!;0eN}#QYi}y-=%LS2o-A|JI P|25UuKT%Q{%7pblBN$U+ literal 0 HcmV?d00001 diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua new file mode 100644 index 0000000000..e5a72fa74c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/api.lua @@ -0,0 +1,227 @@ +local log = require('log') +local RestClient = require "lunchbox.rest" +local utils = require "utils" +local json = require('st.json') + +local Api = {} +Api.__index = Api +--------------- +-------------------- +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "peer", + options = "all", + cafile = "./selfSignedRoot.crt" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + + +function Api.client(wiser_index_code, ip) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + ip = ip, + client = nil + }, Api) + + return ret +end + +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + return response:get_body(), nil, response.status + else + return nil, "no response or error received", nil + end +end + +local function retry_fn(retry_attempts) + local count = 0 + return function() + count = count + 1 + return count < retry_attempts + end +end + +function Api:do_get(url) + -- get url + local client = RestClient.new("https://"..self.ip, utils.labeled_socket_builder(self.wiser_index_code, SSL_CONFIG)) + if (client == nil) then + log.warn('do_get url '..url..' client is nil') + return nil,'client nil',404 + end + log.debug('do_get '..url) + local response,err,partial = client:get(url, ADDITIONAL_HEADERS, retry_fn(3)) + client:shutdown() + client = nil + return process_rest_response(response,err,partial) +end + +function Api:do_post(url, content) + -- get url + local client = RestClient.new("https://"..self.ip, utils.labeled_socket_builder(self.wiser_index_code, SSL_CONFIG)) + if (client == nil) then + log.warn('do_post url '..url..' client is nil') + return nil,'client nil',404 + end + log.debug('do_post '..url..' content '..content) + local response,err,partial = client:post(url, content, ADDITIONAL_HEADERS, retry_fn(3)) + if (err ~= nil) then + log.warn('post url '..url..' content '..content..' error '..err) + else + if (response == nil or response:get_body() == nil) then + log.warn('post url '..url..' content '..content..' res nil') + else + log.trace('post url '..url..' content '..content..' res '..response:get_body()) + end + end + client:shutdown() + client = nil + return process_rest_response(response,err,partial) +end + +------------ +-- load config from wiser +------------ +function Api:load_config() + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/devices' + local retry = 2 + while (retry > 0) do + log.trace('begin to load devices '..dest_url..' retry '..retry) + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + if (code ~= nil) then + log.warn('load config error code '..code) + else + log.warn('load config code nil') + end + end + return false,nil +end +------------ +-- load dp2knx config from wiser +------------ +function Api:load_dp2knx() + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/dp2knx' + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + if (code ~= nil) then + log.warn('load dp2knx code '..code) + else + log.warn('load dp2knx code nil') + end + end + return false,nil +end +------------ +-- load dpenum config from wiser +------------ +function Api:load_dpenum(wiser) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/dpsamenum' + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_get(dest_url) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load config {}') + return true,'{}' + end + return true,res_body + end + retry = retry - 1 + end + return false,nil +end +------------ +-- load changed devs +------------ +function Api:load_changeddevs(last_time) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/config/changeddevs' + local t = last_time + if (t == nil) then + t = "0" + end + local content = '{\"command\":\"getchangeddevs\","params":{"time":"'..t..'"}}' + local res_body,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load changeddevs {}') + return true,'{}' + end + return true,res_body + end + return false,nil +end +------------ +-- query devs +------------ +function Api:query(addrs) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/query' + local req = {command='query',addrs=addrs} + local content = json.encode(req) + local retry = 2 + while (retry > 0) do + local res_body,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + if (res_body == nil) then + log.trace('load changeddevs {}') + return '{}' + end + return res_body + end + retry = retry - 1 + end + return nil +end +------------ +-- control devs +------------ +function Api:control(addr, type, val) + local dest_url = 'https://'..self.ip..'/homecontroller/api/v1/control' + local cmdlist = {} + cmdlist[1] = {addr=addr,type=type,val=tonumber(val),delay=0} + local req = {command='control',cmdlist=cmdlist} + local content = json.encode(req) + local retry = 2 + while (retry > 0) do + local _,_,code = self:do_post(dest_url, content) + -- Handle response + if code == 200 then + return true + end + retry = retry - 1 + end + return false +end + + + +return Api + diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua new file mode 100644 index 0000000000..d9e06be339 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/devices.lua @@ -0,0 +1,270 @@ +local log = require('log') +local json = require('st.json') + +local Devices = {} +Devices.__index = Devices + + +--device +----wiser_index_code +----id +----uuid +----name +----type +----roomid +----location +----productId +----productType +----roomName +----dps +------dpId +------dpName +------name +------dataType +------value +------protocolObjId + +--protocols +----protocolObjId +----feedbackList +------addr +------dataType +------value +------min +------max +----sendList +------addr +------dataType +------value +------min +------max + +----------- +-- convert knx addr from 'a/b/c' to integer +-- bits 5 3 8 +-- knx a b c +-- a is high bits c is low bits +----------- +function Devices.parse_addr(addr) + local tmp = addr + local b,e = string.find(tmp, '/') + local fir = tonumber(string.sub(tmp, 1, b-1)) + tmp = string.sub(tmp, e+1) + b,e = string.find(tmp, '/') + local sec = tonumber(string.sub(tmp, 1, b-1)) + local thr = tonumber(string.sub(tmp, e+1)) + if (fir > 15) then + return ((fir << 12)&0xF000) | (0x800|(sec << 8)) | thr; + else + return (fir << 12) | (sec << 8) | thr; + end +end + +--------------- +-- load config from wiser +-- old_config is the prev config, config will be compared with old_config to find add/dev devs +--------------- +function Devices.load_config(wiser_index_code, config, old_config, add_devs, del_devs) + local device = setmetatable({ + wiser_index_code = wiser_index_code, + devices = {}, + scenes = {}, + protocols = {}, + protocol2dps = {}, + addrs = {} + }, Devices) + if (config == nil) then + return device + end + -- if config has no devices then just return + local _,js = pcall(json.decode, config) + if (js == nil or js.devices == nil) then + log.trace('parse config nil') + return device + end + -- make protocols to protocol map + for i,v in pairs(js.protocolObjs) do + log.trace('parse protocolObj '..v.id) + device.protocols[v.id] = v + end + -- parse all devs + for i,v in pairs(js.devices) do + local id = v.id + local pid = v.productId + if (pid == nil) then + goto continue + end + -- use wiser:pid:id as the unique dev id + local dev_id = wiser_index_code..':'..pid..':'..id + v.dev_id = dev_id + v.wiser_index_code = wiser_index_code + v.dpids = {} + device.devices[dev_id] = v + log.trace('parse dev id '..id..' pid '..pid..' devId '..dev_id) + if (v.dps == nil) then + goto continue + end + -- parse dev all dps + for _,dp in pairs(v.dps) do + log.trace('parse dev id '..dev_id..' dpid '..dp.dpId) + local dpid = tonumber(dp.dpId) + v.dpids[dpid] = dp + -- if dp has no protocol then we just ignore + -- protocol is the read/write knx config for cur dp + if (dp.protocolObjId ~= nil) then + local dpmap = device.protocol2dps[dp.protocolObjId] + if (dpmap == nil) then + device.protocol2dps[dp.protocolObjId] = {} + dpmap = device.protocol2dps[dp.protocolObjId] + end + local dp_info = {} + dp_info.dev_id = dev_id + dp_info.dpid = dpid + dpmap[#dpmap+1] = dp_info + -- addr + -- find protocol by protocolObjId + local protocol = device.protocols[dp.protocolObjId] + if (protocol ~= nil) then + if (protocol.sendList ~= nil) then + for i1,v1 in pairs(protocol.sendList) do + local addr = Devices.parse_addr(v1.addr) + v1.addr_int = addr + log.trace('protocol '..dp.protocolObjId..' sendList addr '..v1.addr..' val '..addr) + local addr_map = device.addrs[addr] + if (addr_map == nil) then + device.addrs[addr] = {} + addr_map = device.addrs[addr] + end + addr_map[#addr_map+1] = dp_info + end + end -- sendList + if (protocol.feedbackList ~= nil) then + for i1,v1 in pairs(protocol.feedbackList) do + local addr = Devices.parse_addr(v1.addr) + v1.addr_int = addr + log.trace('protocol '..dp.protocolObjId..' feedbackkList addr '..v1.addr..' val '..addr) + local addr_map = device.addrs[addr] + if (addr_map == nil) then + device.addrs[addr] = {} + addr_map = device.addrs[addr] + end + local exist = false + for _,v in pairs(addr_map) do + if (v.dev_id == dp_info.dev_id and v.dpid == dp_info.dpid) then + exist = true + break; + end + end + if (not exist) then + addr_map[#addr_map+1] = dp_info + end + end + end -- feedbackList + end -- if protocol exists + end -- if dp has protocol + end -- end dev dps terator + ::continue:: + end -- end devs iterator + -- make all load devices as new devices + -- add same device(same device_neywork_id) will do nothing for samsung hub + -- if hub delete some device from app. Reload config will make the deleted device to normal device + if (true and add_devs ~= nil) then + for _,dev in pairs(device.devices) do + add_devs[dev.dev_id] = dev + log.trace('dev '..dev.dev_id..' add') + end + end + -- compare with old_config to find the del_devs + -- ie. old config has device A,B,C,D + -- new config has device A,B,E, then C&&D should delete from hub + if (del_devs ~= nil and add_devs ~= nil and old_config ~= nil and old_config.devices ~= nil) then + for _,dev in pairs(old_config.devices) do + if (add_devs[dev.dev_id] == nil) then + log.info('dev '..dev.dev_id..' is deleted') + del_devs[dev.dev_id] = dev + end + end + end + return device +end +------------------ +function Devices:get_device(id) + return self.devices[id] +end + + +function Devices:get_addr_dev_dpid(addr) + return self.addrs[addr] +end + +function Devices:get_dev_dpid_addr(dev_id, dpid) + local device = self:get_device(dev_id) + if (device == nil) then + log.warn('dev '..dev_id..' is not exist') + return nil,nil + end + for i,dp in pairs(device.dps) do + log.trace('dev '..dev_id..' dpId '..dp.dpId..' params dpid '..dpid) + if (dp.dpId == tostring(dpid)) then + local protocolObjId = dp.protocolObjId + if (protocolObjId == nil) then + log.trace('dev '..dev_id..' dp '..dpid..' protocol is nil') + return nil,nil + end + log.trace('dpid '..dpid..' protocol '..protocolObjId) + -- get protocols + local protocol = self.protocols[protocolObjId] + if (protocol == nil) then + log.warn('protocol '..protocolObjId..' is not exist') + return nil,nil + end + -- find sendList && feedbackList + local send_list = {} + local feedback_list = {} + if (protocol.sendList ~= nil) then + for _,v in pairs(protocol.sendList) do + log.trace('add send addr '..v.addr) + send_list[v.addr] = v + end + end + if (protocol.feedbackList ~= nil) then + for _,v in pairs(protocol.feedbackList) do + log.trace('add recv addr '..v.addr) + feedback_list[v.addr] = v + end + log.trace('sendlist count '..#send_list..' feedback_list count '..#feedback_list) + return send_list,feedback_list + end + end + end + return nil,nil +end + + +function Devices:get_dev_addrs(dev_id) + local device = self:get_device(dev_id) + if (device == nil) then + log.warn('device '..dev_id..' is not exist') + return nil,nil + end + local send_list = {} + local feedback_list = {} + for i,dp in pairs(device.dpids) do + local send,recv = self:get_dev_dpid_addr(dev_id, dp.dpId) + if (send ~= nil) then + for addr,v in pairs(send) do + send_list[dp.dpId] = v + log.trace('get dev '..dev_id..' dpid '..dp.dpId..' send addr '..addr) + end + end + if (recv ~= nil) then + for addr,v in pairs(recv) do + feedback_list[dp.dpId] = v + log.trace('get dev '..dev_id..' dpid '..dp.dpId..' recv addr '..addr) + end + end + end + return send_list,feedback_list +end + +return Devices diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua new file mode 100644 index 0000000000..aea32e0813 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua @@ -0,0 +1,163 @@ +local log = require('log') +local json = require('st.json') +local config = require('config') + +local Dp2Knx = {} +Dp2Knx.__index = Dp2Knx +Dp2Knx.pids = { + ['cekfhkz5'] = {pid='cekfhkz5',type=config.ENUM.AC, devs={[1]={[1]=0,[4]=1,[5]=2,[2]=3,[3]=4,[101]=5}}}, + ['drrearrq'] = {pid='drrearrq',type=config.ENUM.HEATER, devs={[1]={[1]=0,[24]=2,[16]=3}}}, + ['w35ineur'] = {pid='w35ineur',type=config.ENUM.NEWFAN, devs={[1]={[1]=0,[12]=1}}}, + ['fzjliiuu'] = {pid='fzjliiuu',type=config.ENUM.SWITCH, devs={[1]={[1]=0}}}, + ['a2u6yabn'] = {pid='a2u6yabn',type=config.ENUM.SLIDER, devs={[1]={[101]=0,[102]=1}}}, + ['uyyvbtjs'] = {pid='uyyvbtjs',type=config.ENUM.HUE, devs={[1]={[101]=0,[102]=1,[24]=2}}}, + ['oruqmmxg'] = {pid='oruqmmxg',type=config.ENUM.HUE, devs={[1]={[101]=0,[102]=1,[24]=2}}} +} +------------------ +-- load wiser dp2knx config +-- for same pid, dp2knx loaded from wiser will cover the default config +------------------ +function Dp2Knx.load_config(wiser_index_code, data) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + pids = Dp2Knx.pids + }, Dp2Knx) + if (data ~= nil) then + -- parse data + local _,js = pcall(json.decode, data) + if (js ~= nil and js.pids ~= nil) then + for k,v in pairs(js.pids) do + local pid = v.pid + log.info('load dp2knx pid '..pid) + ret.pids[pid] = v + end + end + end + return ret +end +------------------ +-- get pid type +------------------ +function Dp2Knx:get_pid_type(pid) + log.trace('get pid '..pid..' devtype') + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + log.warn('pid '..pid..' not configed') + return nil + end + return dp2knx.type +end +------------------- +-- pid+dpid->addrtype +-- get dev addrtype by pid&&dpid +-- returns: +-- devtype,addrtype,idx(dev index) +------------------- +function Dp2Knx:get_addrtype_by_pid_dpid(pid, dpid) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + log.warn('pid '..pid..' not exist in dp2knx') + return nil,nil,nil + end + for i,v in pairs(dp2knx.devs) do + -- i1->dpid v1->addrtype + for i1,v1 in pairs(v) do + if (tostring(i1) == tostring(dpid)) then + return dp2knx.type,v1,i + end + end + end + log.trace('pid '..pid..' dpid '..dpid..' is not configed') + return dp2knx.type,nil,nil +end +-- get pid addrtype dpid +function Dp2Knx:get_dpid_by_pid_addrtype(pid, idx, addrtype) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + for i,v in pairs(dev) do + if (v == addrtype) then + if (type(i) == "string") then + return tonumber(i) + else + return i + end + end + end + return nil +end + +function Dp2Knx:get_dev_addr_types(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + local addr_types = {} + local idx = 1 + for i,v in pairs(dev) do + addr_types[idx] = v + idx = idx + 1 + end + return addr_types +end + + +function Dp2Knx:get_dev_count(pid) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return 0 + end + if (dp2knx.devs == nil) then + return 0 + end + return #dp2knx.devs +end + + +function Dp2Knx:get_dev_addr_dpids(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + return dev +end + +function Dp2Knx:get_dev_dpids(pid, idx) + local dp2knx = self.pids[pid] + if (dp2knx == nil) then + return nil + end + local dev = dp2knx.devs[idx] + if (dev == nil) then + return nil + end + local dpids = {} + local idx = 1 + for i,v in pairs(dev) do + if (type(i) == "string") then + dpids[idx] = tonumber(i) + else + dpids[idx] = i + end + idx = idx + 1 + end + return dpids +end + + + + +return Dp2Knx diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua new file mode 100644 index 0000000000..f1333ce255 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/dpenum.lua @@ -0,0 +1,99 @@ +local json = require('st.json') + +local DpEnum = {} +DpEnum.__index = DpEnum + +---------------- +-- dpenum default config +-- ac&&newfan are default configed +-- for ac: +-- mode +-- samsung cool heat fan dry auto +-- deepsmart 3 1 9 0 2 +-- fanspeed +-- samsung low medium high auto +-- deepsmart 1 3 5 0 +-- for newfan +-- fanspeed +-- samsung low medium high auto +-- deepsmart 1 3 5 0 +---------------- +DpEnum.pids = { + ['cekfhkz5'] = {pid='cekfhkz5',dpids={{dpid=4,devDp='mode',enums={['3']='cool',['1']='heat',['9']='fan',['0']='dry',['2']='auto'}},{dpid=5,devDp='fanMode',enums={['1']='low',['3']='medium',['5']='high',['0']='auto'}}}}, + ['w35ineur'] = {pid='w35ineur',dpids={{dpid=12,devDp='fanMode',enums={['1']='low',['3']='medium',['5']='high',['0']='auto'}}}} +} +--dpenum +----dpid:cmd +----dpid_enum +------[dpEnum:devEnum] +--------------- +-- load dpenum from wiser +-- for same pid config, wiser dpenum will cover the hub initial config +--------------- +function DpEnum.load_config(wiser_index_code, data) + local ret = setmetatable({ + wiser_index_code = wiser_index_code, + pids = DpEnum.pids + }, DpEnum) + if (data ~= nil) then + -- parse data + local _,js = pcall(json.decode, data) + if (js ~= nil and js.pids ~= nil) then + -- cover pid config to pids + for k,v in pairs(js.pids) do + local pid = v.pid + ret.pids[pid] = v + end + end + end + return ret +end + +------------------ +-- get pid dpenum +------------------ +function DpEnum:get_pid(pid) + local pidenum = self.pids[pid] + return pidenum +end + +------------------ +-- dpid+dpid+dev_val->pid_val +-- get deepsmart enum value by samsung enum val +------------------ +function DpEnum:get_pid_val_by_dev_val(pid, dpid, dev_val) + local pidenum = self.pids[pid] + if (pidenum == nil) then + return nil + end + for i,v in pairs(pidenum.dpids) do + if (tostring(v.dpid) == tostring(dpid)) then + for i1,v1 in pairs(v.enums) do + if (tostring(v1) == tostring(dev_val)) then + return i1 + end + end + return nil + end + end + return nil +end +-- get pid addrtype dpid +------------------ +-- dpid+dpid+pid_val->dev_val +-- get samsung enum value by deepsmart enum val +------------------ +function DpEnum:get_dev_val_by_pid_val(pid, dpid, pid_val) + local pidenum = self.pids[pid] + if (pidenum == nil) then + return nil + end + for i,v in pairs(pidenum.dpids) do + if (tostring(v.dpid) == tostring(dpid)) then + return v.enums[tostring(pid_val)] + end + end + return nil +end + +return DpEnum diff --git a/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua b/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua new file mode 100644 index 0000000000..f8a0dbf056 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua @@ -0,0 +1,899 @@ +local log = require('log') +local json = require('st.json') +local caps = require('st.capabilities') +local api = require('deepsmart.api') +local Devices = require('deepsmart.devices') +local dp2knx = require('deepsmart.dp2knx') +local dpenum = require('deepsmart.dpenum') +local config = require('config') +local ssdp = require('ssdp') +local Wisers = {} + + +Wisers.wisers = {} +Wisers.idmap = {} +Wisers.ips = {} +---------- +-- parse device_network_id to dev_id,idx +-- deepsmart device's device_network_id is defined as wiser:pid:id[_idx], _idx is optional +-- if one deepsmart device convert to one hub device then idx is ignored +-- else one deepsmart device convert to several hub devices then idx is needed, +-- as wiser:pid:id_1 -> first device +-- as wiser:pid:id_2 -> second device +-- as wiser:pid:id_n -> nth device +---------- +function Wisers.parse_device_network_id(device_network_id) + local dev_id = device_network_id + local idx = 1 + -- if _ exists then idx exists + local beginpos,endpos = string.find(dev_id, '_') + if (beginpos ~= nil) then + dev_id = string.sub(device_network_id, 1, beginpos-1) + local str = string.sub(device_network_id, endpos+1) + idx = tonumber(str) + end + -- find pid + local pid = nil + beginpos,endpos = string.find(dev_id, ':') + if (beginpos ~= nil) then + local str = string.sub(device_network_id, endpos+1) + local pos,_ = string.find(str, ':') + if (pos ~= nil) then + pid = string.sub(device_network_id, 1, pos-1) + end + end + return dev_id,pid,idx +end +--------------- +-- Parse protocol +function Wisers.get_wiser(wiser_index_code) + return Wisers.wisers[wiser_index_code] +end + + +-- check if device is bridge +function Wisers.is_device_bridge(device) + if (device.parent_assigned_child_key == nil) then + return true + end + return false +end +------------- +-- it runs every 30s +-- it will find new wisers around and refresh the binded wiser info when wiser is online +------------- +function Wisers.check_ip(discovery) + -- find all wisers + ssdp.search(discovery.ssdp_checkip_callback) +end + +-- try update wiser(if added) ip +function Wisers.update_wiser_ip(uuid, ip) + local wiser = Wisers.get_wiser(uuid) + if (wiser == nil) then + log.warn('wiser '..uuid..' not exist') + return + end + if (wiser.ip ~= ip) then + log.info('wiser '..uuid..' change ip to '..(ip or '')) + wiser["ip"] = ip + if (wiser.api ~= nil) then + wiser.api.client:shutdown() + end + if (wiser.loopapi ~= nil) then + wiser.loopapi.client:shutdown() + end + wiser["api"] = api.client(uuid, ip) + wiser["loopapi"] = api.client(uuid, ip) + end +end + +function Wisers.wiser_loop(wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn('wiser '..wiser_index_code..' not exist') + return + end + if (wiser.config == nil) then + log.trace('wiser has no devices') + return + end + if (wiser.loopapi == nil) then + log.trace('wiser api is not inited') + return + end + -- get wiser + -- query changed devs + local ret,res = wiser.loopapi:load_changeddevs(wiser.last_active_time) + if (ret) then + log.trace('load changed devs '..res..' old state '..wiser.state) + -- parse changed devs + local _,js = pcall(json.decode, res) + if (js ~= nil and js.data ~= nil and js.data.last ~= nil and js.data.devs ~= nil) then + wiser.last_active_time = js.data.last + log.trace('change wiser '..wiser_index_code..' last time '..wiser.last_active_time) + for _,v in pairs(js.data.devs) do + -- dev empty is wiser + if (v == '') then + -- refresh subdevice status + Wisers.driver:inject_capability_command(wiser.bridge, { + capability = caps.refresh.ID, + command = caps.refresh.commands.refresh.NAME, + args = {} + }) + else + log.trace('query changed dev '..v) + Wisers.query_deviceid(v, true) + end + end + end + -- has response means bridge is online + wiser.bridge:online() + if (wiser.state == 0) then + log.trace('wiser '..wiser_index_code..' online') + wiser.state = 1 + Wisers.refresh_wiser_devices(wiser_index_code) + end + else + log.trace('load changed devs nil old state '..wiser.state) + -- no response means bridge is offline + if (wiser.state == 1) then + wiser.state = 0 + log.trace('wiser '..wiser_index_code..' offline') + wiser.bridge:offline() + end + end +end + + +function Wisers.create_device(device) + log.info('===== CREATING DEVICE...') + local wiser = Wisers.get_wiser(device.wiser_index_code) + if (wiser == nil) then + log.warn('device '..device.dev_id..' wiser '..device.wiser_index_code..' not exist') + return 0 + end + if (wiser.bridge == nil) then + log.warn('device '..device.dev_id..' wiser '..device.wiser_index_code..' bridge not inited') + return 0 + end + if (device.productId == nil) then + log.trace('ignore device '..device.dev_id..' with null pid') + return 0 + end + -- parse type + local dev_type = wiser.dp2knx:get_pid_type(device.productId) + if (dev_type == nil) then + log.warn('device '..device.dev_id..' productId '..device.productId..' is not configed') + return 0 + end + log.trace('dev '..device.dev_id..' productId '..device.productId..' dev_type '..dev_type) + if (config.DEVICE_PROFILE[dev_type] == nil) then + log.warn('device type '..dev_type..' is not supported now') + return 0 + end + -- find device count + local dev_count = wiser.dp2knx:get_dev_count(device.productId) + if (dev_count == nil or dev_count == 0) then + log.warn('pid '..device.productId..' has no devs configed') + return 0 + end + log.info('pid '..device.productId..' has dev count '..dev_count) + for i = 1,dev_count do + local dev_name = device.name + local dev_id = device.dev_id + if (dev_count > 1) then + dev_name = device.name..'_'..i + dev_id = device.dev_id..'_'..i + end + log.info('create device '..dev_id..' i '..i..' name '..dev_name) + -- device metadata table + local metadata = { + type = 'EDGE_CHILD', + label = dev_name, + profile = config.DEVICE_PROFILE[dev_type], + manufacturer = 'deepsmart', + model = 'deepsmart', + vendor_provided_label = device.name, + parent_device_id = wiser.bridge.id, + parent_assigned_child_key = dev_id + } + Wisers.driver:try_create_device(metadata) + end + return 0 +end + +------------ +-- refresh single wiser +-- load wiser all configs and create new device +------------ +function Wisers.refresh_wiser(wiser_index_code) + log.info('refresh wiser '..wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn(' wiser '..wiser_index_code..' not exist') + return 0 + end + local add_devs = {} + local del_devs = {} + Wisers.reload(wiser_index_code, add_devs, del_devs) + log.trace('refresh_wiser '..wiser_index_code..' add '..#add_devs..' del '..#del_devs) + -- add devices + for _,v in pairs(add_devs) do + Wisers.create_device(v) + end + log.trace('refresh_wiser '..wiser_index_code..' after create device') + -- del devices + for dev_id,_ in pairs(del_devs) do + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.trace('dev '..dev_id..' has no hub device') + goto delretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto delretry + else + log.info('del dev_id '..dev_id..' hub id '..id..' device') + dev:set_field(config.FIELD.INVALID, 1, { persist = true}) + end + ::delretry:: + end + -- save device_network_id->id + local devices = Wisers.driver:get_devices() + for i,v in pairs(devices) do + if (v.parent_assigned_child_key ~= nil) then + log.debug('save dev '..i..' id '..v.id..' networkid '..v.parent_assigned_child_key) + Wisers.idmap[v.parent_assigned_child_key] = v.id + end + end + -- refresh wiser devices online + Wisers.refresh_wiser_devices(wiser_index_code) + log.info('refresh wiser success') + return true,nil +end + +function Wisers.refresh_wiser_devices(wiser_index_code) + log.info('refresh wiser '..wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn(' wiser '..wiser_index_code..' not exist') + return false,'wiser not exist' + end + -- refresh wiser devices online + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + for dev_id,_ in pairs(wiser.config.devices) do + -- find hub dev + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.warn('dev '..dev_id..' is not in hub idmap') + goto devretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto devretry + end + local flag = dev:get_field(config.FIELD.INVALID) + if (flag == nil or flag == 0) then + log.trace('dev '..dev_id..' online') + dev:online() + else + log.trace('dev '..dev_id..' offline') + dev:offline() + end + ::devretry:: + end + end + log.info('refresh wiser devices success') + return true,nil +end + +------------------ +-- add wiser +-- create wiser client(https) +-- api: load device configs, dp2knx config, dpenum config +------------------ +function Wisers.add_wiser(uuid, ip, bridge) + -- init all wisers + log.info('add wiser '..uuid..' ip('..(ip or 'nil')..')') + local wiser = Wisers.get_wiser(uuid) + -- first check wiser ip, if ip is same then do nothing + -- in case the wiser's ip changed by dhcp or router restart or router changed or mannal static ip change + if (wiser ~= nil) then + if (wiser.ip == ip) then + log.trace('wiser '..uuid..' is same') + else + Wisers.update_wiser_ip(uuid, ip) + end + return + end + -- if wiser is new then create new wiser + if (wiser == nil) then + -- create bridge device + -- if bridge is nil then new bridge is added + -- after try_create_device just return(in lifecycle added func will add new wiser) + if (bridge == nil) then + local wiser_device_msg = { + type = "LAN", + device_network_id = uuid, + label = "DEEPSMART KNX bridge-"..uuid, + profile = "Deepsmart.bridge", + manufacturer = "DEEPSMART", + model = "KNX gateway", + vendor_provided_label = "DEEPSMART KNX bridge" + } + -- save wiser ip + Wisers.ips[uuid] = ip + log.trace('create bridge device') + Wisers.driver:try_create_device(wiser_device_msg) + return + end + log.debug('create wiser '..uuid) + Wisers.wisers[uuid] = {} + wiser = Wisers.wisers[uuid] + wiser.bridge = bridge + wiser.state = 1 + wiser.ctrlmap = {} + wiser["wiser_index_code"] = uuid + wiser["last_active_time"] = "0" + wiser["ip"] = ip + -- wiser online + bridge:online() + if (ip ~= nil) then + wiser["api"] = api.client(uuid, ip) + wiser["loopapi"] = api.client(uuid, ip) + end + local config_str = bridge:get_field(config.FIELD.DEVICES) + local dp2knx_str = bridge:get_field(config.FIELD.DP2KNX) + local dpenum_str = bridge:get_field(config.FIELD.DPENUM) + wiser["config"] = Devices.load_config(uuid, config_str, nil, nil, nil) + wiser["dp2knx"] = dp2knx.load_config(uuid, dp2knx_str) + wiser["dpenum"] = dpenum.load_config(uuid, dpenum_str) + end + -- wiser loop for get updated devices from wiser + -- every 2second use https api to get updated devices from given time + -- after get results save the last time for next request + Wisers.driver:call_on_schedule( + 2, + function () + return Wisers.wiser_loop(uuid) + end, + 'wiser loop') + log.info('add wisers over') +end +------------ +-- del device +------------ +function Wisers.del_device_by_id(devid) + local id = Wisers.idmap[devid] + if (id == nil) then + log.warn('dev '..devid..' is not in hub') + return false + end + log.info('dev '..devid..' hub id '..id..' delete from hub') + Wisers.driver:try_delete_device(id) + return true +end +function Wisers.del_device(device) + local parent_assigned_child_key = device.parent_assigned_child_key + log.info('del device device_network_id '..device.device_network_id) + if (parent_assigned_child_key == nil) then + log.info('del bridge '..device.device_network_id) + Wisers.del_wiser(device.device_network_id) + Wisers.driver:try_delete_device(device.id) + else + -- del device from hub + log.info('del device '..device.id) + Wisers.driver:try_delete_device(device.id) + local dev_id,_,idx = Wisers.parse_device_network_id(parent_assigned_child_key) + log.trace('del device '..parent_assigned_child_key..' -> dev_id '..dev_id..' idx '..idx) + -- get device wiser + local wiser,_ = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..dev_id..' is not exist for del device') + return false,'wiser is nil' + end + -- del config dev + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + wiser.config.devices[dev_id] = nil + end + end +end +------------ +-- unbind the wiser +-- it's not used now(no scene to use) +------------ +function Wisers.del_wiser(wiser_index_code) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + return false + end + wiser.state = 0 + Wisers.wisers[wiser_index_code] = nil + log.info('wiser '..wiser_index_code..' del over') + return true +end +--------------- +-- get device wiser +-- returns +-- wiser,device +--------------- +function Wisers.get_device_wiser(dev_id) + -- check all wisers to find the device + for i,wiser in pairs(Wisers.wisers) do + if (wiser.config ~= nil and wiser.config.devices ~= nil) then + local device = wiser.config.devices[dev_id] + if (device ~= nil) then + return wiser,device + end + end + end + return nil,nil +end + + +-- reload devices&&dps +function Wisers.reload(uuid, add_devs, del_devs) + local wiser = Wisers.get_wiser(uuid) + if (wiser == nil or wiser.bridge == nil) then + log.warn('wiser '..uuid..' is not exist for reload') + return true + end + log.info('reload wiser '..wiser.wiser_index_code..' ip('..(wiser.ip or '')..')') + -- if wiser ip is not changed then do nothing + local deepsmartapi = wiser.api + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false + end + -- get devices + local configret,configstr = deepsmartapi:load_config() + log.trace(string.format('load config (%s)', configstr)) + if (configret and configstr ~= wiser.bridge:get_field(config.FIELD.DEVICES)) then + local old_config = wiser.config + wiser["config"] = Devices.load_config(wiser.wiser_index_code, configstr, old_config, add_devs, del_devs) + wiser.bridge:set_field(config.FIELD.DEVICES, configstr, { persist = true}) + end + -- get dp info + local dp2knxret,dp2knx_config = deepsmartapi:load_dp2knx() + if (dp2knxret and dp2knx_config ~= wiser.bridge:get_field(config.FIELD.DP2KNX)) then + wiser["dp2knx"] = dp2knx.load_config(wiser.wiser_index_code, dp2knx_config) + wiser.bridge:set_field(config.FIELD.DP2KNX, dp2knx_config, { persist = true}) + end + -- get enum info + local dpenumret,dpenum_config = deepsmartapi:load_dpenum() + if (dpenumret and dpenum_config ~= wiser.bridge:get_field(config.FIELD.DPENUM)) then + wiser["dpenum"] = dpenum.load_config(wiser.wiser_index_code, dpenum_config) + wiser.bridge:set_field(config.FIELD.DPENUM, dpenum_config, { persist = true}) + end + return true +end + +-------------- +-- refresh hub device +-- just query deepsmart device +-------------- +function Wisers.refresh(device) + return Wisers.query(device, false) +end + + +------------ +-- process deepsmart knx control command +-- same with response +------------ +function Wisers.knx_control(wiser_index_code, addr, dataType, value, delay) + local vals = {[addr] = value} + log.info('control addr '..addr..' dataType '..dataType..' value '..value) + return Wisers.knx_response(wiser_index_code, vals) +end + +------------ +-- process deepsmart knx response +-- refresh hub device status +------------ +function Wisers.knx_response(wiser_index_code, knxes) + local wiser = Wisers.get_wiser(wiser_index_code) + if (wiser == nil) then + log.warn('wiser '..wiser_index_code..' is not exist for response addrs') + return 0 + end + -- deal all knxes one by one + for addr,value in pairs(knxes) do + log.info('wiser '..wiser_index_code..' response addr '..addr..' value '..value) + if (wiser.config == nil) then + log.warn('wiser '..wiser_index_code..' has no config') + goto knxretry + end + -- get dps by addr + local dps = wiser.config:get_addr_dev_dpid(addr) + if (dps == nil) then + log.warn('addr '..addr..' has no dps') + goto knxretry + end + log.trace('wiser '..wiser_index_code..' response addr '..addr..' get dps count '..#dps) + for i,dp in pairs(dps) do + local dpid = dp.dpid + log.trace(' addr '..addr..' -> dev_id '..dp.dev_id..' dpid '..dpid) + -- convert val to smartthings + local device = wiser.config:get_device(dp.dev_id) + if (device == nil) then + log.warn('dev '..dp.dev_id..' is not exist for response') + goto dpretry + end + local dev_id = dp.dev_id + local dev_count = wiser.dp2knx:get_dev_count(device.productId) + log.trace('pid '..device.productId..' get dev count '..dev_count) + local devtype,addrtype,idx = wiser.dp2knx:get_addrtype_by_pid_dpid(device.productId, dpid) + if (dev_count > 1) then + dev_id = dp.dev_id..'_'..idx + end + -- find hub dev + local id = Wisers.idmap[dev_id] + if (id == nil) then + log.warn('dev '..dev_id..' is not in hub idmap') + goto dpretry + end + log.trace('get dev '..dev_id..' hub devid '..id) + local dev = Wisers.driver:get_device_info(id) + if (dev == nil) then + log.warn('dev '..id..' is not in hub') + goto dpretry + end + if (devtype ~= nil and addrtype ~= nil) then + log.trace('dev '..dev_id..' pid '..device.productId..' convert to devtype '..devtype..' addrtype '..addrtype) + if (devtype == config.ENUM.SWITCH) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (devtype == config.ENUM.AC) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + local mode = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil, mode, nil,nil, nil,nil) + elseif (addrtype == 2) then + local fan = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil,nil,fan,nil, nil,nil) + elseif (addrtype == 3) then + Wisers.driver:ac_report(dev, nil,nil,nil,value/100, nil,nil) + elseif (addrtype == 4) then + Wisers.driver:ac_report(dev, nil,nil,nil,nil, value/100,nil) + end + elseif (devtype == config.ENUM.HEATER) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:heater_report(dev, on_off, nil,nil,nil) + elseif (addrtype == 2) then + Wisers.driver:heater_report(dev, nil,value/100,nil,nil) + elseif (addrtype == 3) then + Wisers.driver:heater_report(dev, nil,nil,value/100,nil) + end + elseif (devtype == config.ENUM.NEWFAN) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + local fan = wiser.dpenum:get_dev_val_by_pid_val(device.productId, dpid, value) + Wisers.driver:ac_report(dev, nil,nil,fan,nil, nil,nil) + end + elseif (devtype == config.ENUM.SLIDER) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + Wisers.driver:set_level(dev, value*100//255) + end + elseif (devtype == config.ENUM.HUE) then + if (addrtype == 0) then + local on_off = 'off' + if (value ~= 0) then + on_off = 'on' + end + Wisers.driver:set_switch(dev, on_off) + elseif (addrtype == 1) then + Wisers.driver:set_level(dev, value*100//255) + elseif (addrtype == 2) then + Wisers.driver:set_hue(dev, value) + end + end + else + log.trace('pid '..device.productId..' dpid '..dpid..' convert to devtype addrtype nil') + end + ::dpretry:: + end + ::knxretry:: + end + return 0 +end + +-------------- +-- get devtype by device_network_id +-------------- +function Wisers.get_dev_type(device) + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype') + if (device.profile.components.main.capabilities.airConditionerMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype ac') + return config.ENUM.AC + elseif (device.profile.components.main.capabilities.thermostatMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype heater') + return config.ENUM.HEATER + elseif (device.profile.components.main.capabilities.airConditionerFanMode ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype newfan') + return config.ENUM.NEWFAN + elseif (device.profile.components.main.capabilities.colorTemperature ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype hue') + return config.ENUM.HUE + elseif (device.profile.components.main.capabilities.switchLevel~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype slider') + return config.ENUM.SLIDER + else + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype switch') + return config.ENUM.SWITCH + end +end + +--------------- +-- test code +-- report default value to control device +--------------- +function Wisers.default_report(driver, device) + local uuid = device.parent_assigned_child_key + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx..' default report') + local wiser,dev = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..device.parent_assigned_child_key..' is not exist for read') + return false,'device not exist' + end + if (dev == nil) then + log.warn('device '..device.dev_id..' is not in wiser devs') + return false,'device is not in wisers' + end + local devtype = wiser.dp2knx:get_pid_type(dev.productId) + log.trace('dev '..dev.productId..' get type '..devtype) + if (devtype == config.ENUM.SWITCH) then + driver:set_switch(device, "off") + elseif (devtype == config.ENUM.SLIDER) then + driver:set_switch(device, "off") + driver:set_level(device, 0) + elseif (devtype == config.ENUM.HUE) then + driver:set_switch(device, "off") + driver:set_level(device, 0) + driver:set_hue(device, 0) + elseif (devtype == config.ENUM.AC) then + driver:ac_report(device, "off", "auto", "auto", 25, 25, nil) + elseif (devtype == config.ENUM.HEATER) then + driver:ac_report(device, "off", nil, nil, 25, 25, nil) + elseif (devtype == config.ENUM.NEWFAN) then + driver:ac_report(device, "off", nil, "low", nil,nil, nil) + end + return true,nil +end + +-------------- +-- query hub device +-- 1 find wiser +-- 2 get all feedback addrs +-- 3 query all addrs +-------------- +function Wisers.query_deviceid(uuid, use_loop) + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx..' query') + local wiser = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..uuid..' is not exist for read') + return false,'device not exist' + end + -- find all feedback addrs + local sendlist,recv = wiser.config:get_dev_addrs(dev_id) + if (recv ~= nil) then + local addrs = {} + local recv2send = {} + local ignore_recv = {} + for dpid,addr in pairs(recv) do + addrs[#addrs+1] = addr.addr_int + log.trace('query dev '..dev_id..' recv addr '..addr.addr_int) + if (sendlist ~= nil and sendlist[dpid] ~= nil) then + local send_addr = sendlist[dpid] + if (send_addr.addr_int ~= addr.addr_int) then + if (wiser.ctrlmap[send_addr.addr_int] ~= nil) then + ignore_recv[addr.addr_int] = 1 + log.trace('ignore recv addr '..addr.addr_int..' as send addr '..send_addr.addr_int..' control is just used') + end + recv2send[addr.addr_int] = send_addr.addr_int + addrs[#addrs+1] = send_addr.addr_int + log.trace('query dev '..dev_id..' send addr '..send_addr.addr_int) + end + end + end + log.trace('query dev '..dev_id..' idx '..idx..' addrs count '..#addrs) + local deepsmartapi = wiser.api + if (use_loop) then + deepsmartapi = wiser.loopapi + end + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false,'api is not inited' + end + --send query + local res = deepsmartapi:query(addrs) + if (res == nil) then + log.warn('query device '..uuid..' empty') + else + log.trace('query device '..uuid..' res '..res) + -- parse res(json) + local _,js = pcall(json.decode, res) + if (js == nil or js.data == nil) then + log.trace('parse query res nil') + return false,'parse query res error' + end + -- make protocols to protocol map + local knxes = {} + local ignore_addrs = {} + for i,v in pairs(js.data) do + -- ignore recv when ctrlmap has send once + if (v ~= nil and v.regaddr ~= nil and v.value ~= nil and ignore_addrs[v.regaddr] == nil and ignore_recv[v.regaddr] == nil) then + knxes[v.regaddr] = v.value + if (recv2send[v.regaddr] ~= nil) then + ignore_addrs[recv2send[v.regaddr]] = 1 + log.trace('recv addr '..v.regaddr..' recv data ignore send addr '..recv2send[v.regaddr]) + end + log.trace('get query res addr '..v.regaddr..' value '..v.value) + end + end + wiser.ctrlmap = {} + -- parse knx response + Wisers.knx_response(wiser.wiser_index_code, knxes) + return true,nil + end + end + return false,'query error' +end + +function Wisers.query(device, use_loop) + local uuid = device.parent_assigned_child_key + return Wisers.query_deviceid(uuid, use_loop) +end +-------------- +-- convert hub command to deepsmart commands and control deepsmart devices +-- device: hub device who is controled +-- command: control params +-- addrtypes: device control type +-------------- +function Wisers.control(device, command, addrtypes) + local capability = command.capability + local cmd = command.command + local uuid = device.parent_assigned_child_key + log.trace('device '..device.parent_assigned_child_key..' capability '..command.capability..' cmd '..command.command) + local dev_id,_,idx = Wisers.parse_device_network_id(uuid) + log.trace('device '..uuid..' -> dev_id '..dev_id..' idx '..idx) + -- get device wiser + local wiser,dev = Wisers.get_device_wiser(dev_id) + if (wiser == nil) then + log.warn('device '..dev_id..' is not exist for control') + return false,'wiser is nil' + end + if (dev == nil) then + log.warn('device '..device.dev_id..' is not in wiser devs') + return false,'device is nil' + end + local ret = false + local deepsmartapi = wiser.api + if (deepsmartapi == nil) then + log.trace('wiser api is not inited') + return false,'api is not inited' + end + for _,addrtype in pairs(addrtypes) do + local dpid = wiser.dp2knx:get_dpid_by_pid_addrtype(dev.productId, idx, addrtype) + if (dpid == nil) then + log.warn('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid nil') + goto addrtype_retry + end + log.trace('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid '.. dpid) + -- get dpid addr + local send_list,_ = wiser.config:get_dev_dpid_addr(dev_id, dpid) + if (send_list == nil) then + log.warn('get pid '..dev.productId..' idx '..idx..' addrtype '..addrtype..' dpid '.. dpid..' has no send addrs') + goto addrtype_retry + end + if (capability == 'switch') then + local value = 0 + if (cmd == 'on') then + value = 1 + end + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'switchLevel') then + local value = command.args.level*255//100 + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'colorTemperature') then + -- samsung color temperature is 1-30000 + -- deepsmart color temperature is 0-255 + local value = (command.args.temperature-1)*255//29999 + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + elseif (capability == 'airConditionerMode') then + local mode = wiser.dpenum:get_pid_val_by_dev_val(dev.productId, dpid, command.args.mode) + if (mode ~= nil) then + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..mode) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = mode + deepsmartapi:control(addr.addr_int, addr.dataType, mode, 0) + end + end + ret = true + elseif (capability == 'airConditionerFanMode') then + local fan = wiser.dpenum:get_pid_val_by_dev_val(dev.productId, dpid, command.args.fanMode) + if (fan ~= nil) then + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..fan) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = fan + deepsmartapi:control(addr.addr_int, addr.dataType, fan, 0) + end + end + ret = true + elseif (capability == 'thermostatHeatingSetpoint') then + -- samsung temperature is xx + -- deepsmart temperature is xx00, temperature/100 is the real value + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..command.args.setpoint*100) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = command.args.setpoint*100 + deepsmartapi:control(addr.addr_int, addr.dataType, command.args.setpoint*100, 0) + end + ret = true + elseif (capability == 'thermostatMode') then + local value = 0 + if (command.args.mode == 'heat') then + value = 1 + end + for i,addr in pairs(send_list) do + log.info('device '..uuid..' control to addr '..addr.addr_int..' value '..value) + -- save control to wiser ctrlmap + wiser.ctrlmap[addr.addr_int] = value + deepsmartapi:control(addr.addr_int, addr.dataType, value, 0) + end + ret = true + end + ::addrtype_retry:: + end + return ret,nil +end + +return Wisers diff --git a/drivers/DeepSmart/deepsmart/src/discovery.lua b/drivers/DeepSmart/deepsmart/src/discovery.lua new file mode 100644 index 0000000000..f824c57615 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/discovery.lua @@ -0,0 +1,45 @@ +local log = require('log') +local Wisers = require "deepsmart.wisers" +local ssdp = require('ssdp') +local net_utils = require('st.net_utils') + +local disco = {} +----------------------- +-- app discover new bridges or new devices +disco.ssdp_discovery_callback = function(uuid, ip) + if (not net_utils.validate_ipv4_string(ip) or uuid == nil) then + log.warn(string.format('ip (%s) or uuid (%s) is invalid', ip, uuid)) + return + end + -- add discovered wiser + Wisers.add_wiser(uuid, ip, nil) + Wisers.refresh_wiser(uuid) +end +-- check whether bridge ip changed +disco.ssdp_checkip_callback = function(uuid, ip) + if (not net_utils.validate_ipv4_string(ip) or uuid == nil) then + log.warn(string.format('ip (%s) or uuid (%s) is invalid', ip, uuid)) + return + end + -- check uuid(if added) + Wisers.update_wiser_ip(uuid, ip) +end +-- Discovery service which will +-- invoke the above private functions. +-- - find_device +-- - parse_ssdp +-- - fetch_device_info +-- - create_device +-- +-- This resource is linked to +-- driver.discovery and it is +-- automatically called when +-- user scan devices from the +-- SmartThings App. +function disco.start(driver, opts, cons) + log.info('in discover start...') + ssdp.search(disco.ssdp_discovery_callback) + log.info('===== DEVICE DISCOVER DEVICES OVER') +end + +return disco diff --git a/drivers/DeepSmart/deepsmart/src/init.lua b/drivers/DeepSmart/deepsmart/src/init.lua new file mode 100644 index 0000000000..b2feab0657 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/init.lua @@ -0,0 +1,158 @@ +local Driver = require('st.driver') +local caps = require('st.capabilities') +local wisers = require('deepsmart.wisers') +local log = require('log') +-- local imports +local discovery = require('discovery') +local lifecycles = require('lifecycles') +local commands = require('commands') + +-------------------- +-- Driver definition +local driver = +Driver( +'DEEPSMART', +{ + discovery = discovery.start, + lifecycle_handlers = lifecycles, + supported_capabilities = { + caps.switch, + caps.switchLevel, + caps.colorTemperature, + caps.refresh, + caps.airConditionerMode, + caps.thermostatHeatingSetpoint, + caps.airConditionerFanMode, + caps.thermostatMode, + caps.thermostatOperatingState + }, + capability_handlers = { + -- Switch command handler + [caps.switch.ID] = { + [caps.switch.commands.on.NAME] = commands.set_switch, + [caps.switch.commands.off.NAME] = commands.set_switch + }, + -- Switch Level command handler + [caps.switchLevel.ID] = { + [caps.switchLevel.commands.setLevel.NAME] = commands.set_level + }, + -- Color Control command handler + [caps.colorTemperature.ID] = { + [caps.colorTemperature.commands.setColorTemperature.NAME] = commands.set_color + }, + -- Refresh command handler + [caps.refresh.ID] = { + [caps.refresh.commands.refresh.NAME] = commands.refresh + }, + [caps.airConditionerMode.ID] = { + [caps.airConditionerMode.commands.setAirConditionerMode.NAME] = commands.set_airconditioner_mode, + }, + [caps.airConditionerFanMode.ID] = { + [caps.airConditionerFanMode.commands.setFanMode.NAME] = commands.set_thermostat_fan_mode, + }, + [caps.thermostatHeatingSetpoint.ID] = { + [caps.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = commands.set_setheatingpoint + }, + [caps.thermostatMode.ID] = { + [caps.thermostatMode.commands.setThermostatMode.NAME] = commands.set_thermostat_mode + } + } +} +) + +--------------------------------------- +-- Switch control for external commands +function driver:set_switch(device, on_off) + device:online() + if on_off == 'off' then + return device:emit_event(caps.switch.switch.off()) + end + return device:emit_event(caps.switch.switch.on()) +end + +-- Switch level control for external commands +function driver:set_level(device, lvl) + device:online() + if lvl == 0 then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + return device:emit_event(caps.switchLevel.level(lvl)) +end + +function driver:set_hue(device, hue) + device:online() + return device:emit_event(caps.colorTemperature.colorTemperature(hue*29999//255+1)) +end + + +-- Switch control for external commands +function driver:ac_report(device, onoff, mode, fan, settemp, temp, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "off") then + device:emit_event(caps.switch.switch.off()) + else + device:emit_event(caps.switch.switch.on()) + end + end + if (mode ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report mode '..mode) + device:emit_event(caps.airConditionerMode.airConditionerMode(mode)) + end + -- fan + if (fan ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report fan '..fan) + device:emit_event(caps.airConditionerFanMode.fanMode(fan)) + end + -- settemp + if (settemp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report settemp '..settemp) + device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=settemp,unit='C'})) + end + if (temp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report temp '..temp) + device:emit_event(caps.temperatureMeasurement.temperature({value=temp,unit='C'})) + end + return true,nil +end + +-- Heater control from bridge +function driver:heater_report(device, onoff, settemp, temp, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "off") then + device:emit_event(caps.thermostatMode.thermostatMode.off()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.idle()) + else + device:emit_event(caps.thermostatMode.thermostatMode.heat()) + device:emit_event(caps.thermostatOperatingState.thermostatOperatingState.heating()) + end + end + -- settemp + if (settemp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report settemp '..settemp) + device:emit_event(caps.thermostatHeatingSetpoint.heatingSetpoint({value=settemp,unit='C'})) + end + if (temp ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report temp '..temp) + device:emit_event(caps.temperatureMeasurement.temperature({value=temp,unit='C'})) + end + return true,nil +end + +-- global params init +wisers.driver = driver +-- start ssdp schedule to check bridge ip +driver:call_on_schedule( +600, +function () + return wisers.check_ip(discovery) +end, +'Check ip schedule') +-------------------- +-- Initialize Driver +driver:run() diff --git a/drivers/DeepSmart/deepsmart/src/lifecycles.lua b/drivers/DeepSmart/deepsmart/src/lifecycles.lua new file mode 100644 index 0000000000..366e40037a --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lifecycles.lua @@ -0,0 +1,89 @@ +local config = require('config') +local log = require('log') +local caps = require('st.capabilities') +local wisers = require('deepsmart.wisers') +local lifecycle_handler = {} + +function lifecycle_handler.init(driver, device) + -- report test data + --wisers.default_report(driver, device) + local is_bridge = wisers.is_device_bridge(device) + log.info('device '..device.id..' in init') + -- for bridge do not reload devices&&config + -- just use the devices&&config loaded from wiser's datastore + -- only discover or refresh bridge will reload devices&&config + if (is_bridge) then + -- just add wiser(bridge) to wisers + wisers.add_wiser(device.device_network_id, device:get_field(config.FIELD.IP), device) + -- load devices + wisers.refresh_wiser(device.device_network_id) + else + log.debug('save dev '..device.id..' networkid '..device.parent_assigned_child_key) + wisers.idmap[device.parent_assigned_child_key] = device.id + local devtype = wisers.get_dev_type(device) + if (devtype == config.ENUM.AC) then + local supported_modes = {'heat','cool','dry','fan','auto'} + device:emit_event(caps.airConditionerMode.supportedAcModes(supported_modes, { visibility = { displayed = false } })) + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.NEWFAN then + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.HEATER then + local supported_thermostat_modes = {'heat', 'off'} + device:emit_event(caps.thermostatMode.supportedThermostatModes(supported_thermostat_modes, { visibility = { displayed = false } })) + end + ------------------- + log.info('device '..device.parent_assigned_child_key..' in lifecycle init') + end +end + +function lifecycle_handler.added(driver, device) + log.info('device '..device.id..' added') + if (device.parent_assigned_child_key == nil) then + log.info('bridge device '..device.id..' added') + local ip = wisers.ips[device.device_network_id] + -- for bridge save some infos to bridge device + device:set_field(config.FIELD.IP, ip, { persist = true}) + wisers.add_wiser(device.device_network_id, ip, device) + log.info('bridge device '..device.id..' added over') + return 0 + end + log.debug('save dev '..device.id..' networkid '..device.parent_assigned_child_key) + local devtype = wisers.get_dev_type(device) + -- save device info + wisers.idmap[device.parent_assigned_child_key] = device.id + -- find device type + log.info('device '..device.parent_assigned_child_key..' added devtype '..devtype) + if (devtype == config.ENUM.AC) then + local supported_modes = {'heat','cool','dry','fan','auto'} + device:emit_event(caps.airConditionerMode.supportedAcModes(supported_modes, { visibility = { displayed = false } })) + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.NEWFAN then + local supported_fan_modes = {'low','medium','high','auto'} + device:emit_event(caps.airConditionerFanMode.supportedAcFanModes(supported_fan_modes, { visibility = { displayed = false } })) + elseif devtype == config.ENUM.HEATER then + local supported_thermostat_modes = {'heat', 'off'} + device:emit_event(caps.thermostatMode.supportedThermostatModes(supported_thermostat_modes, { visibility = { displayed = false } })) + end + log.info('edge device '..device.parent_assigned_child_key..' added over') + -- do not refresh + -- wiser loop will refresh all devices +end + +function lifecycle_handler.removed(_, device) + -- Notify device that the device + -- instance has been deleted and + -- parent node must be deleted at + -- device app. + wisers.del_device(device) + -- Remove Schedules created under + -- device.thread to avoid unnecessary + -- CPU processing. + for timer in pairs(device.thread.timers) do + device.thread:cancel_timer(timer) + end +end + +return lifecycle_handler diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua new file mode 100644 index 0000000000..2745880f50 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return {RestClient = RestClient, EventSource = EventSource} diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua new file mode 100644 index 0000000000..7f9c0917ca --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/rest.lua @@ -0,0 +1,369 @@ +local socket = require "cosock.socket" +local log = require "log" +local utils = require "utils" +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +local RestCallStates = { + SEND = "Send", + RECEIVE = "Receive", + RETRY = "Retry", + RECONNECT = "Reconnect", + COMPLETE = "Complete", +} + +local function connect(client) + local port = 80 + local use_ssl = false + + if client.base_url.scheme == "https" then + port = 443 + use_ssl = true + end + local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) + + if sock == nil then + client.socket = nil + return false, err + end + + client.socket = sock + return true +end + +local function reconnect(client) + if client.socket ~= nil then + client.socket:close() + client.socket = nil + end + return connect(client) +end + +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available" + end + local payload = request:serialize() + + + local bytes, err, idx = nil, nil, 0 + + repeat bytes, err, idx = client.socket:send(payload, idx + 1, #payload) until (bytes == #payload) + or (err ~= nil) + + return bytes, err, idx +end + +local function parse_chunked_response(original_response, sock) + local ChunkedTransferStates = { + EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", + EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", + } + + local full_response = Response.new(original_response.status, nil) + + for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end + + local original_body, err = original_response:get_body() + if type(original_body) ~= "string" or err ~= nil then + return original_body, (err or "unexpected nil in error position") + end + local next_chunk_bytes = tonumber(original_body, 16) + local next_chunk_body = "" + local bytes_read = 0; + + local state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + + repeat + local pat = nil + local next_recv, next_err, partial = nil, nil, nil + + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + pat = next_chunk_bytes + else + pat = "*l" + end + + next_recv, next_err, partial = sock:receive(pat) + + if next_err ~= nil then + if string.lower(next_err) == "closed" then + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + next_chunk_bytes = 0 + end + else + return nil, ("unexpected error reading chunked transfer: " .. next_err) + end + end + + if next_recv ~= nil and #next_recv >= 1 then + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + bytes_read = bytes_read + #next_recv + next_chunk_body = next_chunk_body .. next_recv + + if bytes_read >= next_chunk_bytes then + full_response = full_response:append_body(next_chunk_body) + next_chunk_body = "" + bytes_read = 0 + + state = ChunkedTransferStates.EXPECTING_CHUNK_LENGTH + end + elseif state == ChunkedTransferStates.EXPECTING_CHUNK_LENGTH then + next_chunk_bytes = tonumber(next_recv, 16) + + state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + end + end + until next_chunk_bytes == 0 + + local _ = sock:receive("*l") -- clear the trailing CRLF + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function recv_additional_response(original_response, sock) + local full_response = Response.new(original_response.status, nil) + local headers = original_response:get_headers() + local content_length_str = headers:get_one("Content-Length") + local content_length = nil + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + local total = 0 + repeat + next_recv, next_err, partial = sock:receive(content_length - total) + if next_recv ~= nil and #next_recv >= 1 then + total = total + #next_recv + full_response:append_body(next_recv) + end + + if partial ~= nil and #partial >= 1 then + total = total + #next_recv + full_response:append_body(partial) + end + if (total >= content_length) then + break + end + until next_err == "closed" + + full_response._received_body = true + full_response._parsed_headers = true + return full_response +end +local function handle_response(sock) + -- called select right before passing in so we receive immediately + local initial_recv, initial_err, partial = Response.source(function() return sock:receive('*l') end) + + local full_response = nil + + if initial_recv ~= nil then + local headers = initial_recv:get_headers() + + if headers:get_one("Content-Length") then + full_response = recv_additional_response(initial_recv, sock) + elseif headers:get_one("Transfer-Encoding") == "chunked" then + full_response = parse_chunked_response(initial_recv, sock) + else + full_response = initial_recv + end + + return full_response + else + return nil, initial_err, partial + end +end + +local function execute_request(client, request, retry_fn) + if not client._active then + return nil, "Called `execute request` on a terminated REST Client" + end + + if client.socket == nil then + local success, err = connect(client) + if not success then + return nil, err + end + end + + local should_retry = retry_fn + + if type(should_retry) ~= "function" then + should_retry = function() return false end + end + + -- send output + local _bytes_sent, send_err, _idx = nil, nil, 0 + -- recv output + local response, recv_err, _partial = nil, nil, nil + -- return values + local ret, err = nil, nil + + local backoff = utils.backoff_builder(60, 1, 0.1) + local current_state = RestCallStates.SEND + repeat + local retry = should_retry() + if current_state == RestCallStates.SEND then + backoff = utils.backoff_builder(60, 1, 0.1) + _bytes_sent, send_err, _idx = send_request(client, request) + + if not send_err then + current_state = RestCallStates.RECEIVE + elseif retry then + if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = send_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECEIVE then + response, recv_err, _partial = handle_response(client.socket) + + if not recv_err then + ret = response + err = nil + current_state = RestCallStates.COMPLETE + elseif retry then + if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = recv_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECONNECT then + local success, reconn_err = reconnect(client) + if success then + current_state = RestCallStates.RETRY + elseif not retry then + ret = nil + err = reconn_err + current_state = RestCallStates.COMPLETE + else + socket.sleep(backoff()) + end + elseif current_state == RestCallStates.RETRY then + bytes_sent, send_err, _idx = nil, nil, 0 + response, recv_err, partial = nil, nil, nil + current_state = RestCallStates.SEND + socket.sleep(backoff()) + end + until current_state == RestCallStates.COMPLETE + + return ret, err +end + +---@class RestClient +--- +---@field base_url table `net.url` URL table +---@field socket table `cosock` TCP socket +local RestClient = {} +RestClient.__index = RestClient + +function RestClient.one_shot_get(full_url, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.host, socket_builder) + local ret, err = client:get(url_table.path, additional_headers) + client:shutdown() + client = nil + return ret, err +end + +function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + local client = RestClient.new(url_table.scheme .. "://" .. url_table.host, socket_builder) + local ret, err = client:post(url_table.path, body, additional_headers) + client:shutdown() + client = nil + return ret, err +end + +function RestClient:close_socket() + if self.socket ~= nil and self._active then + self.socket:close() + self.socket = nil + end +end + +function RestClient:shutdown() + self:close_socket() + self._active = false +end + +function RestClient:update_base_url(new_url) + if self.socket ~= nil then + self.socket:close() + self.socket = nil + end + + self.base_url = lb_utils.force_url_table(new_url) +end + +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + return execute_request(self, request, retry_fn) +end + +function RestClient:post(path, body_string, additional_headers, retry_fn) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient:put(path, body_string, additional_headers, retry_fn) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient.new(base_url, sock_builder) + base_url = lb_utils.force_url_table(base_url) + + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end + + return + setmetatable({base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true}, RestClient) +end + +return RestClient diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..78fc95923b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,511 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +local log = require "log" +local util = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +--- A pure Lua implementation of the EventSource interface. +--- The EventSource interface represents the client end of an HTTP(S) +--- connection that receives an event stream following the Server-Sent events +--- specification. +--- +--- MDN Documentation for EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +--- HTML Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +--- +--- @class EventSource +--- @field public url table A `net.url` table representing the URL for the connection +--- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public onopen function in-line callback for on-open events +--- @field public onmessage function in-line callback for on-message events +--- @field public onerror function in-line callback for on-error events; error callbacks will fire +--- @field private _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field private _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field private _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field private _sock table the TCP socket for the connection +--- @field private _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field private _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field private _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field private _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field private _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +local EventSource = {} +EventSource.__index = EventSource + +--- The Ready States that an EventSource can be in. We use base 0 to match the specification. +EventSource.ReadyStates = util.read_only { + CONNECTING = 0, -- The connection has not yet been established + OPEN = 1, -- The connection is open + CLOSED = 2 -- The connection has closed +} + +--- The event types supported by this source, patterned after their values in JavaScript. +EventSource.EventTypes = util.read_only { + ON_OPEN = "open", + ON_MESSAGE = "message", + ON_ERROR = "error", +} + +--- Helper function that creates the initial Request to start the stream. +--- @function create_request +--- @local +--- @param url_table table a net.url table +--- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed. +local function create_request(url_table, extra_headers) + local request = Request.new("GET", url_table.path, nil) + :add_header("user-agent", "smartthings-lua-edge-driver") + :add_header("host", string.format("%s", url_table.host)) + :add_header("connection", "keep-alive") + :add_header("accept", "text/event-stream") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + return request +end + +--- Helper function to send the request and kick off the stream. +--- @function send_stream_start_request +--- @local +--- @param payload string the entire string buffer to send +--- @param sock table the TCP socket to send it over +local function send_stream_start_request(payload, sock) + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = sock:send(payload, idx + 1, #payload) + until (bytes == #payload) or (err ~= nil) + + if err then + log.error_with({ hub_logs = true }, "send error: " .. err) + end + + return bytes, err, idx +end + +--- Helper function to create an table representing an event from the source's parse buffers. +--- @function make_event +--- @local +--- @param source EventSource +local function make_event(source) + local event_type = nil + + if #source._parse_buffers["event"] > 0 then + event_type = source._parse_buffers["event"] + end + + return { + type = event_type or "message", + data = source._parse_buffers["data"], + origin = source.url.scheme .. "://" .. source.url.host, + lastEventId = source._parse_buffers["id"] + } +end + +--- SSE spec for dispatching an event: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage +--- @function dispatch_event +--- @local +--- @param source EventSource +local function dispatch_event(source) + local data_buffer = source._parse_buffers["data"] + local is_blank_line = data_buffer ~= nil and + (#data_buffer == 0) or + data_buffer == "\n" or + data_buffer == "\r" or + data_buffer == "\r\n" + if data_buffer ~= nil and not is_blank_line then + local event = util.read_only(make_event(source)) + + if type(source.onmessage) == "function" then + source.onmessage(event) + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_MESSAGE]) do + if type(listener) == "function" then + listener(event) + end + end + end + + source._parse_buffers["event"] = "" + source._parse_buffers["data"] = "" +end + +local valid_fields = util.read_only { + ["event"] = true, + ["data"] = true, + ["id"] = true, + ["retry"] = true +} + +-- An event stream "line" can end in more than one way; from the spec: +-- Lines must be separated by either +-- a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, +-- a single U+000A LINE FEED (LF) character, +-- or a single U+000D CARRIAGE RETURN (CR) character. +-- +-- util.iter_string_lines won't suffice here because: +-- a.) it assumes \n, and +-- b.) it doesn't differentiate between a "line" that ends without a newline and one that does. +-- +-- h/t to github.com/FreeMasen for the suggestions on the efficient implementation of this +local function find_line_endings(chunk) + local r_idx, n_idx = string.find(chunk, "[\r\n]+") + if r_idx == n_idx then + -- 1 character + return r_idx, n_idx + end + local slice = string.sub(chunk, r_idx, n_idx) + if slice == "\r\n" then + return r_idx, n_idx + end + -- invalid multi character match, return first character only + return r_idx, r_idx +end + +local function event_lines(chunk) + local remaining = chunk + local line_end, rn_end + local remainder_sent = false + return function() + line_end, rn_end = find_line_endings(remaining) + if not line_end then + if remainder_sent or (not remaining) or #remaining == 0 then + return nil + else + remainder_sent = true + return remaining, false + end + end + local next_line = string.sub(remaining, 1, line_end - 1) + remaining = string.sub(remaining, rn_end + 1) + return next_line, true + end +end +--- SSE spec for interpreting an event stream: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface +--- @function parse +--- @local +--- @param source EventSource +--- @param recv string the received payload from the last socket receive +local function sse_parse_chunk(source, recv) + for line, complete in event_lines(recv) do + if not source._needs_more and (#line == 0 or (not line:match("([%w%p]+)"))) then -- empty/blank lines indicate dispatch + dispatch_event(source) + elseif source._needs_more then + local append = line + if source._last_field == "data" and complete then append = append .. "\n" end + if complete then source._needs_more = false end + source._parse_buffers[source._last_field] = source._parse_buffers[source._last_field] .. append + else + if line:sub(1, 1) ~= ":" then -- ignore any complete lines that start w/ a colon + local matches = line:gmatch("(%w*)(:*)(.*)") -- colon after field is optional, in that case it's a field w/ no value + + for field, _colon, value in matches do + value = value:gsub("^[^%g]", "", 1) -- trim a single leading space character + + if valid_fields[field] then + source._last_field = field + if field == "retry" then + local new_time = tonumber(value, 10) + if type(new_time) == "number" then + source._reconnect_time_millis = new_time + end + elseif field == "data" then + local append = (value or "") + if complete then append = append .. "\n" end + source._parse_buffers[field] = source._parse_buffers[field] .. append + elseif field == "id" then + -- skip ID's if they contain the NULL character + if not string.find(value, '\0') then + source._parse_buffers[field] = value + end + else + source._parse_buffers[field] = value + end + end + source._needs_more = source._needs_more or (not complete) + end + end + end + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. +--- @function connecting_action +--- @local +--- @param source EventSource +local function connecting_action(source) + if not source._sock then + if type(source._sock_builder) == "function" then + source._sock = source._sock_builder() + else + source._sock, err = socket.tcp() + if err ~= nil then return nil, err end + + _, err = source._sock:settimeout(60) + if err ~= nil then return nil, err end + + _, err = source._sock:connect(source.url.host, source.url.port) + if err ~= nil then return nil, err end + + _, err = source._sock:setoption("keepalive", true) + if err ~= nil then return nil, err end + + if source.url.scheme == "https" then + source._sock, err = ssl.wrap(source._sock, { + mode = "client", + protocol = "any", + verify = "none", + options = "all" + }) + if err ~= nil then return nil, err end + + _, err = source._sock:dohandshake() + if err ~= nil then return nil, err end + end + end + end + + local request = create_request(source.url, source._extra_headers) + + local last_event_id = source._parse_buffers["id"] + + if last_event_id ~= nil and #last_event_id > 0 then + request = request:add_header("Last-Event-ID", last_event_id) + end + + local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + + if err ~= nil then + return nil, err + end + + local response + response, err = Response.tcp_source(source._sock) + + if err ~= nil then + return nil, err + end + + if response.status ~= 200 then + return nil, "Server responded with status other than 200 OK", { response.status, response.status_msg } + end + + local headers, err = response:get_headers() + if err ~= nil then + return nil, err + end + local content_type = string.lower((headers:get_one('content-type') or "none")) + if not content_type:find("text/event-stream", 1, true) then + local err_msg = "Expected content type of text/event-stream in response headers, received: " .. content_type + return nil, err_msg + end + + source.ready_state = EventSource.ReadyStates.OPEN + + if type(source.onopen) == "function" then + source.onopen() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_OPEN]) do + if type(listener) == "function" then + listener() + end + end +end +--- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. +--- @function open_action +--- @local +--- @param source EventSource +local function open_action(source) + local recv, err, partial = source._sock:receive('*l') + + if err then + --- connection is fine but there was nothing + --- to be read from the other end so we just + --- early return. + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + -- the number of bytes to read per the chunked encoding spec + local recv_as_num = tonumber(recv, 16) + + if recv_as_num ~= nil then + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + local _, err, partial = source._sock:receive('*l') -- clear the final line + + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + sse_parse_chunk(source, recv) + else + local recv_dbg = recv or "" + if #recv_dbg == 0 then recv_dbg = "" end + recv_dbg = recv_dbg:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") + log.error_with({ hub_logs = true }, + string.format("Received %s while expecting a chunked encoding payload length (hex number)\n", recv_dbg)) + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CLOSED state. +--- @function closed_action +--- @local +--- @param source EventSource +local function closed_action(source) + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + + if source._reconnect then + if type(source.onerror) == "function" then + source.onerror() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_ERROR]) do + if type(listener) == "function" then + listener() + end + end + + local sleep_time_secs = source._reconnect_time_millis / 1000.0 + socket.sleep(sleep_time_secs) + + source.ready_state = EventSource.ReadyStates.CONNECTING + end +end + +local state_actions = { + [EventSource.ReadyStates.CONNECTING] = connecting_action, + [EventSource.ReadyStates.OPEN] = open_action, + [EventSource.ReadyStates.CLOSED] = closed_action +} + +--- Create a new EventSource. The only required parameter is the URL, which can +--- be a string or a net.url table. The string form will be converted to a net.url table. +--- +--- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. +--- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial GET request +--- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @return EventSource a new EventSource +function EventSource.new(url, extra_headers, sock_builder) + local url_table = util.force_url_table(url) + + if not url_table.port then + if url_table.scheme == "http" then + url_table.port = 80 + elseif url_table.scheme == "https" then + url_table.port = 443 + end + end + + local sock = nil + + if type(sock_builder) == "function" then + sock = sock_builder() + end + + local source = setmetatable({ + url = url_table, + ready_state = EventSource.ReadyStates.CONNECTING, + onopen = nil, + onmessage = nil, + onerror = nil, + _needs_more = false, + _last_field = nil, + _reconnect = true, + _reconnect_time_millis = 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _parse_buffers = { + ["data"] = "", + ["id"] = "", + ["event"] = "", + }, + _listeners = { + [EventSource.EventTypes.ON_OPEN] = {}, + [EventSource.EventTypes.ON_MESSAGE] = {}, + [EventSource.EventTypes.ON_ERROR] = {} + }, + }, EventSource) + + cosock.spawn(function() + local st_utils = require "st.utils" + while true do + if source.ready_state == EventSource.ReadyStates.CLOSED and + not source._reconnect + then + return + end + local _, action_err, partial = state_actions[source.ready_state](source) + if action_err ~= nil then + if action_err ~= "timeout" or action_err ~= "wantread" then + log.error_with({ hub_logs = true }, "Event Source Coroutine State Machine error: " .. action_err) + if partial ~= nil and #partial > 0 then + log.error_with({ hub_logs = true }, st_utils.stringify_table(partial, "\tReceived Partial", true)) + end + source.ready_state = EventSource.ReadyStates.CLOSED + end + end + end + end) + + return source +end + +--- Close the event source, signalling that a reconnect is not desired +function EventSource:close() + self._reconnect = false + if self._sock ~= nil then + self._sock:close() + end + self._sock = nil + self.ready_state = EventSource.ReadyStates.CLOSED +end + +--- Add a callback to the event source +---@param listener_type string One of "message", "open", or "error" +---@param listener function the callback to be called in case of an event. Open and Error events have no payload. The message event will have a single argument, a table. +function EventSource:add_event_listener(listener_type, listener) + local list = self._listeners[listener_type] + + if list then + table.insert(list, listener) + end +end + +return EventSource diff --git a/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua b/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua new file mode 100644 index 0000000000..54a421047f --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lunchbox/util.lua @@ -0,0 +1,46 @@ +local net_url = require "net.url" + +local util = {} + +util.force_url_table = function(url) + if type(url) ~= "table" then url = net_url.parse(url) end + + if not url.port then + if url.scheme == "http" then + url.port = 80 + elseif url.scheme == "https" then + url.port = 443 + end + end + + return url +end + +util.read_only = function(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) error("attempt to update a read-only table", 2) end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + +util.iter_string_lines = function(str) + if str:sub(-1) ~= "\n" then str = str .. "\n" end + + return str:gmatch("(.-)\n") +end + +util.copy_data = function(tbl) + local ret = {} + for k, v in pairs(tbl) do ret[k] = v end + + return ret +end + +return util diff --git a/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt b/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt new file mode 100644 index 0000000000..9bcb022651 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/selfSignedRoot.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDlTCCAn2gAwIBAgIJALBUeJtMnu2RMA0GCSqGSIb3DQEBCwUAMGExCzAJBgNV +BAYTAkNOMREwDwYDVQQIDAhaSEVKSUFORzERMA8GA1UEBwwISEFOR1pIT1UxEjAQ +BgNVBAoMCURFRVBTTUFSVDEYMBYGA1UEAwwPZGVlcHNtYXJ0LmxvY2FsMB4XDTI0 +MDMxMTA2MDUwNFoXDTM0MDMwOTA2MDUwNFowYTELMAkGA1UEBhMCQ04xETAPBgNV +BAgMCFpIRUpJQU5HMREwDwYDVQQHDAhIQU5HWkhPVTESMBAGA1UECgwJREVFUFNN +QVJUMRgwFgYDVQQDDA9kZWVwc21hcnQubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQC0zTA4yKEW2MSd1l4gvi9VrkLok64c7b+617UApuCPfgVn +UQi2l+1ZAYEsFMBMWpmfSg/vEV8cJaMwzXvW2MxvVJ9A4UxJeSVNbZU3CFs80gO8 +E7RnSiFRXdhmWipGBFS8d3wpHck1V3cTTPeLR2RsvXAi1vvQpM3qtUEvIh+A7QGL +b7RtdSwkKqrcHquFfgp0pDD6uDbzukeAobW8QY6N/1ZPHDdj0sfmJ7qFqZPIUWxi +hQD4kcUXnvaFISk7J+eQOJ6Z0kA7l7ZA/1a40Y09FU57Sb8tvhPeQkQI3NqR37WO +kQfCIcw2uXnL6jMoHoGMGZANg1r2M8IMe+92eKF7AgMBAAGjUDBOMB0GA1UdDgQW +BBQypxdj41vsP+iTRSr85D8MnK23UTAfBgNVHSMEGDAWgBQypxdj41vsP+iTRSr8 +5D8MnK23UTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCjwp8Qy56b +qR8h5mvksQgs2i3pAS5qCXLmvoUGAR8Q+azZTy2uQ3pgo6+1SZh+/40ZpaQWvROz +LCaKiVPVRK8xXKQPbMSg7Ls+zVZdKWidZnelzBZvnA1EUKpqJWVDCx0zRVEubv3N +qL+WPEkZt03sXiC3Ezgm6Nib6MwLfQw0OaE8Y37FgDnOWJVEti1zGriUz44vQVlY +jn/WCsYSiTe/mSWDIgx4qEMrmjvFffNerhm0jPAgv7n3UYJf2le++0WBV3vPQSXL +BzV135cS2EdIS3VlwGQ3Pe5iOqDHraMsMMofsxXxiOZZ3gHcvU7VpGIgBnkPgo5x +L/uDa/JtJTLe +-----END CERTIFICATE----- diff --git a/drivers/DeepSmart/deepsmart/src/ssdp.lua b/drivers/DeepSmart/deepsmart/src/ssdp.lua new file mode 100644 index 0000000000..4bbde9150c --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/ssdp.lua @@ -0,0 +1,89 @@ +local socket = require('socket') +local log = require('log') +local config = require('config') + + + + +local SSDP = {} + +local DEEPSMART_SSDP_SEARCH_TERM = "DEEPSMART-ARM" +----------------------- +-- SSDP Response parser +local function parse_ssdp(data) + local res = {} + res.status = data:sub(0, data:find('\r\n')) + for k, v in data:gmatch('([%w-]+): ([%a+-: /=]+)') do + local key = k:lower() + log.info('parse key '..key..' val '..v) + res[key] = v + end + return res +end + + +-- This function enables a UDP +-- Socket and broadcast a single +-- M-SEARCH request, i.e., it +-- must be looped appart. +function SSDP.search(callback) + local bridges = {} + -- UDP socket initialization + local upnp = socket.udp() + local _,err = upnp:setsockname('0.0.0.0', 0) + if err then + log.error(string.format("udp socket failure setsockname: %s", err)) + return false,bridges,err + end + -- use broadcast to find wisers as wifi router's group cast is forbidden by default(need login to router to start group cast) + upnp:setoption('broadcast', true) + local timeout = socket.gettime() + config.MC_TIMEOUT + 1 + local mx = 2 + -- broadcasting request + log.info('===== SCANNING NETWORK...') + local multicast_msg = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 255.255.255.255:1900", + 'MAN: "ssdp:discover"', -- yes, there are really supposed to be quotes in this one + string.format("MX: %s", mx), + string.format("ST: %s", DEEPSMART_SSDP_SEARCH_TERM), + "\r\n" + }, "\r\n") + local _, err = upnp:sendto(multicast_msg, config.MC_ADDRESS, config.MC_PORT) + if err then + log.error(string.format("udp socket failure sendto: %s", err)) + return false,bridges,err + end + while true do + -- Socket will wait n seconds + -- based on the s:setoption(n) + -- to receive a response back. + local time_remaining = math.max(0, timeout - socket.gettime()) + upnp:settimeout(time_remaining) + local res,ip = upnp:receivefrom() + if (res ~= nil) then + log.info('recv wiser '..res..' from '..ip) + local headers = parse_ssdp(res) + -- Device metadata + local usn = headers.usn + local uuid = usn:match("uuid:(%d+)::") + if (uuid ~= nil) then + log.info('usn '..usn..' uuid '..uuid..' ip '..ip) + if callback ~= nil and type(callback) == "function" then + callback(uuid, ip) + end + bridges[uuid] = ip + end + else + break + end + end + log.info('===== SCANNING NETWORK OVER') + -- close udp socket + upnp:close() + + return true,bridges,nil +end + + +return SSDP diff --git a/drivers/DeepSmart/deepsmart/src/utils.lua b/drivers/DeepSmart/deepsmart/src/utils.lua new file mode 100644 index 0000000000..0a0b036246 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/utils.lua @@ -0,0 +1,235 @@ +local log=require('log') + +local utils = {} +utils.base = {0, 0x40, 0x7A, 0x10, 0xF3, 0x5A, 0, 0} + +function utils.tonumber(str) + local len = #str + local bytes = {} + for i=1,8 do + bytes[i] = 0 + end + local lowstr = str + local highstr = nil + if (len > 14) then + lowstr = string.sub(str,len-13) + highstr = string.sub(str, 1, len-14) + end + local low = tonumber(lowstr) + local pos = 1 + while(low > 0) do + bytes[pos] = (low%256) + low = ((low-bytes[pos])>>8) + pos = pos+1 + end + -- high + local high = 0 + if (highstr ~= nil) then + high = tonumber(highstr) + end + local sec = 0 + for i=1,8 do + local fir = high * utils.base[i] + bytes[i] + sec + sec = ((fir-(fir%256))>>8) + bytes[i] = (fir%256) + end + return bytes +end + +function utils.tobytes(num) + local bytes = {} + local pos = 1 + local tmp = num + while(tmp > 0) do + bytes[pos] = (tmp%256) + tmp = ((tmp-bytes[pos])>>8) + pos = pos+1 + end + return bytes +end + +function utils.loadfile(file) + local fp = io.open(file, 'r') + if (fp) then + local content = fp:read('*a') + fp:close() + return content + else + return '' + end +end + + +function utils.str_starts_with(str, start) + return str:sub(1, #start) == start +end + +function utils.is_nan(number) + -- IEEE 754 dictates that NaN compares falsey to everything, including itself. + if number ~= number then + return true + end + + -- If someone passes in something that isn't a Number type, it'll pass the above check. + -- Philosophical question: Something that isn't a number can't technicaly have the value + -- of "nan" but "nan" stands for "not a number", so what do we do here? + if type(number) ~= "number" then + log.warn(string.format("utils.is_nan received value of type %s as argument, returning true", type(number))) + return true + end + + -- In the event that something goes wrong with the above two things, + -- we simply compare the tostring against a known NaN value. + return tostring(number) == tostring(0 / 0) +end + +-- build a exponential backoff time value generator +-- +-- max: the maximum wait interval (not including `rand factor`) +-- inc: the rate at which to exponentially back off +-- rand: a randomization range of (-rand, rand) to be added to each interval +function utils.backoff_builder(max, inc, rand) + local count = 0 + inc = inc or 1 + return function() + local randval = 0 + if rand then + randval = math.random() * rand * 2 - rand + end + + local base = inc * (2 ^ count - 1) + count = count + 1 + + -- ensure base backoff (not including random factor) is less than max + if max then base = math.min(base, max) end + + -- ensure total backoff is >= 0 + return math.max(base + randval, 0) + end +end + +function utils.labeled_socket_builder(label, ssl_config) + local log = require "log" + local socket = require "cosock.socket" + local ssl = require "cosock.ssl" + + label = (label or "") + if #label > 0 then + label = label .. " " + end + + local function make_socket(host, port, wrap_ssl) + log.info( + string.format( + "%sCreating TCP socket for REST Connection", label + ) + ) + local _ = nil + local sock, err = socket.tcp() + + if err ~= nil or (not sock) then + return nil, (err or "unknown error creating TCP socket") + end + + log.info( + string.format( + "%sSetting TCP socket timeout for REST Connection", label + ) + ) + _, err = sock:settimeout(60) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.info( + string.format( + "%sConnecting TCP socket for REST Connection", label + ) + ) + _, err = sock:connect(host, port) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.info( + string.format( + "%sSet Keepalive for TCP socket for REST Connection", label + ) + ) + _, err = sock:setoption("keepalive", true) + if err ~= nil then + return nil, "Setoption error: " .. err + end + + if wrap_ssl then + log.info( + string.format( + "%sCreating SSL wrapper for for REST Connection", label + ) + ) + sock, err = + ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err + end + log.info( + string.format( + "%sPerforming SSL handshake for for REST Connection", label + ) + ) + _, err = sock:dohandshake() + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info( + string.format( + "%sSuccessfully created TCP connection", label + ) + ) + return sock, err + end + return make_socket +end + +--- From https://gist.github.com/sapphyrus/fd9aeb871e3ce966cc4b0b969f62f539 +--- MIT licensed +function utils.deep_table_eq(tbl1, tbl2) + if tbl1 == tbl2 then + return true + elseif type(tbl1) == "table" and type(tbl2) == "table" then + for key1, value1 in pairs(tbl1) do + local value2 = tbl2[key1] + + if value2 == nil then + -- avoid the type call for missing keys in tbl2 by directly comparing with nil + return false + elseif value1 ~= value2 then + if type(value1) == "table" and type(value2) == "table" then + if not utils.deep_table_eq(value1, value2) then + return false + end + else + return false + end + end + end + + -- check for missing keys in tbl1 + for key2, _ in pairs(tbl2) do + if tbl1[key2] == nil then + return false + end + end + + return true + end + + return false +end + + + +return utils +