diff --git a/drivers/DeepSmart/deepsmart/CLAUDE.md b/drivers/DeepSmart/deepsmart/CLAUDE.md new file mode 100644 index 0000000000..67a03c4cf7 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a **SmartThings Edge Driver** written in Lua that integrates DEEPSMART KNX gateways (called "Wiser" bridges) with Samsung SmartThings. It discovers bridges via SSDP, communicates over HTTPS REST APIs, and translates KNX telegrams to SmartThings capability events. + +There is no build system, package manager, or test suite — this is a pure source project deployed directly to the SmartThings Edge platform. + +## Architecture + +``` +src/ +├── init.lua # Driver entry point — registers capabilities, lifecycle/command handlers, starts the IP-check schedule +├── config.lua # Device type enums (AC=3, HEATER=4, NEWFAN=53, SWITCH=14, SLIDER=16, HUE=25), address type constants +├── discovery.lua # SSDP-based bridge discovery. Callback spawns bridge creation and device loading +├── lifecycles.lua # init/added/removed handlers for both bridge devices and child (edge) devices +├── commands.lua # Translates SmartThings capability commands → Wisers.control() calls with appropriate addrtypes +├── ssdp.lua # Raw UDP SSDP M-SEARCH broadcast, parses responses for UUID+IP +├── utils.lua # Socket builder (TCP+SSL via cosock), backoff generator, deep table comparison +├── selfSignedRoot.crt # CA certificate for bridge HTTPS connections +├── deepsmart/ +│ ├── wisers.lua # Central orchestrator: bridge lifecycle (add/del/reload/refresh), KNX↔SmartThings translation, 2s poll loop +│ ├── api.lua # HTTPS REST client wrapping lunchbox.rest: load_config, load_dp2knx, load_dpenum, query, control +│ ├── devices.lua # Parses bridge JSON config into device/protocol/address index. Maps KNX group addresses → device DP IDs +│ ├── dp2knx.lua # Maps productId → device type + DP ID ↔ address-type associations +│ └── dpenum.lua # Enum value conversion: SmartThings values (cool/heat/auto) ↔ DEEPSMART integer values +└── lunchbox/ + ├── init.lua # Module re-export: RestClient, EventSource + ├── rest.lua # Full-featured HTTPS client with connection pooling, retry/backoff, chunked transfer support + ├── sse/eventsource.lua # Server-Sent Events client + └── util.lua # URL parsing helpers, read-only table proxy +``` + +## How It Works — Data Flow + +### Discovery & Setup +1. User scans for devices → `discovery.start()` sends SSDP M-SEARCH for `DEEPSMART-ARM` +2. Each bridge UUID+IP is passed to `wisers.add_wiser()`, which creates a bridge device and fetches all configs (devices, dp2knx, dpenum) from the bridge's HTTPS API +3. Every 2 seconds, `wiser_loop()` polls the bridge for changed devices — this keeps device state in sync + +### Command Path (SmartThings → KNX) +1. SmartThings app sends command → `commands.lua` handler +2. Handler determines `addrtypes` based on device type (e.g., AC mode = addrtype 1) +3. Calls `wisers.control(device, command, addrtypes)` which: + - Looks up the DP ID for the product + addrtype via `dp2knx` + - Finds the KNX send address(es) from `devices` + - Converts SmartThings values to DEEPSMART values via `dpenum` + - Posts `control` API call to bridge + +### Response Path (KNX → SmartThings) +1. `wiser_loop()` polls `/homecontroller/api/v1/config/changeddevs` every 2s +2. For changed devices, calls `query_deviceid()` which reads all KNX feedback addresses +3. KNX values are translated back to SmartThings values via `dpenum` and `dp2knx` +4. `driver:set_switch()`, `driver:ac_report()`, etc. emit SmartThings capability events + +### Device Addressing +- `device_network_id` format: `wiser:pid:id[_idx]` (e.g., `abc123:cekfhkz5:42_1`) +- One physical DEEPSMART device can map to multiple SmartThings child devices (`idx` suffix) +- Bridge devices have `parent_assigned_child_key == nil`; child devices always have one + +## Key Files for Common Changes + +- **Adding a new device type**: Add the enum to `config.lua`, create a profile YAML in `profiles/`, add PID mapping in `dp2knx.lua`, add command handling in `commands.lua`, add response handling in `wisers.lua:knx_response()` +- **Changing API endpoints**: Edit `src/deepsmart/api.lua` +- **Changing discovery behavior**: Edit `src/discovery.lua` or `src/ssdp.lua` +- **Changing device lifecycle behavior**: Edit `src/lifecycles.lua` + +## SmartThings Platform Constraints + +- Runs on SmartThings Edge Lua runtime — `require('st.driver')`, `require('st.capabilities')`, etc. are platform-provided +- Uses `cosock` for TCP/SSL sockets (not standard Lua socket) +- The `luncheon` library provides HTTP Request/Response objects +- Driver data is persisted via `device:set_field(key, value, {persist = true})` +- Bridge configuration (devices, dp2knx, dpenum) are stored as persisted fields on the bridge device + +## Device Types and Their Capabilities + +| Type | Enum | Profile | Capabilities | +|------|------|---------|--------------| +| AC | 3 | Ac.v1 | switch, temperatureMeasurement, thermostatHeatingSetpoint, airConditionerMode, airConditionerFanMode | +| Heater | 4 | Heater.v1 | thermostatMode, thermostatOperatingState, thermostatHeatingSetpoint, temperatureMeasurement | +| NewFan | 53 | Newfan.v1 | switch, airConditionerFanMode | +| Switch | 14 | Light.v1 | switch | +| Slider | 16 | Slider.v1 | switch, switchLevel | +| Hue | 25 | Hue.v1 | switch, switchLevel, colorTemperature | +| Bridge | — | Deepsmart-bridge.v1 | refresh | + +## Scheduling + +- **Every 600 seconds**: `check_ip()` SSDP scan to detect bridge IP changes (e.g., DHCP renewal) +- **Every 2 seconds**: `wiser_loop()` polls bridge for changed device states and keeps the poll connection alive 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/Curtain.yml b/drivers/DeepSmart/deepsmart/profiles/Curtain.yml new file mode 100644 index 0000000000..8a8d90203f --- /dev/null +++ b/drivers/DeepSmart/deepsmart/profiles/Curtain.yml @@ -0,0 +1,12 @@ +name: Curtain.v1 +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind 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..ea759350c5 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/commands.lua @@ -0,0 +1,227 @@ +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 + +---------------- +-- Window Shade command (open/close/pause) +---------------- +function command_handler.set_shade(_, device, command) + local cmd = command.command + log.info('hub control device '..device.parent_assigned_child_key..' shade '..cmd) + -- determine addrtypes based on command + local addrtypes = {} + if (cmd == 'open') then + addrtypes[1] = config.CURTAIN.OPEN + addrtypes[2] = config.CURTAIN.CLOSE + elseif (cmd == 'close') then + addrtypes[1] = config.CURTAIN.OPEN + addrtypes[2] = config.CURTAIN.CLOSE + elseif (cmd == 'pause') then + addrtypes[1] = config.CURTAIN.PAUSE + else + addrtypes[1] = config.CURTAIN.PAUSE + end + local success = wisers.control(device, command, addrtypes) + if success then + device:online() + if cmd == 'close' then + return device:emit_event(caps.windowShade.windowShade.closed()) + elseif cmd == 'open' then + return device:emit_event(caps.windowShade.windowShade.open()) + elseif cmd == 'pause' then + return device:emit_event(caps.windowShade.windowShade.paused()) + end + -- pause: no state change, just acknowledge + return + end + log.error('no response from device') + return 0 +end + +---------------- +-- Window Shade Level command +---------------- +function command_handler.set_shade_level(_, device, command) + local level = command.args.shadeLevel + log.info('hub control device '..device.parent_assigned_child_key..' shade level '..level) + local success = wisers.control(device, command, {config.CURTAIN.LEVEL}) + if success then + device:online() + device:emit_event(caps.windowShadeLevel.shadeLevel(level)) + if level == 0 then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif level == 100 then + device:emit_event(caps.windowShade.windowShade.open()) + end + return + 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..c49c037a93 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/config.lua @@ -0,0 +1,78 @@ +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_PROFILE[15]='Curtain.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 +config.ENUM.CURTAIN = 15 + +--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.CURTAIN = {} +config.CURTAIN.PAUSE= 0 +config.CURTAIN.OPEN = 1 +config.CURTAIN.CLOSE = 2 +config.CURTAIN.LEVEL = 3 + +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/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..28ae098725 --- /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 + end + log.trace('sendlist count '..#send_list..' feedback_list count '..#feedback_list) + return send_list,feedback_list + 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..172f0d661b --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/dp2knx.lua @@ -0,0 +1,164 @@ +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}}}, + ['5bempufw'] = {pid='5bempufw',type=config.ENUM.CURTAIN, devs={[1]={[4]=0,[2]=3,[1]=1}}} +} +------------------ +-- 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..b4ee39dc52 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/deepsmart/wisers.lua @@ -0,0 +1,942 @@ +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.bridge:set_field(config.FIELD.IP, ip, { persist = true}) + wiser["ip"] = ip + 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) ret(%s)', configstr, tostring(configret))) + 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() + log.trace(string.format('load dp2knx (%s) ret(%s)', dp2knx_config, tostring(dp2knxret))) + 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() + log.trace(string.format('load dpenum (%s) ret(%s)', dpenum_config, tostring(dpenumret))) + 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 + elseif (devtype == config.ENUM.CURTAIN) then + if (addrtype == 1 or addrtype == 2) then + local on_off = 'close' + if (value ~= 0) then + on_off = 'open' + end + Wisers.driver:curtain_report(dev, on_off, nil, nil) + elseif (addrtype == 0) then + Wisers.driver:curtain_report(dev, "pause", nil, nil) + elseif (addrtype == 3) then + Wisers.driver:curtain_report(dev, nil, (128+value*100)//255, nil) + 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.windowShade ~= nil) then + log.trace('get device '..(device.parent_assigned_child_key or 'nil')..' devtype curtain') + return config.ENUM.CURTAIN + elseif (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) + elseif (devtype == config.ENUM.CURTAIN) then + driver:curtain_report(device, "closed", 0, 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 + elseif (capability == 'windowShade') then + if (cmd == 'pause') then + -- pause sends value 1 to stop addr + for i,addr in pairs(send_list) do + log.info('device '..uuid..' shade pause to addr '..addr.addr_int) + wiser.ctrlmap[addr.addr_int] = 1 + deepsmartapi:control(addr.addr_int, addr.dataType, 1, 0) + end + else + local value = 0 + if (cmd == 'open') then + value = 1 + end + 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 + end + ret = true + elseif (capability == 'windowShadeLevel') then + -- SmartThings level is 0-100, deepsmart level is 0-255 + local value = command.args.shadeLevel*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 + 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..da146635d0 --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/init.lua @@ -0,0 +1,195 @@ +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, + caps.windowShade, + caps.windowShadeLevel + }, + 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 + }, + -- Window Shade command handler + [caps.windowShade.ID] = { + [caps.windowShade.commands.open.NAME] = commands.set_shade, + [caps.windowShade.commands.close.NAME] = commands.set_shade, + [caps.windowShade.commands.pause.NAME] = commands.set_shade + }, + -- Window Shade Level command handler + [caps.windowShadeLevel.ID] = { + [caps.windowShadeLevel.commands.setShadeLevel.NAME] = commands.set_shade_level + } + } +} +) + +--------------------------------------- +-- 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 + +-- Curtain control from bridge +function driver:curtain_report(device, onoff, level, err) + device:online() + if (onoff ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report onoff '..onoff) + if (onoff == "close") then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif (onoff == "open") then + device:emit_event(caps.windowShade.windowShade.open()) + else + device:emit_event(caps.windowShade.windowShade.paused()) + end + end + if (level ~= nil) then + log.info('device '..device.parent_assigned_child_key..' report level '..level) + device:emit_event(caps.windowShadeLevel.shadeLevel(level)) + if level == 0 then + device:emit_event(caps.windowShade.windowShade.closed()) + elseif level == 100 then + device:emit_event(caps.windowShade.windowShade.open()) + end + 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..b00f662f0d --- /dev/null +++ b/drivers/DeepSmart/deepsmart/src/lifecycles.lua @@ -0,0 +1,91 @@ +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 } })) + elseif devtype == config.ENUM.CURTAIN then + wisers.default_report(driver, device) + 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 +