diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 4b9107020a..495a83972f 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4,22 +4,22 @@ matterManufacturer: deviceLabel: Eve Energy vendorId: 0x130A productId: 0x53 - deviceProfileName: plug-binary + deviceProfileName: power-energy-powerConsumption - id: "Eve/Energy/Europe" deviceLabel: Eve Energy vendorId: 0x130A productId: 0x50 - deviceProfileName: plug-binary + deviceProfileName: power-energy-powerConsumption - id: "Eve/Energy/U.K." deviceLabel: Eve Energy vendorId: 0x130A productId: 0x54 - deviceProfileName: plug-binary + deviceProfileName: power-energy-powerConsumption - id: "Eve/Energy/Australia" deviceLabel: Eve Energy vendorId: 0x130A productId: 0x5E - deviceProfileName: plug-binary + deviceProfileName: power-energy-powerConsumption #GE - id: "GELightingSavant/Bulb/A19" diff --git a/drivers/SmartThings/matter-switch/profiles/power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/power-energy-powerConsumption.yml new file mode 100644 index 0000000000..362edd1a30 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/power-energy-powerConsumption.yml @@ -0,0 +1,18 @@ +name: power-energy-powerConsumption +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug diff --git a/drivers/SmartThings/matter-switch/src/eve-energy/init.lua b/drivers/SmartThings/matter-switch/src/eve-energy/init.lua new file mode 100644 index 0000000000..196707fc61 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/eve-energy/init.lua @@ -0,0 +1,265 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +------------------------------------------------------------------------------------- +-- Definitions +------------------------------------------------------------------------------------- + +local capabilities = require "st.capabilities" +local log = require "log" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local utils = require "st.utils" +local data_types = require "st.matter.data_types" + +local EVE_MANUFACTURER_ID = 0x130A +local PRIVATE_CLUSTER_ID = 0x130AFC01 + +local PRIVATE_ATTR_ID_WATT = 0x130A000A +local PRIVATE_ATTR_ID_WATT_ACCUMULATED = 0x130A000B +local PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT = 0x130A000E + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local RECURRING_POLL_TIMER = "RECURRING_POLL_TIMER" +local TIMER_REPEAT = (1 * 60) -- Run the timer each minute +local REPORT_TIMEOUT = (15 * 60) -- Report the value each 15 minutes + + +------------------------------------------------------------------------------------- +-- Eve specifics +------------------------------------------------------------------------------------- + +local function is_eve_energy_products(opts, driver, device) + if device.manufacturer_info.vendor_id == EVE_MANUFACTURER_ID then + return true + end + + return false +end + +-- Return a ISO 8061 formatted timestamp in UTC (Z) +-- @return e.g. 2022-02-02T08:00:00Z +local function iso8061Timestamp(time) + return os.date("!%Y-%m-%dT%TZ", time) +end + +local function updateEnergyMeter(device, totalConsumptionWh) + -- Report the energy consumed + device:emit_event(capabilities.energyMeter.energy({ value = totalConsumptionWh, unit = "Wh" })) + + -- Only send powerConsumptionReport every couple of minutes (REPORT_TIMEOUT) + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_time = last_time + REPORT_TIMEOUT + if current_time < next_time then + return + end + + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) + + -- Calculate the energy consumed between the start and the end time + local previousTotalConsumptionWh = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, + capabilities.powerConsumptionReport.powerConsumption.NAME) + + local deltaEnergyWh = 0.0 + if previousTotalConsumptionWh ~= nil and previousTotalConsumptionWh.energy ~= nil then + deltaEnergyWh = math.max(totalConsumptionWh - previousTotalConsumptionWh.energy, 0.0) + end + + local startTime = iso8061Timestamp(last_time) + local endTime = iso8061Timestamp(current_time - 1) + + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ + start = startTime, + ["end"] = endTime, + deltaEnergy = deltaEnergyWh, + energy = totalConsumptionWh + })) +end + + +------------------------------------------------------------------------------------- +-- Timer +------------------------------------------------------------------------------------- + +local function requestData(device) + -- Update the on/off status + device:send(clusters.OnOff.attributes.OnOff:read(device)) + + -- Update the Watt usage + device:send(cluster_base.read(device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT, nil)) + + -- Update the energy consumption + device:send(cluster_base.read(device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT_ACCUMULATED, nil)) +end + +local function create_poll_schedule(device) + -- The powerConsumption report needs to be updated at least every 15 minutes in order to be included in SmartThings Energy + -- Eve Energy generally report changes every 10 or 17 minutes + local timer = device.thread:call_on_schedule(TIMER_REPEAT, function() + requestData(device) + end, "polling_schedule_timer") + + device:set_field(RECURRING_POLL_TIMER, timer) +end + + +------------------------------------------------------------------------------------- +-- Matter Utilities +------------------------------------------------------------------------------------- + +--- component_to_endpoint helper function to handle situations where +--- device does not have endpoint ids in sequential order from 1 +--- In this case the function returns the lowest endpoint value that isn't 0 +local function find_default_endpoint(device, component) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = device:get_endpoints(nil) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + res = v + break + end + end + return res +end + +local function component_to_endpoint(device, component_id) + -- Assumes matter endpoint layout is sequentional starting at 1. + local ep_num = component_id:match("switch(%d)") + return ep_num and tonumber(ep_num) or find_default_endpoint(device, component_id) +end + +local function endpoint_to_component(device, ep) + local switch_comp = string.format("switch%d", ep) + if device.profile.components[switch_comp] ~= nil then + return switch_comp + else + return "main" + end +end + + +------------------------------------------------------------------------------------- +-- Device Management +------------------------------------------------------------------------------------- + +local function device_init(driver, device) + log.info_with({ hub_logs = true }, "device init") + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) + device:subscribe() + + create_poll_schedule(device) +end + +local function device_added(driver, device) + -- Reset the values + device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) +end + +local function device_removed(driver, device) + local poll_timer = device:get_field(RECURRING_POLL_TIMER) + if poll_timer ~= nil then + device.thread:cancel_timer(poll_timer) + device:set_field(RECURRING_POLL_TIMER, nil) + end +end + +local function handle_refresh(self, device) + requestData(device) +end + +local function handle_resetEnergyMeter(self, device) + -- 978307200 is the number of seconds from 1 January 1970 to 1 January 2001 + local current_time = os.time() + local current_time_2001 = current_time - 978307200 + if current_time_2001 < 0 then + current_time_2001 = 0 + end + + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local startTime = iso8061Timestamp(last_time) + local endTime = iso8061Timestamp(current_time - 1) + + -- Reset the consumption on the device + local data = data_types.validate_or_build_type(current_time_2001, data_types.Uint32) + device:send(cluster_base.write(device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT, nil, + data)) + + -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ + start = startTime, + ["end"] = endTime, + deltaEnergy = 0, + energy = 0 + })) + + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) +end + +------------------------------------------------------------------------------------- +-- Eve Energy Handler +------------------------------------------------------------------------------------- + +local function watt_attr_handler(driver, device, ib, zb_rx) + if ib.data.value then + local wattValue = ib.data.value + device:emit_event(capabilities.powerMeter.power({ value = wattValue, unit = "W" })) + end +end + +local function watt_accumulated_attr_handler(driver, device, ib, zb_rx) + if ib.data.value then + local totalConsumptionRawValue = ib.data.value + local totalConsumptionWh = utils.round(1000 * totalConsumptionRawValue) + updateEnergyMeter(device, totalConsumptionWh) + end +end + +local eve_energy_handler = { + NAME = "Eve Energy Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + removed = device_removed, + }, + matter_handlers = { + attr = { + [PRIVATE_CLUSTER_ID] = { + [PRIVATE_ATTR_ID_WATT] = watt_attr_handler, + [PRIVATE_ATTR_ID_WATT_ACCUMULATED] = watt_accumulated_attr_handler + } + }, + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + }, + [capabilities.energyMeter.ID] = { + [capabilities.energyMeter.commands.resetEnergyMeter.NAME] = handle_resetEnergyMeter, + }, + }, + supported_capabilities = { + capabilities.switch, + capabilities.powerMeter, + capabilities.energyMeter, + capabilities.powerConsumptionReport + }, + can_handle = is_eve_energy_products +} + +return eve_energy_handler diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index dca3820d04..81ac2420e3 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -345,6 +345,9 @@ local matter_driver_template = { capabilities.colorControl, capabilities.colorTemperature, }, + sub_drivers = { + require("eve-energy") + } } local matter_driver = MatterDriver("matter-switch", matter_driver_template) diff --git a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua new file mode 100644 index 0000000000..2f3f6a276b --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua @@ -0,0 +1,314 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" + +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" + +local PRIVATE_CLUSTER_ID = 0x130AFC01 +local PRIVATE_ATTR_ID_WATT = 0x130A000A +local PRIVATE_ATTR_ID_WATT_ACCUMULATED = 0x130A000B +local PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT = 0x130A000E + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x130A, + product_id = 0x0050, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + }, + { + cluster_id = PRIVATE_CLUSTER_ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + } + } + } + } +}) + +local function test_init() + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "On command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, 1) + } + } + } +) + +test.register_message_test( + "Off command should send the appropriate commands", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, 1) + } + } + } +) + +test.register_coroutine_test( + "Check the power and energy meter when the device is added", function() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 0.0, unit = "W" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + ) + + test.wait_for_events() + end +) + + +test.register_coroutine_test( + "Check that the timer created in create_poll_schedule properly reads the device in requestData", + function() + test.mock_time.advance_time(60000) -- Ensure that the timer created in create_poll_schedule triggers + test.socket.matter:__set_channel_ordering("relaxed") + + test.socket.matter:__expect_send({ mock_device.id, clusters.OnOff.attributes.OnOff:read(mock_device) }) + test.socket.matter:__expect_send({ mock_device.id, + cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT, nil) }) + test.socket.matter:__expect_send({ mock_device.id, + cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT_ACCUMULATED, nil) }) + + test.wait_for_events() + end, + { + test_init = function() + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + } + test.socket.matter:__set_channel_ordering("relaxed") + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) + test.mock_device.add_test_device(mock_device) + + test.timer.__create_and_queue_test_time_advance_timer(60, "interval", "create_poll_schedule") + end + } +) + +test.register_coroutine_test( + "Check the refresh command", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = capabilities.refresh.ID, command = capabilities.refresh.commands.refresh.NAME, args = {} }, + } + ) + + test.socket.matter:__expect_send({ mock_device.id, clusters.OnOff.attributes.OnOff:read(mock_device) }) + test.socket.matter:__expect_send({ mock_device.id, + cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT, nil) }) + test.socket.matter:__expect_send({ mock_device.id, + cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT_ACCUMULATED, nil) }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Report with the custom Watt attribute", function() + local data = data_types.validate_or_build_type(50, data_types.Uint16, "watt") + test.socket.matter:__queue_receive( + { + mock_device.id, + cluster_base.build_test_report_data( + mock_device, + 0x01, + PRIVATE_CLUSTER_ID, + PRIVATE_ATTR_ID_WATT, + data + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 50, unit = "W" })) + ) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Report with the custom Watt accumulated attribute", function() + local data = data_types.validate_or_build_type(50, data_types.Uint16, "watt accumulated") + test.socket.matter:__queue_receive( + { + mock_device.id, + cluster_base.build_test_report_data( + mock_device, + 0x01, + PRIVATE_CLUSTER_ID, + PRIVATE_ATTR_ID_WATT_ACCUMULATED, + data + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50000, unit = "Wh" })) + ) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Report with the custom Watt accumulated attribute after 10 minutes", function() + local currentTime = 60000 + test.mock_time.advance_time(currentTime) + + local data = data_types.validate_or_build_type(50, data_types.Uint16, "watt accumulated") + test.socket.matter:__queue_receive( + { + mock_device.id, + cluster_base.build_test_report_data( + mock_device, + 0x01, + PRIVATE_CLUSTER_ID, + PRIVATE_ATTR_ID_WATT_ACCUMULATED, + data + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50000, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 50000, + deltaEnergy = 0.0, + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T16:39:59Z" + })) + ) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Check the reset command", function() + local timeDiff = 1 + local currentTime = 978307200 + timeDiff -- 1 January 2001 + test.mock_time.advance_time(currentTime) + + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.energyMeter.ID, + command = capabilities.energyMeter.commands.resetEnergyMeter.NAME, + args = {} + }, + } + ) + + local data = data_types.validate_or_build_type(timeDiff, data_types.Uint32) + test.socket.matter:__expect_send({ mock_device.id, + cluster_base.write(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT, nil, data) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + energy = 0, + deltaEnergy = 0, + start = "1970-01-01T00:00:00Z", + ["end"] = "2001-01-01T00:00:00Z" + })) + ) + + test.wait_for_events() + end +) + +test.run_registered_tests()