diff --git a/drivers/SmartThings/abb-scu200/config.yml b/drivers/SmartThings/abb-scu200/config.yml new file mode 100644 index 0000000000..2cda5384d4 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/config.yml @@ -0,0 +1,7 @@ +name: 'SCU200 InSite Energy Management System' +packageKey: 'ABB.SCU200' +description: "SmartThings driver for SCU200 InSite Energy Management System" +vendorSupportInformation: "https://support.smartthings.com" +permissions: + lan: {} + discovery: {} \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/auxiliary-contact.yml b/drivers/SmartThings/abb-scu200/profiles/auxiliary-contact.yml new file mode 100644 index 0000000000..92c4a2fcb6 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/auxiliary-contact.yml @@ -0,0 +1,34 @@ +name: abb.scu200.auxiliary-contact.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +deviceConfig: + dashboard: + states: + - component: main + capability: switch + version: 1 + actions: [] + detailView: + - component: main + capability: switch + version: 1 + visibleCondition: + capability: switch + version: 1 + component: main + value: switch.value + operator: ONE_OF + operand: '[""]' + automation: + conditions: + - component: main + capability: switch + version: 1 + actions: [] \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/bridge.yml b/drivers/SmartThings/abb-scu200/profiles/bridge.yml new file mode 100644 index 0000000000..d45ebc4943 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/bridge.yml @@ -0,0 +1,8 @@ +name: abb.scu200.bridge.v1 +components: +- id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Bridges diff --git a/drivers/SmartThings/abb-scu200/profiles/current-sensor.yml b/drivers/SmartThings/abb-scu200/profiles/current-sensor.yml new file mode 100644 index 0000000000..8e3adcd4db --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/current-sensor.yml @@ -0,0 +1,25 @@ +name: abb.scu200.current-sensor.v1 +components: +- id: main + capabilities: + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: productionMeter + label: "To Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/energy-meter-module.yml b/drivers/SmartThings/abb-scu200/profiles/energy-meter-module.yml new file mode 100644 index 0000000000..875934453f --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/energy-meter-module.yml @@ -0,0 +1,36 @@ +name: abb.scu200.energy-meter-module.v1 +components: +- id: main + capabilities: + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: consumptionMeter + label: "From Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: productionMeter + label: "To Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/energy-meter.yml b/drivers/SmartThings/abb-scu200/profiles/energy-meter.yml new file mode 100644 index 0000000000..5691c2c919 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/energy-meter.yml @@ -0,0 +1,27 @@ +name: abb.scu200.energy-meter.v1 +components: +- id: main + capabilities: + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: productionMeter + label: "To Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/gas-meter.yml b/drivers/SmartThings/abb-scu200/profiles/gas-meter.yml new file mode 100644 index 0000000000..cc3297eb05 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/gas-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.gas-meter.v1 +components: +- id: main + capabilities: + - id: gasMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/output-module.yml b/drivers/SmartThings/abb-scu200/profiles/output-module.yml new file mode 100644 index 0000000000..d6e0395da8 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/output-module.yml @@ -0,0 +1,10 @@ +name: abb.scu200.output-module.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch diff --git a/drivers/SmartThings/abb-scu200/profiles/usb-energy-meter.yml b/drivers/SmartThings/abb-scu200/profiles/usb-energy-meter.yml new file mode 100644 index 0000000000..8eb09ce365 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/usb-energy-meter.yml @@ -0,0 +1,14 @@ +name: abb.scu200.usb-energy-meter.v1 +components: +- id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/profiles/water-meter.yml b/drivers/SmartThings/abb-scu200/profiles/water-meter.yml new file mode 100644 index 0000000000..bf5f50068d --- /dev/null +++ b/drivers/SmartThings/abb-scu200/profiles/water-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.water-meter.v1 +components: +- id: main + capabilities: + - id: waterMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/search-parameters.yaml b/drivers/SmartThings/abb-scu200/search-parameters.yaml new file mode 100644 index 0000000000..5cfaf45c83 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/search-parameters.yaml @@ -0,0 +1,2 @@ +ssdp: + - searchTerm: urn:ABB:device:SCU200:1 \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/abb/api.lua b/drivers/SmartThings/abb-scu200/src/abb/api.lua new file mode 100644 index 0000000000..6524d86567 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/abb/api.lua @@ -0,0 +1,145 @@ +local log = require("log") +local st_utils = require "st.utils" +local json = require "st.json" + +-- Local imports +local config = require("config") +local utils = require("utils") +local RestClient = require "lunchbox.rest" + +-- API for the ABB SCU200 Bridge +local api = {} +api.__index = api + +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "none", + options = "all" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + +-- Method for getting the base URL +local function get_base_url(bridge_ip) + return "https://" .. bridge_ip .. ":" .. config.REST_API_PORT +end + +-- Method for processing the REST response +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + local status, decoded_json = pcall(json.decode, response:get_body()) + + if status and response.status == 200 then + log.debug("process_rest_response(): Response = " .. response.status .. " " .. response:get_body()) + + return decoded_json, nil, response.status + elseif status then + log.error("process_rest_response(): Response error = " .. response.status) + + return nil, "response status is not 200 OK", response.status + else + log.error("process_rest_response(): Failed to decode data") + + return nil, "failed to decode data", nil + end + else + return nil, "no response or error received", nil + end +end + +-- Method for creating a retry function +local function retry_fn(retry_attempts) + local count = 0 + + return function() + count = count + 1 + return count < retry_attempts + end +end + +-- Method for performing a GET request +local function do_get(api_instance, path) + log.debug("do_get(): Sending GET request to " .. path) + + return process_rest_response(api_instance.client:get(path, api_instance.headers, retry_fn(5))) +end + +-- Method for performing a POST request +local function do_post(api_instance, path, payload) + log.debug("do_post(): Sending POST request to " .. path .. " with payload " .. json.encode(payload)) + + return process_rest_response(api_instance.client:post(path, payload, api_instance.headers, retry_fn(5))) +end + +-- Method for creating a labeled socket builder +function api.labeled_socket_builder(label) + local socket_builder = utils.labeled_socket_builder(label, SSL_CONFIG) + + return socket_builder +end + +-- Method for creating a new bridge manager +function api.new_bridge_manager(bridge_ip, bridge_dni) + local base_url = get_base_url(bridge_ip) + local socket_builder = api.labeled_socket_builder(bridge_dni) + + return setmetatable( + { + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), + client = RestClient.new(base_url, socket_builder), + base_url = base_url + }, + api + ) +end + +-- Method for getting the thing infos +function api.get_thing_infos(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (thing infos)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/devices", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_thing_infos(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- Method for getting the bridge info +function api.get_bridge_info(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (bridge info)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/bridge", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_bridge_info(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- API methods +function api:get_devices() + return do_get(self, "/devices") +end + +function api:get_device_by_id(id) + return do_get(self, string.format("/devices/%s", id)) +end + +function api:post_device_by_id(id, payload) + return do_post(self, string.format("/devices/%s/control", id), payload) +end + +function api:get_sse_url() + return self.base_url .. "/events" +end + +return api \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/abb/device_manager.lua b/drivers/SmartThings/abb-scu200/src/abb/device_manager.lua new file mode 100644 index 0000000000..aeb51bb4fd --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/abb/device_manager.lua @@ -0,0 +1,119 @@ +local log = require("log") + +-- Local imports +local utils = require("utils") +local fields = require("fields") +local api = require("abb.api") +local device_refresher = require("abb.device_refresher") + +-- Device manager methods +local device_manager = {} + +-- Method for checking if connection is valid +function device_manager.is_valid_connection(driver, device, conn_info) + local dni = utils.get_dni_from_device(device) + + if not conn_info then + log.error("device_manager.is_valid_connection(): Failed to find conn_info, dni = " .. dni) + return false + end + + local bridge_ip = utils.get_device_ip_address(device) + local thing_infos = api.get_thing_infos(bridge_ip, dni) + + if thing_infos and thing_infos.devices then + return true + else + log.error("device_manager.is_valid_connection(): Failed to get thing infos, dni = " .. dni) + return false + end +end + +-- Method for getting bridge connection info +function device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + local bridge_conn_info = api.new_bridge_manager(bridge_ip, bridge_dni) + + if bridge_conn_info == nil then + log.error("device_manager.get_bridge_connection_info(): No bridge connection info") + end + + return bridge_conn_info +end + +-- Method for handling JSON status +function device_manager.handle_device_json(driver, device, device_json) + local dni = utils.get_dni_from_device(device) + if dni == nil then + log.error("device_manager.handle_device_json(): dni is nil, the device has been probably deleted") + return + end + + if not device_json then + log.error("device_manager.handle_device_json(): device_json is nil, dni = " .. dni) + return + end + + log.debug("device_manager.handle_device_json(): dni: " .. dni .. " device_json = " .. utils.dump(device_json)) + + local status = device_json.status + if status ~= nil then + if status == "offline" then + log.info("device_manager.handle_device_json(): status is offline, dni = " .. dni) + + device:offline() + return + elseif status == "online" then + device:online() + end + end + + local values = device_json.values + if values == nil then + log.error("device_manager.handle_device_json(): values is nil, dni = " .. dni) + return + end + + device_refresher.refresh_device(driver, device, values) +end + +-- Method for refreshing device +function device_manager.refresh(driver, device) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + if not conn_info then + log.warn("device_manager.refresh(): Failed to find conn_info, dni = " .. dni) + return + end + + local response, err, status = conn_info:get_device_by_id(dni) + + if err or status ~= 200 then + status = status or "nil" + log.error("device_manager.refresh(): Failed to get device by id, dni = " .. dni .. ", err = " .. err .. ", status = " .. status) + + if status == 404 then + log.info("device_manager.refresh(): Deleted, dni = " .. dni) + + device:offline() + end + + return + end + + device_manager.handle_device_json(driver, device, response) +end + +-- Method for monitoring the connection of the bridge devices +function device_manager.bridge_monitor(driver, device, bridge_info) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, thing_device) + end) + end +end + +return device_manager \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/abb/device_refresher.lua b/drivers/SmartThings/abb-scu200/src/abb/device_refresher.lua new file mode 100644 index 0000000000..c13912c765 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/abb/device_refresher.lua @@ -0,0 +1,452 @@ +local log = require("log") +local caps = require('st.capabilities') + +-- Local imports +local utils = require("utils") +local config = require("config") +local fields = require('fields') + +-- Controller for refreshing device data +local device_refresher = {} + +local function refresh_current_sensor(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_current_sensor(): Refreshing data of Current Sensor, dni = " .. dni) + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_current_sensor(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePower + + if activePower ~= nil then + log.trace("refresh_current_sensor(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy + local activeEnergy = values.activeEnergy + local isExport = values.isExport + + if activeEnergy ~= nil and isExport ~= nil then + -- Determine Consumption and Production Active Energy values + local consumptionActiveEnergy = activeEnergy + local productionActiveEnergy = 0.0 + + if isExport then + consumptionActiveEnergy = 0.0 + productionActiveEnergy = activeEnergy + end + + -- Refresh Active Energy Consumption + log.trace("refresh_current_sensor(): Refreshing Active Energy Consumption, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=consumptionActiveEnergy, unit="kWh"})) + + -- Refresh Active Energy Production + log.trace("refresh_current_sensor(): Refreshing Active Energy Production, dni = " .. dni) + device.profile.components["productionMeter"]:emit_event(caps.energyMeter.energy({value=productionActiveEnergy, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption and production periodically + local current_consumption_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_production_report = device:get_latest_state("productionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption and production energy + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((consumptionActiveEnergy * 1000) - current_consumption_report.energy, 0.0) + end + + local delta_production_energy = 0.0 + if current_production_report ~= nil then + delta_production_energy = math.max((productionActiveEnergy * 1000) - current_production_report.energy, 0.0) + end + + -- Refresh Power Consumption / Production Report + log.trace("refresh_current_sensor(): Refreshing Power Consumption / Production Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=consumptionActiveEnergy * 1000, deltaEnergy=delta_consumption_energy})) + device.profile.components["productionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=productionActiveEnergy * 1000, deltaEnergy=delta_production_energy})) + + -- Save date of the last energy report + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_current_sensor(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_energy_meter_module(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_energy_meter_module(): Refreshing data of Energy Meter Module, dni = " .. dni) + + -- Refresh Voltage Measurement + local voltage = values.voltage + + if voltage ~= nil then + log.trace("refresh_energy_meter_module(): Refreshing Voltage Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.voltageMeasurement.voltage({value=voltage, unit="V"})) + end + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_energy_meter_module(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePower + + if activePower ~= nil then + log.trace("refresh_energy_meter_module(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy Net, Import & Export Total + local activeEnergyNetTotal = values.activeEnergyNetTotal + local activeEnergyImportTotal = values.activeEnergyImportTotal + local activeEnergyExportTotal = values.activeEnergyExportTotal + + if activeEnergyNetTotal ~= nil and activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- Refresh Active Energy Net Total + log.trace("refresh_energy_meter_module(): Refreshing Active Energy Net Total, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergyNetTotal, unit="kWh"})) + + -- Refresh Active Energy Import Total + log.trace("refresh_energy_meter_module(): Refreshing Active Energy Import Total, dni = " .. dni) + device.profile.components["consumptionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Refresh Active Energy Export Total + log.trace("refresh_energy_meter_module(): Refreshing Active Energy Export Total, dni = " .. dni) + device.profile.components["productionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyExportTotal, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy net, consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy net, consumption and production periodically + local current_net_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_consumption_report = device:get_latest_state("consumptionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_production_report = device:get_latest_state("productionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Make net energy data zero if negative + activeEnergyNetTotal = math.max(activeEnergyNetTotal, 0.0) + + -- Calculate delta net, consumption and production energy + local delta_net_energy = 0.0 + if current_net_report ~= nil then + delta_net_energy = math.max((activeEnergyNetTotal * 1000) - current_net_report.energy, 0.0) + end + + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + local delta_production_energy = 0.0 + if current_production_report ~= nil then + delta_production_energy = math.max((activeEnergyExportTotal * 1000) - current_production_report.energy, 0.0) + end + + -- Refresh Power Net, Consumption & Production Report + log.trace("refresh_energy_meter_module(): Refreshing Power Net, Consumption & Production Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyNetTotal * 1000, deltaEnergy=delta_net_energy})) + device.profile.components["consumptionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + device.profile.components["productionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyExportTotal * 1000, deltaEnergy=delta_production_energy})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_energy_meter_module(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_auxiliary_contact(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_auxiliary_contact(): Refreshing data of Auxiliary Contact, dni = " .. dni) + + -- Refresh Contact Sensor + local isClosed = values.isClosed + + if isClosed ~= nil then + log.trace("refresh_auxiliary_contact(): Refreshing Switch, dni = " .. dni) + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + end + + return true +end + +local function refresh_output_module(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_output_module(): Refreshing data of Output Module, dni = " .. dni) + + -- Refresh Switch + local isClosed = values.isClosed + log.trace("refresh_output_module(): Refreshing Switch, dni = " .. dni) + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + + return true +end + +local function refresh_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_energy_meter(): Refreshing data of Energy Meter, dni = " .. dni) + + -- Refresh Voltage Measurement + local voltage = values.voltage + + if voltage ~= nil then + log.trace("refresh_energy_meter(): Refreshing Voltage Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.voltageMeasurement.voltage({value=voltage, unit="V"})) + end + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_energy_meter(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePowerTotal = values.activePowerTotal + + if activePowerTotal ~= nil then + log.trace("refresh_energy_meter(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePowerTotal, unit="W"})) + end + + -- Refresh Active Energy Import/Export Total + local activeEnergyImportTotal = values.activeEnergyImportTotal + local activeEnergyExportTotal = values.activeEnergyExportTotal + + if activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- Refresh Active Energy Import Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Refresh Active Energy Export Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Export Total, dni = " .. dni) + device.profile.components["productionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyExportTotal, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption and production periodically + local current_consumption_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_production_report = device:get_latest_state("productionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption and production energy + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + local delta_production_energy = 0.0 + if current_production_report ~= nil then + delta_production_energy = math.max((activeEnergyExportTotal * 1000) - current_production_report.energy, 0.0) + end + + -- Refresh Power Consumption / Production Report + log.trace("refresh_energy_meter(): Refreshing Power Consumption / Production Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + device.profile.components["productionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyExportTotal * 1000, deltaEnergy=delta_production_energy})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_water_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_water_meter(): Refreshing data of Water Meter, dni = " .. dni) + + local unit = values.unit + if unit == nil then + log.error("refresh_water_meter(): The unit of the water meter is not set, dni = " .. dni) + return false + end + + -- Refresh Water Meter: last hour + local lastHourFlow = values.lastHourFlow + + if lastHourFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last hour, dni = " .. dni) + device:emit_event(caps.waterMeter.lastHour({value=lastHourFlow, unit=unit})) + end + + -- Refresh Water Meter: last 24 hours + local lastTwentyFourHoursFlow = values.lastTwentyFourHoursFlow + + if lastTwentyFourHoursFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 24 hours, dni = " .. dni) + device:emit_event(caps.waterMeter.lastTwentyFourHours({value=lastTwentyFourHoursFlow, unit=unit})) + end + + -- Refresh Water Meter: last 7 days + local lastSevenDaysFlow = values.lastSevenDaysFlow + + if lastSevenDaysFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 7 days, dni = " .. dni) + device:emit_event(caps.waterMeter.lastSevenDays({value=lastSevenDaysFlow, unit=unit})) + end + + return true +end + +local function refresh_gas_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_gas_meter(): Refreshing data of Gas Meter, dni = " .. dni) + + local gasMeterVolumeUnit = values.gasMeterVolumeUnit + if gasMeterVolumeUnit == nil then + log.error("refresh_gas_meter(): The unit of the gas meter is not set, dni = " .. dni) + return false + end + + -- Correct the unit if necessary + if gasMeterVolumeUnit == "m3" then + gasMeterVolumeUnit = "m^3" + end + + -- Refresh Gas Meter + local gasMeterVolume = values.gasMeterVolume + + if gasMeterVolume ~= nil then + log.trace("refresh_gas_meter(): Refreshing Gas Meter, dni = " .. dni) + device:emit_event(caps.gasMeter.gasMeterVolume({value=gasMeterVolume, unit=gasMeterVolumeUnit})) + end + + return true +end + +local function refresh_usb_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_usb_energy_meter(): Refreshing data of USB Energy Meter, dni = " .. dni) + + -- Refresh Active Power Import Total + local activePowerImportTotal = values.activePowerImportTotal + + if activePowerImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Power Import Total, dni = " .. dni) + device:emit_event(caps.powerMeter.power({value=activePowerImportTotal, unit="W"})) + end + + -- Refresh Active Energy Import Total + local activeEnergyImportTotal = values.activeEnergyImportTotal + + if activeEnergyImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption periodically + local current_consumption_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption energy + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + -- Refresh Power Consumption Report + log.trace("refresh_usb_energy_meter(): Refreshing Power Consumption Report, dni = " .. dni) + device:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_usb_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +function device_refresher.refresh_device(driver, device, values) + local dni, device_type = utils.get_dni_from_device(device) + log.info("device_refresher.refresh_device(): Refreshing data of device, dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("device_refresher.refresh_device(): Cannot refresh bridge device, dni = " .. dni) + return + end + + log.debug("device_refresher.refresh_device(): Provided values: " .. utils.dump(values)) + + local refresh_methods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = refresh_current_sensor, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = refresh_energy_meter_module, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = refresh_auxiliary_contact, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = refresh_output_module, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = refresh_energy_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = refresh_water_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = refresh_gas_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = refresh_usb_energy_meter + } + + local device_model = utils.get_device_model(device) + if device_model == nil then + log.error("device_refresher.refresh_device(): No device model found for device, dni = " .. dni) + return + end + + local refresh_method = refresh_methods[device_model] + if refresh_method == nil then + log.error("device_refresher.refresh_device(): No refresh method found for device, dni = " .. dni .. ", model = " .. device_model) + return + end + + return refresh_method(driver, device, values) +end + +return device_refresher \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/commands.lua b/drivers/SmartThings/abb-scu200/src/commands.lua new file mode 100644 index 0000000000..ad5f223918 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/commands.lua @@ -0,0 +1,112 @@ +local caps = require('st.capabilities') +local log = require('log') +local cosock = require "cosock" +local json = require('dkjson') + +-- Local imports +local utils = require('utils') +local fields = require('fields') +local config = require("config") +local device_manager = require('abb.device_manager') +local connection_monitor = require('connection_monitor') + +-- Commands handler for the bridge and thing devices +local commands = {} + +-- Method for posting the payload to the device +local function post_payload(device, payload) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + local response, err, status = conn_info:post_device_by_id(dni, payload) + if not err and status == 200 then + log.info("post_payload(): Success, dni = " .. dni) + + return true + else + status = status or "nil" + log.error("post_payload(): Error, err = " .. err .. ", status = " .. status .. ", dni = " .. dni) + + device:offline() + + return false + end +end + +-- Switch on command +function commands.switch_on(driver, device, cmd) + local dni, device_type = utils.get_dni_from_device(device) + log.info("commands.switch_on(): Switching on capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.on() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Switch off commands +function commands.switch_off(driver, device, cmd) + local dni, device_type = utils.get_dni_from_device(device) + log.info("commands.switch_off(): Switching off capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.off() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Refresh command +function commands.refresh(driver, device, cmd) + local dni, device_type = utils.get_dni_from_device(device) + log.info("commands.refresh(): Refresh capability within dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + connection_monitor.check_and_update_connection(driver, device) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device_manager.refresh(driver, thing_device) + end + elseif device_type == fields.DEVICE_TYPE_THING then + local bridge = device:get_parent_device() + + if bridge.thread ~= nil then + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, device) + end) + end + end +end + +return commands \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/config.lua b/drivers/SmartThings/abb-scu200/src/config.lua new file mode 100644 index 0000000000..7a6687b38c --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/config.lua @@ -0,0 +1,72 @@ +local config = {} + +-- Device Config +config.DEVICE_TYPE = "LAN" + +config.MANUFACTURER = "ABB" + +config.BRIDGE_PROFILE = "abb.scu200.bridge.v1" +config.BRIDGE_TYPE = "SCU200" +config.BRIDGE_VERSION = "1" + +config.BRIDGE_URN = "urn:" .. config.MANUFACTURER .. ":device:" .. config.BRIDGE_TYPE .. ":" .. config.BRIDGE_VERSION + +config.BRIDGE_CONN_MONITOR_INTERVAL = 300 -- 5 minutes + +-- Edge Child Config +config.EDGE_CHILD_TYPE = "EDGE_CHILD" + +config.EDGE_CHILD_CURRENT_SENSOR_TYPE = "CurrentSensor" +config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE = "EnergyMeterModule" +config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE = "AuxiliaryContact" +config.EDGE_CHILD_OUTPUT_MODULE_TYPE = "OutputModule" +config.EDGE_CHILD_ENERGY_METER_TYPE = "EnergyMeter" +config.EDGE_CHILD_WATER_METER_TYPE = "WaterMeter" +config.EDGE_CHILD_GAS_METER_TYPE = "GasMeter" +config.EDGE_CHILD_USB_ENERGY_METER_TYPE = "USBEnergyMeter" + +config.EDGE_CHILD_CURRENT_SENSOR_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION = 1 +config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION = 1 +config.EDGE_CHILD_OUTPUT_MODULE_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_VERSION = 1 +config.EDGE_CHILD_WATER_METER_VERSION = 1 +config.EDGE_CHILD_GAS_METER_VERSION = 1 +config.EDGE_CHILD_USB_ENERGY_METER_VERSION = 1 + +config.EDGE_CHILD_CURRENT_SENSOR_PROFILE = "abb.scu200.current-sensor.v1" +config.EDGE_CHILD_ENERGY_METER_MODULE_PROFILE = "abb.scu200.energy-meter-module.v1" +config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE = "abb.scu200.auxiliary-contact.v1" +config.EDGE_CHILD_OUTPUT_MODULE_PROFILE = "abb.scu200.output-module.v1" +config.EDGE_CHILD_ENERGY_METER_PROFILE = "abb.scu200.energy-meter.v1" +config.EDGE_CHILD_WATER_METER_PROFILE = "abb.scu200.water-meter.v1" +config.EDGE_CHILD_GAS_METER_PROFILE = "abb.scu200.gas-meter.v1" +config.EDGE_CHILD_USB_ENERGY_METER_PROFILE = "abb.scu200.usb-energy-meter.v1" + +config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD = 30 +config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD = 30 +config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD = 30 +config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD = 30 + +config.EDGE_CHILD_ENERGY_REPORT_INTERVAL = 900 -- 15 minutes + +-- REST API Config +config.REST_API_PORT = 1025 + +-- SSDP Config +config.MC_ADDRESS = "239.255.255.250" +config.MC_PORT = 1900 +config.MC_TIMEOUT = 5 +config.MSEARCH = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 239.255.255.250:1900", + "MAN: \"ssdp:discover\"", + "MX: 5", + "ST: " .. config.BRIDGE_URN +}, "\r\n") + +return config diff --git a/drivers/SmartThings/abb-scu200/src/connection_monitor.lua b/drivers/SmartThings/abb-scu200/src/connection_monitor.lua new file mode 100644 index 0000000000..bdeb2b9621 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/connection_monitor.lua @@ -0,0 +1,71 @@ +local log = require("log") + +-- Local imports +local fields = require("fields") +local utils = require("utils") +local discovery = require("discovery") +local eventsource_handler = require("eventsource_handler") +local device_manager = require("abb.device_manager") + +-- Connection monitor for the SCU200 Bridge +local connection_monitor = {} + +function connection_monitor.update_connection(driver, device, bridge_ip) + local bridge_dni = utils.get_dni_from_device(device) + log.info("connection_monitor.update_connection(): Update connection for bridge device: " .. bridge_dni) + + local conn_info = device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + + if device_manager.is_valid_connection(driver, device, conn_info) then + device:set_field(fields.CONN_INFO, conn_info) + eventsource_handler.create_sse(driver, device) + end +end + +local function find_new_connection(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("find_new_connection(): Find new connection for dni = " .. dni) + + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + local found_device = found_devices[dni] + + if found_device then + log.info("find_new_connection(): Found new connection for dni = " .. dni) + + local ip = found_device.ip + + device:set_field(fields.BRIDGE_IPV4, ip, {persist = true}) + connection_monitor.update_connection(driver, device, ip) + end + end +end + +function connection_monitor.check_and_update_connection(driver, device) + local dni = utils.get_dni_from_device(device) + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("connection_monitor.check_and_update_connection(): Disconnected from device. Try to find new connection for dni = " .. dni) + + find_new_connection(driver, device) + end +end + +-- Method for monitoring the connection of the bridge devices +function connection_monitor.monitor_connections(driver) + local device_list = driver:get_devices() + + for _, device in ipairs(device_list) do + if device:get_field(fields.DEVICE_TYPE) == fields.DEVICE_TYPE_BRIDGE then + local dni = utils.get_dni_from_device(device) + log.info("connection_monitor.monitor_connections(): Monitoring connection for bridge device: " .. dni) + + connection_monitor.check_and_update_connection(driver, device) + device_manager.bridge_monitor(driver, device) + end + end +end + +return connection_monitor \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/discovery.lua b/drivers/SmartThings/abb-scu200/src/discovery.lua new file mode 100644 index 0000000000..64f0cf475d --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/discovery.lua @@ -0,0 +1,314 @@ +local log = require "log" +local socket = require('socket') +local cosock = require "cosock" + +-- Local imports +local api = require("abb.api") +local config = require("config") +local utils = require("utils") +local fields = require("fields") + +-- Discovery service run within SmartThings app +local discovery = {} + +local joined_bridge = {} +local joined_thing = {} + +-- Method for setting the device fields +function discovery.set_device_fields(driver, device) + local dni = utils.get_dni_from_device(device) + + if joined_bridge[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for bridge: " .. dni) + local bridge_cache_value = driver.datastore.bridge_discovery_cache[dni] + + device:set_field(fields.BRIDGE_IPV4, bridge_cache_value.ip, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_BRIDGE, {persist = true}) + elseif joined_thing[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for thing: " .. dni) + local thing_cache_value = driver.datastore.thing_discovery_cache[dni] + + device:set_field(fields.PARENT_BRIDGE_DNI, thing_cache_value.parent_bridge_dni, {persist = true}) + device:set_field(fields.THING_INFO, thing_cache_value.thing_info, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_THING, {persist = true}) + else + log.warn("discovery.set_device_field(): Could not set device field for unknown device: " .. dni) + end +end + +-- Method for updating the bridge discovery cache +local function update_bridge_discovery_cache(driver, dni, device) + log.info("update_bridge_discovery_cache(): Updating bridge discovery cache: " .. dni) + + driver.datastore.bridge_discovery_cache[dni] = { + ip = device["ip"] + } +end + +-- Method for updating the thing discovery cache +local function update_thing_discovery_cache(driver, thing_dni, parent_bridge_dni, thing_info) + log.info("update_thing_discovery_cache(): Updating thing discovery cache: " .. thing_dni) + + driver.datastore.thing_discovery_cache[thing_dni] = { + parent_bridge_dni = parent_bridge_dni, + thing_info = thing_info, + } +end + +-- Method for trying to add a new bridge +local function try_add_bridge(driver, dni, device) + log.info("try_add_bridge(): Trying to add bridge: " .. dni) + + local bridge_info = api.get_bridge_info(device["ip"], dni) + if bridge_info == nil then + log.error("try_add_bridge(): Failed to get bridge info for bridge: " .. dni) + return false + end + + update_bridge_discovery_cache(driver, dni, device) + + local metadata = { + type = config.DEVICE_TYPE, + device_network_id = dni, + label = bridge_info.name, + profile = config.BRIDGE_PROFILE, + manufacturer = config.MANUFACTURER, + model = config.BRIDGE_TYPE, + vendor_provided_label = config.BRIDGE_TYPE + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_bridge(): Bridge created: " .. dni) + + return true + else + log.error("try_add_bridge(): Failed to create bridge: " .. dni) + log.debug("try_add_bridge(): Error: " .. err) + + return false + end +end + +-- Method for trying to add a new thing +local function try_add_thing(driver, parent_device, thing_dni, thing_info) + local parent_device_dni = utils.get_dni_from_device(parent_device) + log.info("try_add_thing(): Trying to add thing: " .. thing_dni .. " of type: " .. thing_info.type .. " on bridge: " .. parent_device_dni) + + update_thing_discovery_cache(driver, thing_dni, parent_device_dni, thing_info) + + if thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE) or thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE) then + log.warn("try_add_thing(): Not supported thing type: " .. thing_info.type) + return false + end + + local profile_ref = utils.get_thing_profile_ref(thing_info) + if profile_ref == nil then + log.error("try_add_thing(): Failed to get profile reference for thing: " .. thing_dni) + return false + end + + local parent_device_id = utils.get_device_id_from_device(parent_device) + + local metadata = { + type = config.EDGE_CHILD_TYPE, + label = thing_info.name, + vendor_provided_label = thing_info.name, + profile = profile_ref, + manufacturer = config.MANUFACTURER, + model = thing_info.type, + parent_device_id = parent_device_id, + parent_assigned_child_key = thing_info.uuid, + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_thing(): Thing created: " .. thing_dni) + + return true + else + log.error("try_add_thing(): Failed to create thing: " .. thing_dni) + log.debug("try_add_thing(): Error: " .. err) + + return false + end +end + +-- SSDP Response parser +local function parse_ssdp(data) + local res = {} + + res.status = data:sub(0, data:find('\r\n')) + + for line in data:gmatch("[^\r\n]+") do + _, _, header, value = string.find(line, "([%w-]+):%s*([%a+-:_ /=?]*)") + + if header ~= nil and value ~= nil then + res[header:lower()] = value + end + end + + return res +end + +-- Method for finding devices +function discovery.find_devices() + log.info("discovery.find_devices(): Finding devices") + + -- Initialize UDP socket + local upnp = cosock.socket.udp() + + upnp:setsockname('*', 0) + upnp:setoption("broadcast", true) + upnp:settimeout(config.MC_TIMEOUT) + + -- Broadcast M-SEARCH request + log.info("discovery.find_devices(): Scanning network...") + + upnp:sendto(config.MSEARCH, config.MC_ADDRESS, config.MC_PORT) + + -- Listen for responses + local devices = {} + local start_time = socket.gettime() + + while (socket.gettime() - start_time) < config.MC_TIMEOUT do + local res = upnp:receivefrom() + + if res ~= nil then + local device = parse_ssdp(res) + local dni = string.match(device["usn"], "^uuid:([a-zA-Z0-9-]+)::" .. config.BRIDGE_URN .. "$") + + if dni ~= nil then + local _, _, device_ip = string.find(device["location"], "https?://(%d+%.%d+%.%d+%.%d+):?%d*/?.*") + device["ip"] = device_ip + + devices[dni] = device + end + end + end + + -- Print found devices + if next(devices) then + for dni, device in pairs(devices) do + log.debug("discovery.find_devices(): Device found: " .. utils.dump(device)) + end + else + log.debug("discovery.find_devices(): No devices found") + end + + -- Close the UDP socket + upnp:close() + + log.debug("discovery.find_devices(): Stop scanning network") + + if devices ~= nil then + return devices + end + + return nil +end + +-- Start the discovery of bridges +local function discover_bridges(driver) + log.info("discover_bridges(): Discovering bridges") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_bridges(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Find new devices + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + for dni, device in pairs(found_devices) do + if not known_devices or not known_devices[dni] then + log.info("discover_bridges(): Found new bridge: " .. dni) + + if not joined_bridge[dni] then + if try_add_bridge(driver, dni, device) then + joined_bridge[dni] = true + + bridge_ip = device["ip"] + end + else + log.debug("discover_bridges(): Bridge already joined: " .. dni) + end + else + log.debug("discover_bridges(): Bridge already added: " .. dni) + end + end + end +end + +-- Start the discovery of things +local function discover_things(driver) + log.info("discover_things(): Discovering things") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_things(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Found new devices + for bridge_dni, bridge_cache_value in pairs(driver.datastore.bridge_discovery_cache) do + local bridge_ip = bridge_cache_value.ip + log.info("discover_things(): Fetching things from bridge: " .. bridge_dni .. " at IP: " .. bridge_ip) + + if known_devices[bridge_dni] ~= nil and known_devices[bridge_dni]:get_field(fields.CONN_INFO) ~= nil then + local thing_infos = api.get_thing_infos(bridge_ip, bridge_dni) + + if thing_infos and thing_infos.devices ~= nil then + for _, thing_info in pairs(thing_infos.devices) do + if thing_info ~= nil then + local thing_dni = thing_info.uuid + + log.info("discover_things(): Found thing: " .. thing_dni .. " on bridge: " .. bridge_dni) + + if thing_dni ~= nil then + if not known_devices[thing_dni] then + if try_add_thing(driver, known_devices[bridge_dni], thing_dni, thing_info) then + joined_thing[thing_dni] = true + end + elseif not joined_thing[thing_dni] then + log.debug("discover_things(): Thing already known: " .. thing_dni) + else + log.debug("discover_things(): Thing already joined: " .. thing_dni) + end + end + end + + cosock.socket.sleep(0.2) + end + end + end + end +end + +-- Main function to start the discovery service +function discovery.start(driver, _, should_continue) + log.info("discovery.start(): Starting discovery") + + while should_continue() do + discover_bridges(driver) + discover_things(driver) + + cosock.socket.sleep(0.2) + end + + log.info("discovery.start(): Ending discovery") +end + +return discovery \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/eventsource_handler.lua b/drivers/SmartThings/abb-scu200/src/eventsource_handler.lua new file mode 100644 index 0000000000..1d49fece27 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/eventsource_handler.lua @@ -0,0 +1,128 @@ +local log = require('log') +local json = require('dkjson') + +-- Local imports +local EventSource = require "lunchbox.sse.eventsource" +local device_manager = require("abb.device_manager") +local device_refresher = require("abb.device_refresher") +local fields = require "fields" +local utils = require "utils" + +local eventsource_handler = {} + +-- Method for handling an incoming SSE event +function eventsource_handler.handle_sse_event(driver, bridge, msg) + log.debug("eventsource_handler.handle_sse_event(): Handling SSE event (TYPE: " .. msg.type .. ", DATA: " .. msg.data .. ")") + + if msg.type == "valueChanged" then + local data = json.decode(msg.data) + + if data ~= nil and next(data) ~= nil then + -- Find the device + local device = nil + + local child_devices = bridge:get_child_list() + for _, thing_device in ipairs(child_devices) do + local dni = utils.get_dni_from_device(thing_device) + + if dni == data.uuid then + device = thing_device + break + end + end + + if device == nil then + log.warn("eventsource_handler.handle_sse_event(): Failed to find the device with dni: " .. data.uuid) + return + end + + -- Prepare the values + local values = {} + + values[data.attribute.name] = data.attribute.value + + -- Refresh the device + if device_refresher.refresh_device(driver, device, values) then + -- Define online status + device:online() + else + log.error("eventsource_handler.handle_sse_event(): Failed to update the device's values") + + -- Set device as offline + device:offline() + end + else + log.error("eventsource_handler.handle_sse_event(): Failed to decode JSON data: " .. msg.data) + end + elseif msg.type == "noDevices" then + log.info("eventsource_handler.handle_sse_event(): No devices to monitor found") + + eventsource_handler.close_sse(driver, bridge) + elseif msg.type == "refreshConnection" then + log.info("eventsource_handler.handle_sse_event(): Refreshing connection") + + eventsource_handler.close_sse(driver, bridge) + eventsource_handler.create_sse(driver, bridge) + else + log.warn("eventsource_handler.handle_sse_event(): Unknown SSE event type: " .. msg.type) + end +end + +-- Method for creating SSE +function eventsource_handler.create_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.create_sse(): Creating SSE for dni: " .. dni) + + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("eventsource_handler.create_sse(): Invalid connection for dni: " .. dni) + return + end + + local sse_url = conn_info:get_sse_url() + if not sse_url then + log.error("eventsource_handler.create_sse(): Failed to get sse_url for dni: " .. dni) + return + end + + log.trace("eventsource_handler.create_sse(): Creating SSE EventSource for " .. dni .. " with sse_url: " .. sse_url) + local eventsource = EventSource.new(sse_url, {}, nil, nil) + + eventsource.onmessage = function(msg) + if msg then + eventsource_handler.handle_sse_event(driver, device, msg) + end + end + + eventsource.onerror = function() + log.error("eventsource_handler.create_sse(): Error in the eventsource for dni: " .. dni) + device:offline() + end + + eventsource.onopen = function(msg) + log.info("eventsource_handler.create_sse(): Eventsource has been opened for dni: " .. dni) + device:online() + end + + local old_eventsource = device:get_field(fields.EVENT_SOURCE) + if old_eventsource then + log.info("eventsource_handler.create_sse(): Eventsource has been closed for dni: " .. dni) + old_eventsource:close() + end + device:set_field(fields.EVENT_SOURCE, eventsource) +end + +-- Method for closing SSE +function eventsource_handler.close_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.close_sse(): Closing SSE for dni: " .. dni) + + local eventsource = device:get_field(fields.EVENT_SOURCE) + if eventsource then + log.info("eventsource_handler.close_sse(): Closing eventsource for device: " .. dni) + eventsource:close() + end +end + +return eventsource_handler diff --git a/drivers/SmartThings/abb-scu200/src/fields.lua b/drivers/SmartThings/abb-scu200/src/fields.lua new file mode 100644 index 0000000000..2b1a16e539 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/fields.lua @@ -0,0 +1,16 @@ +-- Table of constants used to index in to device store fields +local fields = { + BRIDGE_IPV4 = "bridge_ipv4", + THING_INFO = "thing_info", + CONN_INFO = "conn_info", + PARENT_BRIDGE_DNI = "parent_bridge_dni", + EVENT_SOURCE = "eventsource", + DEVICE_TYPE = "devcie_type", + LAST_ENERGY_REPORT = "last_energy_report", + _INIT = "init", + + DEVICE_TYPE_BRIDGE = "bridge", + DEVICE_TYPE_THING = "thing" +} + +return fields \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/init.lua b/drivers/SmartThings/abb-scu200/src/init.lua new file mode 100644 index 0000000000..df33d18b1c --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/init.lua @@ -0,0 +1,45 @@ +local log = require('log') +local Driver = require('st.driver') +local caps = require('st.capabilities') + +-- Local imports +local discovery = require('discovery') +local commands = require('commands') +local config = require('config') +local lifecycles = require('lifecycles') +local connection_monitor = require('connection_monitor') + +-- Driver definition +local driver = Driver("ABB.SCU200", { + discovery = discovery.start, + lifecycle_handlers = lifecycles, + capability_handlers = { + -- Refresh command handler + [caps.refresh.ID] = { + [caps.refresh.commands.refresh.NAME] = commands.refresh + }, + [caps.switch.ID] = { + [caps.switch.commands.on.NAME] = commands.switch_on, + [caps.switch.commands.off.NAME] = commands.switch_off + } + } +}) + +-- Prepare datastores for bridge and thing discovery caches +if driver.datastore.bridge_discovery_cache == nil then + driver.datastore.bridge_discovery_cache = {} +end + +if driver.datastore.thing_discovery_cache == nil then + driver.datastore.thing_discovery_cache = {} +end + +-- Connection monitoring thread +driver:call_on_schedule(config.BRIDGE_CONN_MONITOR_INTERVAL, connection_monitor.monitor_connections, "SCU200 Bridge connection monitoring thread") + +-- Initialize driver +log.info("Starting driver") + +driver:run() + +log.warn("Exiting driver") \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/lifecycles.lua b/drivers/SmartThings/abb-scu200/src/lifecycles.lua new file mode 100644 index 0000000000..a73d17da51 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/lifecycles.lua @@ -0,0 +1,88 @@ +local log = require('log') + +-- Local imports +local fields = require('fields') +local utils = require('utils') +local discovery = require('discovery') +local commands = require('commands') +local connection_monitor = require('connection_monitor') +local device_manager = require('abb.device_manager') +local eventsource_handler = require("eventsource_handler") + +-- Lifecycles handlers for the driver +local lifecycles = {} + +-- Lifecycle handler for a device which has been initialized +function lifecycles.init(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + + -- Verify if the device has already been initialized + if device:get_field(fields._INIT) then + log.info("lifecycles.init(): Device already initialized: " .. dni .. " of type: " .. device_type) + return + end + + log.info("lifecycles.init(): Initializing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + if driver.datastore.bridge_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved bridge fields") + discovery.set_device_fields(driver, device) + end + + local bridge_ip = device:get_field(fields.BRIDGE_IPV4) + + connection_monitor.update_connection(driver, device, bridge_ip) + elseif device_type == fields.DEVICE_TYPE_THING then + if driver.datastore.thing_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved thing fields") + discovery.set_device_fields(driver, device) + end + + -- Refresh the device manually + commands.refresh(driver, device, _) + + -- Refresh schedule + local refresh_period = utils.get_thing_refresh_period(device) + + device.thread:call_on_schedule( + refresh_period, + function () + return commands.refresh(driver, device, _) + end, + "Refresh schedule") + end + + -- Set the device as initialized + device:set_field(fields._INIT, true, {persist = false}) +end + +-- Lifecycle handler for a device which has been added +function lifecycles.added(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.added(): Adding device: " .. dni .. " of type: " .. device_type) + + -- Force the initialization due to cases where the device is not initialized after being added + lifecycles.init(driver, device) +end + +-- Lifecycle handler for a device which has been removed +function lifecycles.removed(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.removed(): Removing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("lifecycles.removed(): Closing SSE for device: " .. dni) + + eventsource_handler.close_sse(driver, device) + elseif device_type == fields.DEVICE_TYPE_THING then + log.debug("lifecycles.removed(): Removing schedules for device: " .. dni) + + -- Remove the schedules to avoid unnecessary CPU processing + for timer in pairs(device.thread.timers) do + device.thread:cancel_timer(timer) + end + end +end + +return lifecycles \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/lunchbox/init.lua b/drivers/SmartThings/abb-scu200/src/lunchbox/init.lua new file mode 100644 index 0000000000..d454f39e68 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return {RestClient = RestClient, EventSource = EventSource} \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/lunchbox/rest.lua b/drivers/SmartThings/abb-scu200/src/lunchbox/rest.lua new file mode 100644 index 0000000000..b7c03525a6 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/lunchbox/rest.lua @@ -0,0 +1,427 @@ +---@class ChunkedResponse : Response +---@field package _received_body boolean +---@field package _parsed_headers boolean +---@field public new fun(status_code: number, socket: table?): ChunkedResponse +---@field public fill_body fun(self: ChunkedResponse): string? +---@field public append_body fun(self: ChunkedResponse, next_chunk_body: string): ChunkedResponse + +local socket = require "cosock.socket" +local utils = require "utils" +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" --[[@as ChunkedResponse]] +local json = require('dkjson') + +local api_version = require("version").api + +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 + + if client.base_url.port ~= port then port = client.base_url.port 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 + +---comment +---@param client RestClient +---@param request Request +---@return integer? bytes_sent +---@return string? err_msg +---@return integer idx +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available", 0 + 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) --[[@as ChunkedResponse]] + + 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 + else + return nil, next_err + 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 + local bytes_read = 0 + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + + repeat + next_recv, next_err, partial = sock:receive(content_length - bytes_read) + + if next_recv ~= nil and #next_recv >= 1 then + full_response:append_body(next_recv) + bytes_read = bytes_read + #next_recv + end + + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + bytes_read = bytes_read + #partial + end + until next_err == "closed" or bytes_read >= content_length + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function handle_response(sock) + if api_version >= 9 then + local response, err = Response.tcp_source(sock) + if err or (not response) then return response, (err or "unknown error") end + return response, response:fill_body() + end + -- 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 and headers:get_one("Transfer-Encoding") == "chunked" then + local response, err = parse_chunked_response(initial_recv, sock) + if err ~= nil then + return nil, err + end + full_response = response + 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", nil + end + + if client.socket == nil then + local success, err = connect(client) + if not success then return nil, err, nil 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, partial +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 query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:get(url_table.path .. query_params, additional_headers) + + client:shutdown() + + 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 query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + if type(body) == "table" then + body = json.encode(body) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:post(url_table.path .. query_params, body, additional_headers) + + client:shutdown() + + 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 \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/lunchbox/sse/eventsource.lua b/drivers/SmartThings/abb-scu200/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..d9a5930df2 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,523 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" +local json = require "dkjson" + +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 package _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field package _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field package _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field package _sock table? the TCP socket for the connection +--- @field package _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field package _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field package _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 +--- @param body table a set of key/value pairs (strings) to be sent as the body of the initial POST request. +local function create_request(url_table, extra_headers, body) + local request = Request.new("POST", 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") + :add_header("content-type", "application/json") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + local encoded_body = json.encode(body) + request = request:append_body(encoded_body) + + 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 == nil or r_idx == n_idx then + -- 1 character or no match + 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, source._body) + + 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 not response or err ~= nil then + return nil, err or "nil response from Response.tcp_source" + 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 and 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 and recv_as_num == 0 then + return -- the stream has ended + end + + 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 POST request +--- @param body table|nil an optional table of key-value pairs (strings) to be sent as the body of the initial POST 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, body, 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 = 15 * 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _body = body, + _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 \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/lunchbox/util.lua b/drivers/SmartThings/abb-scu200/src/lunchbox/util.lua new file mode 100644 index 0000000000..6eb94407eb --- /dev/null +++ b/drivers/SmartThings/abb-scu200/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 \ No newline at end of file diff --git a/drivers/SmartThings/abb-scu200/src/utils.lua b/drivers/SmartThings/abb-scu200/src/utils.lua new file mode 100644 index 0000000000..23c48e77c0 --- /dev/null +++ b/drivers/SmartThings/abb-scu200/src/utils.lua @@ -0,0 +1,212 @@ +local log = require("log") +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +-- Local imports +local config = require("config") +local fields = require("fields") + +-- Utility functions for the SmartThings edge driver +local utils = {} + +-- Get the device id from the device +function utils.get_dni_from_device(device) + if device.parent_assigned_child_key then + local thing_dni = device.parent_assigned_child_key + + return thing_dni, fields.DEVICE_TYPE_THING + else + local bridge_dni = device.device_network_id + + return bridge_dni, fields.DEVICE_TYPE_BRIDGE + end +end + +-- Get the device ID +function utils.get_device_id_from_device(device) + return device.st_store.id +end + +-- Get the device model +function utils.get_device_model(device) + local thing_info = device:get_field(fields.THING_INFO) + + if thing_info == nil then + return nil + end + + return thing_info.type +end + +-- Get the device IP address +function utils.get_device_ip_address(device) + local _, device_type = utils.get_dni_from_device(device) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + return device:get_field(fields.BRIDGE_IPV4) + else + local bridge = device:get_parent_device() + + return bridge:get_field(fields.BRIDGE_IPV4) + end +end + +-- Method for getting edge child device version by type +function utils.get_edge_child_device_version(edge_child_device_type) + local edge_child_device_versions = { + [config.EDGE_CHILD_CURRENT_SENSOR_TYPE] = config.EDGE_CHILD_CURRENT_SENSOR_VERSION, + [config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE] = config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION, + [config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE] = config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION, + [config.EDGE_CHILD_OUTPUT_MODULE_TYPE] = config.EDGE_CHILD_OUTPUT_MODULE_VERSION, + [config.EDGE_CHILD_ENERGY_METER_TYPE] = config.EDGE_CHILD_ENERGY_METER_VERSION, + [config.EDGE_CHILD_WATER_METER_TYPE] = config.EDGE_CHILD_WATER_METER_VERSION, + [config.EDGE_CHILD_GAS_METER_TYPE] = config.EDGE_CHILD_GAS_METER_VERSION, + [config.EDGE_CHILD_USB_ENERGY_METER_TYPE] = config.EDGE_CHILD_USB_ENERGY_METER_VERSION + } + + return edge_child_device_versions[edge_child_device_type] +end + +-- Method for getting the thing exact type +function utils.get_thing_exact_type(edge_child_device_type) + local device_version = utils.get_edge_child_device_version(edge_child_device_type) + + if device_version == nil then + return nil + end + + return config.MANUFACTURER .. "_" .. config.BRIDGE_TYPE .. "_" .. edge_child_device_type .. "_" .. device_version +end + +-- Method for getting the thing profile reference +function utils.get_thing_profile_ref(thing_info) + local thing_profiles = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = config.EDGE_CHILD_CURRENT_SENSOR_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_MODULE_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_PROFILE + } + + return thing_profiles[thing_info.type] +end + +-- Method for getting the thing refresh period +function utils.get_thing_refresh_period(device) + local device_model = utils.get_device_model(device) + + local thing_refresh_periods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD + } + + return thing_refresh_periods[device_model] +end + +-- Method for dumping a table to string +function utils.dump(o) + if type(o) == "table" then + local s = '{' + + for k,v in pairs(o) do + if type(k) ~= "number" then k = '"'..k..'"' end + s = s .. ' ['..k..'] = ' .. utils.dump(v) .. ',' + end + + return s .. '} ' + else + return tostring(o) + end +end + +-- Method for building a exponential backoff time value generator +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 + +-- Method for creating a labeled socket +function utils.labeled_socket_builder(label, ssl_config) + label = (label or "") + if #label > 0 then + label = label .. " " + end + + if not ssl_config then + ssl_config = { mode = "client", protocol = "any", verify = "none", options = "all" } + end + + local function make_socket(host, port, wrap_ssl) + log.info("utils.labeled_socket_builder(): Creating 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.debug("utils.labeled_socket_builder(): Setting TCP socket timeout for REST Connection: " .. label) + _, err = sock:settimeout(60) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Connecting TCP socket for REST Connection: " .. label) + _, err = sock:connect(host, port) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Set 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.debug("utils.labeled_socket_builder(): Creating SSL wrapper for REST Connection: " .. label) + sock, err = ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Performing SSL handshake for REST Connection: " .. label) + _, err = sock:dohandshake() + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info("utils.labeled_socket_builder(): Successfully created TCP connection: " .. label) + return sock, err + end + + return make_socket +end + +return utils \ No newline at end of file