diff --git a/.marketplace/devices/devices.yml b/.marketplace/devices/devices.yml index a872ca54..d6d7057e 100644 --- a/.marketplace/devices/devices.yml +++ b/.marketplace/devices/devices.yml @@ -608,3 +608,13 @@ blueprint_options: - blueprint: gas_sensors/igd_toc_635 verification_level: verified + +- id: smappee_infinity + display_name: Smappee Infinity + description: All-in-one energy management system. + icon: enapter-home + vendor: igd + category: power_meters + blueprint_options: + - blueprint: power_meters/smappee_infinity + verification_level: verified diff --git a/.marketplace/vendors/icons/smappee.png b/.marketplace/vendors/icons/smappee.png new file mode 100644 index 00000000..ab27ecf2 Binary files /dev/null and b/.marketplace/vendors/icons/smappee.png differ diff --git a/.marketplace/vendors/vendors.yml b/.marketplace/vendors/vendors.yml index e35024b4..fa57baad 100644 --- a/.marketplace/vendors/vendors.yml +++ b/.marketplace/vendors/vendors.yml @@ -197,3 +197,8 @@ display_name: International Gas Detectors Ltd icon_url: https://raw.githubusercontent.com/Enapter/marketplace/main/.marketplace/vendors/icons/igd.png website: https://www.internationalgasdetectors.com + +- id: smappee + display_name: Smappee + icon_url: https://raw.githubusercontent.com/Enapter/marketplace/main/.marketplace/vendors/icons/smappee.png + website: https://www.smappee.com/ diff --git a/power_meters/smappee_infinity/README.md b/power_meters/smappee_infinity/README.md new file mode 100644 index 00000000..829f1ae3 --- /dev/null +++ b/power_meters/smappee_infinity/README.md @@ -0,0 +1,19 @@ +# Smappee Infinity + +This [Enapter Device Blueprint](https://go.enapter.com/marketplace-readme) integrates **Smappee Infinity** - Smart energy management solution for any energy need - via [HTTP API](https://go.enapter.com/developers-enapter-http) implemented on [Enapter Virtual UCM](https://go.enapter.com/handbook-vucm). + +## Connect to Enapter + +- Sign up to Enapter Cloud using [Web](https://cloud.enapter.com/) or mobile app ([iOS](https://apps.apple.com/app/id1388329910), [Android](https://play.google.com/store/apps/details?id=com.enapter&hl=en)). +- Use [Enapter Gateway](https://go.enapter.com/handbook-gateway-setup) to run Virtual UCM. +- Create [Enapter Virtual UCM](https://go.enapter.com/handbook-vucm). +- [Upload](https://go.enapter.com/developers-upload-blueprint) this blueprint to Enapter Virtual UCM. +- Use the `Set Up Connection` command in the Enapter mobile or Web app to set up the Enel JuiceBox 32 communication parameters: + - [Your Client ID](https://go.enapter.com/smappee-devapi-docs); + - [Your Client secret](https://go.enapter.com/smappee-devapi-docs). + - [Your Smappee username](https://go.enapter.com/smappee-devapi-docs). + - [Your Smappee password](https://go.enapter.com/smappee-devapi-docs). + +## References + +- [Smappee Infinity product page](https://go.enapter.com/smappee-infinity-product) diff --git a/power_meters/smappee_infinity/manifest.yml b/power_meters/smappee_infinity/manifest.yml new file mode 100644 index 00000000..41f2ff02 --- /dev/null +++ b/power_meters/smappee_infinity/manifest.yml @@ -0,0 +1,200 @@ +blueprint_spec: device/1.0 +display_name: Smappee Infinity +description: Smart energy monitoring system +icon: enapter-home +vendor: smappee +license: MIT +author: enapter +contributors: + - anataty +support: + url: https://go.enapter.com/enapter-blueprint-support + email: support@enapter.com +verification_level: verified + +communication_module: + product: ENP-VIRTUAL + lua: + dir: smappee_lua + amalg_mode: nodebug + dependencies: + - enapter-ucm + +properties: + vendor: + type: string + display_name: Vendor + model: + type: string + display_name: Model + +telemetry: + status: + type: string + display_name: State + enum: + - ok + - warning + consumption_power: + type: float + unit: Wh + display_name: Consumption + voltage1: + type: float + unit: V + display_name: Voltage Line 1 + voltage2: + type: float + unit: V + display_name: Voltage Line 2 + voltage3: + type: float + unit: V + display_name: Voltage Line 3 + current1_PHASEA: + type: float + unit: A + display_name: Main Load Current phase A + current1_PHASEB: + type: float + unit: A + display_name: Main Load Current phase B + current1_PHASEC: + type: float + unit: A + display_name: Main Load Current phase C + active1_PHASEA: + type: float + unit: W + display_name: Main Load active power phase A + active1_PHASEB: + type: float + unit: W + display_name: Main Load active power phase B + active1_PHASEC: + type: float + unit: W + display_name: Main Load active power phase C + current2_PHASEA: + type: float + unit: A + display_name: Zone Carport/Cafe Current phase A + current2_PHASEB: + type: float + unit: A + display_name: Zone Carport/Cafe Current phase B + current2_PHASEC: + type: float + unit: A + display_name: Zone Carport/Cafe Current phase C + active2_PHASEA: + type: float + unit: W + display_name: Zone Carport/Cafe active power phase A + active2_PHASEB: + type: float + unit: W + display_name: Zone Carport/Cafe active power phase B + active2_PHASEC: + type: float + unit: W + display_name: Zone Carport/Cafe active power phase C + current3_PHASEA: + type: float + unit: A + display_name: Zone Hotel / Office Current phase A + current3_PHASEB: + type: float + unit: A + display_name: Zone Hotel / Office Current phase B + current3_PHASEC: + type: float + unit: A + display_name: Zone Hotel / Office Current phase C + active3_PHASEA: + type: float + unit: W + display_name: Zone Hotel / Office active power phase A + active3_PHASEB: + type: float + unit: W + display_name: Zone Hotel / Office active power phase B + active3_PHASEC: + type: float + unit: W + display_name: Zone Hotel / Office active power phase C + +alerts: + no_data: + severity: warning + display_name: No data from device + description: > + Can't read data from Smappee Infinity. + Please check connection parameters. + connection_error: + severity: warning + display_name: Connection Error + description: > + Please use "Set Up Connection" command to set up your + Smappee account configuration. + no_service_location_name: + severity: warning + display_name: No service location name + description: > + Please use "Set Up Connection" command to set up your + Smappee service location name. + invalid_service_location_id: + severity: warning + display_name: Incorrect service location name + description: > + Please double check Smappee service location name. + +command_groups: + connection: + display_name: Connection + +commands: + # TODO: mark commands containing secrets + write_configuration: + display_name: Set Up Connection + description: Set your Smappee account parameters + group: connection + populate_values_command: read_configuration + ui: + icon: file-document-edit-outline + arguments: + username: + display_name: Smappee username + type: string + required: true + password: + display_name: Smappee password + type: string + required: true + client_id: + display_name: Smappee Client ID + type: string + required: true + client_secret: + display_name: Smappee Client Secret + type: string + required: true + service_location_name: + display_name: Smappee Service Location name + type: string + required: false + read_configuration: + display_name: Read Connection Parameters + group: connection + ui: + icon: file-check-outline + +.cloud: + mobile_main_chart: consumption_power + mobile_charts: + - consumption_power + - active_power + - current + - voltage1 + - voltage2 + - voltage3 diff --git a/power_meters/smappee_infinity/smappee_lua/http_connection.lua b/power_meters/smappee_infinity/smappee_lua/http_connection.lua new file mode 100644 index 00000000..07ddea78 --- /dev/null +++ b/power_meters/smappee_infinity/smappee_lua/http_connection.lua @@ -0,0 +1,248 @@ +local SmappeeHTTP = {} + +local json = require('json') +local net_url = require('net.url') + +function SmappeeHTTP.new(username, password, client_secret, client_id, location_name) + assert( + type(username) == 'string', + 'username (arg #1) must be string, given: ' .. inspect(username) + ) + assert( + type(password) == 'string', + 'password (arg #2) must be string, given: ' .. inspect(password) + ) + assert( + type(client_secret) == 'string', + 'client_secret (arg #3) must be string, given: ' .. inspect(client_secret) + ) + assert( + type(client_id) == 'string', + 'client_id (arg #4) must be string, given: ' .. inspect(client_id) + ) + + local self = setmetatable({}, { __index = SmappeeHTTP }) + + self.username = username + self.password = password + self.client_secret = client_secret + self.client_id = client_id + self.location_name = location_name + + self.url = net_url.parse('https://app1pub.smappee.net/dev/v3') + self.client = http.client({ timeout = 5 }) + + return self +end + +function SmappeeHTTP:process_unauthorized(request_type, headers, url, body) + local request = http.request(request_type, url, body) + + if headers ~= nil then + for name, value in pairs(headers) do + request:set_header(name, value) + end + end + + local response, err = self.client:do_request(request) + + if err then + return nil, err + elseif response.code ~= 200 then + return nil, 'non-OK code: ' .. tostring(response.code) + else + return json.decode(response.body), nil + end +end + +function SmappeeHTTP:process_authorized(request_type, url, body) + local request = http.request(request_type, url, body) + + request:set_header('Authorization', 'Bearer ' .. self.access_token) + request:set_header('Content-Type', 'application/json') + + local response, err = self.client:do_request(request) + + if err then + return nil, err + elseif response.code ~= 200 then + return nil, 'non-OK code: ' .. tostring(response.code) + else + return json.decode(response.body), nil + end +end + +function SmappeeHTTP:set_token() + local body = { + grant_type = 'password', + client_id = self.client_id, + client_secret = self.client_secret, + username = self.username, + password = self.password, + } + + local headers = {} + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + local response, err = self:process_unauthorized( + 'POST', headers, self.url .. 'oauth2/token', body + ) + if err then + return 'set_token failed: ' .. tostring(err) + end + + if response ~= nil then + self.access_token = response['access_token'] + self.new_token = response['refresh_token'] + if response['expires_in'] == nil then + return 'no_expire_time' + else + self.expires = response['expires_in'] + os.time() + end + else + return 'no_tokens_data' + end +end + +function SmappeeHTTP:refresh_token() + local body = { + grant_type = 'refresh_token', + client_id = self.client_id, + client_secret = self.client_secret, + refresh_token = self.new_token, + } + + local headers = {} + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + local response, err = self:process_unauthorized( + 'POST', headers, self.url .. 'oauth2/token', body + ) + if err then + return 'resfresh_token failed: ' .. tostring(err) + end + + if response ~= nil then + self.access_token = response['access_token'] + self.new_token = response['refresh_token'] + if response['expires_in'] == nil then + return 'no_expire_time' + else + self.expires = response['expires_in'] + os.time() + end + else + return 'no_refresh_tokens_data' + end +end + +function SmappeeHTTP:set_service_locations() + local response, err = self:process_authorized('GET', self.url .. 'servicelocation') + if err then + return 'set_service_locations failed: ' .. tostring(err) + end + + if response ~= nil then + self.service_locations = response['serviceLocations'] + else + return 'no_data' + end +end + +function SmappeeHTTP:set_service_location_id() + local err = self:set_service_locations() + if err == nil then + if type(self.service_locations) == 'table' then + for _, location in ipairs(self.service_locations) do + for k, v in pairs(location) do + if k == 'name' and v == self.location_name then + self.service_location_id = location['serviceLocationId'] + end + end + end + if not self.service_location_id then + return 'no_service_location_id' + end + else + return 'service_locations_invalid_type' + end + else + return tostring(err) + end +end + +function SmappeeHTTP:set_metering_configuration() + local response, err = self:process_authorized( + 'GET', + self.url .. 'servicelocation/' .. self.service_location_id .. '/meteringconfiguration' + ) + if err then + return 'set_metering_configuration failed: ' .. tostring(err) + end + + if response ~= nil then + local inputs = {} + + for i, input in ipairs(response['measurements']) do + if not inputs[i] then + inputs[i] = {} + end + inputs[i]['name'] = input['name'] + for _, val in ipairs(input['channels']) do + if not inputs[i]['indexes'] then + inputs[i]['indexes'] = {} + end + inputs[i]['indexes'][val['phase']] = val['consumptionIndex'] + end + end + + self.inputs = inputs + else + return 'no_metering_data' + end +end + +function SmappeeHTTP:get_electricity_consumption(from, to) + local url = self.url / 'servicelocation' / self.service_location_id / 'consumption' + url:setQuery({ aggregation = 1, from = from, to = to }) + + local response, err = self:process_authorized('GET', url) + + if err then + return nil, 'get_electricity_consumption failed: ' .. tostring(err) + end + + local err = self:set_metering_configuration() + if err ~= nil then + return nil, err + end + + local telemetry = {} + if response ~= nil then + if response['serviceLocationId'] == self.service_location_id then + local data = response['consumptions'][1] + if data ~= nil then + telemetry['voltage1'] = data['lineVoltages'][1] + telemetry['voltage2'] = data['lineVoltages'][2] + telemetry['voltage3'] = data['lineVoltages'][3] + telemetry['consumption_power'] = data['consumption'] + + for i, input in ipairs(self.inputs) do + for phase, index in pairs(input['indexes']) do + telemetry['current' .. i .. '_' .. phase] = data['current'][index + 1] + telemetry['active' .. i .. '_' .. phase] = data['active'][index + 1] + end + end + telemetry['status'] = 'ok' + end + else + telemetry['status'] = 'warning' + telemetry['alerts'] = { 'invalid_service_location_id' } + end + end + telemetry['status'] = 'warning' + telemetry['alerts'] = { 'no_data' } + + return telemetry, nil +end + +return SmappeeHTTP diff --git a/power_meters/smappee_infinity/smappee_lua/main.lua b/power_meters/smappee_infinity/smappee_lua/main.lua new file mode 100644 index 00000000..22f98545 --- /dev/null +++ b/power_meters/smappee_infinity/smappee_lua/main.lua @@ -0,0 +1,107 @@ +local config = require('enapter.ucm.config') +local SmappeeHTTP = require('http_connection') + +-- Configuration variables must be also defined +-- in `write_configuration` command arguments in manifest.yml +USERNAME = 'username' +PASSWORD = 'password' +CLIENT_SECRET = 'client_secret' +CLIENT_ID = 'client_id' +SERVICE_LOCATION_NAME = 'service_location_name' + +-- holds global Smappee connection +local smappee + +-- Initiate device firmware. Called at the end of the file. +function main() + scheduler.add(30000, send_properties) + scheduler.add(2000, send_telemetry) + + config.init({ + [USERNAME] = { type = 'string', required = true }, + [PASSWORD] = { type = 'string', required = true }, + [CLIENT_SECRET] = { type = 'string', required = true }, + [CLIENT_ID] = { type = 'string', required = true }, + [SERVICE_LOCATION_NAME] = { type = 'string', required = true }, + }, + { + after_write = function(args) + if args.service_location_name == nil then + return "service_location_name is required" + else + smappee:refresh_token() + smappee:set_service_location_id() + end + end + }) +end + +function send_properties() + enapter.send_properties({ + vendor = 'Smappee', + model = 'Infinity', + }) +end + +function send_telemetry() + local smappee, err = connect_smappee() + if err then + enapter.log("Can't connect to Smappee: " .. err) + enapter.send_telemetry({ + status = 'warning', + alerts = { 'connection_error' }, + }) + return + else + local location_name = config.read(SERVICE_LOCATION_NAME) + if tostring(location_name) ~= '' then + smappee:set_service_location_id(location_name, smappee:get_service_locations()) + + local telemetry, err = smappee:get_electricity_consumption((os.time() - 600) * 1000, os.time() * 1000) + if err == nil then + enapter.send_telemetry(telemetry) + else + enapter.log(err, 'error', true) + enapter.send_telemetry({ status = 'warning', alerts = 'no_data' }) + end + end + end +end + +function connect_smappee() + if smappee and smappee.expires ~= nil then + if smappee.expires <= os.time() then + local err = smappee:refresh_token() + if err ~= nil then + return nil, err + end + end + + return smappee, nil + end + + local values, err = config.read_all() + if err then + enapter.log('cannot read config: ' .. tostring(err), 'error') + return nil, 'cannot_read_config' + else + local client_secret, client_id = values[CLIENT_SECRET], values[CLIENT_ID] + local username, password = values[USERNAME], values[PASSWORD] + local location_name = values[SERVICE_LOCATION_NAME] + + if not client_secret or not client_id or not username or not password or not location_name then + return nil, 'not_configured' + else + smappee = SmappeeHTTP.new(username, password, client_secret, client_id) + + local err = smappee:set_token() + if err == nil then + return smappee, nil + else + return nil, err + end + end + end +end + +main()