Skip to content

Commit

Permalink
[Eve] Improve Eve Energy support (#940)
Browse files Browse the repository at this point in the history
* These changes improve the Eve Energy support

- A subdriver has been implemented
- It implements the switch, powerMeter, energyMeter and powerConsumptionReport capabilities
- It supports the refresh capability
- It reads with a timer each minute the current Watt and Watt Accumulated values from custom attributes
- The powerMeter and energyMeter values are updated each values or on a refresh
- A powerConsumptionReport is sent each 10 minutes by comparing the previous Watt Accumulated value with the current Watt Accumulated value

* Eve Energy: Fix 2 unused variable warnings from the Luacheck linter

* Use 2-width spaces instead of tabs

* Rename the profile to power-energy-powerConsumption

* We now store the timer in the device storage and not as a driver-level variable

* Don't override the on_off_attr_handler since it is equivalent to the one in the base driver

* Rename the profile to power-energy-powerConsumption

* Add initial basic tests

* Add missing copyright

* Remove the matter_handler fallback which is identical to the base driver

* Fix formatting

* Add unit test to chech that the power meter and energy meter are properly set up when the device is added

* Add unit test to check that the timer created in create_poll_schedule triggers the function requestData(). This function will read the standard and custom attributes from the device.

* Unit test to check the refresh command

* Add a unit test when reporting custom Watt data

* Add a unit test when reporting custom Watt accumulated data

* Unit test for a report with the custom Watt accumulated attribute sent after 10 minutes

* Set the correct creation year in the copyright

* Only send the powerConsumptionReport event every 15 minutes instead of every 10 minutes

* Eve Energy: Reset the consumption when using the SmartThings command to reset the energy meter

* - Set LAST_REPORT_TIME after reading it
- Add safety check in case os.time() returns a value before 2001 which could be the cases in unit tests

* Add unit tests for the reset command
  • Loading branch information
Timac authored Sep 25, 2023
1 parent e394dd7 commit 4b2c4de
Show file tree
Hide file tree
Showing 5 changed files with 604 additions and 4 deletions.
8 changes: 4 additions & 4 deletions drivers/SmartThings/matter-switch/fingerprints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
265 changes: 265 additions & 0 deletions drivers/SmartThings/matter-switch/src/eve-energy/init.lua
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions drivers/SmartThings/matter-switch/src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 4b2c4de

Please sign in to comment.