diff --git a/.marketplace/devices/devices.yml b/.marketplace/devices/devices.yml index a7053a02..fe1d1e7f 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: deye_inverter-sun-10k-sg04lp3-eu + display_name: Deye Inverter SUN-10k-SG04LP3-EU + description: Three-phase hybrid inverter. + icon: enapter-inverter-solar + vendor: deye + category: solar_inverters + blueprint_options: + - blueprint: solar_inverters/deye_sun-10k-sg04lp3-eu_solarman + verification_level: verified diff --git a/.marketplace/vendors/icons/deye.png b/.marketplace/vendors/icons/deye.png new file mode 100644 index 00000000..947f64f6 Binary files /dev/null and b/.marketplace/vendors/icons/deye.png differ diff --git a/.marketplace/vendors/icons/solarman.png b/.marketplace/vendors/icons/solarman.png new file mode 100644 index 00000000..5c50633e Binary files /dev/null and b/.marketplace/vendors/icons/solarman.png differ diff --git a/.marketplace/vendors/vendors.yml b/.marketplace/vendors/vendors.yml index e35024b4..4165f772 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: deye + display_name: Deye + icon_url: https://raw.githubusercontent.com/Enapter/marketplace/main/.marketplace/vendors/icons/deye.png + website: https://www.deyeinverter.com/ diff --git a/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/README.md b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/README.md new file mode 100644 index 00000000..f7b3a37c --- /dev/null +++ b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/README.md @@ -0,0 +1,20 @@ +# Deye SUN-10k-SG04LP3-EU (HTTP) + +This [Enapter Device Blueprint](https://go.enapter.com/marketplace-readme) integrates **Deye Inverter SUN-10k-SG04LP3-EU** - three-phase hybrid inverter connected to Solarman system - 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 following communication parameters: + - Your Solarman account App ID; + - Your Solarman account App Secret; + - Your Solarman account username; + - Your Solarman account password; + - Inverter serial number; + +## References + +- [Deye Inverter product page](https://www.deyeinverter.com/product/hybrid-inverter-1/sun5-6-8-10-12ksg04lp3.html) diff --git a/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/http_connection.lua b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/http_connection.lua new file mode 100644 index 00000000..a326f9af --- /dev/null +++ b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/http_connection.lua @@ -0,0 +1,264 @@ +local SolarmanHTTP = {} + +local json = require('json') +local net_url = require('net.url') +local sha256 = require('hashings.sha256') + +function SolarmanHTTP.new(app_id, app_secret, email, password, device_sn, org_name, optional) + assert(type(app_id) == 'string', 'app_id (arg #1) must be string, given: ' .. inspect(app_id)) + assert( + type(app_secret) == 'string', + 'app_secret (arg #2) must be string, given: ' .. inspect(app_secret) + ) + assert(type(email) == 'string', 'email (arg #3) must be string, given: ' .. inspect(email)) + assert( + type(password) == 'string', + 'password (arg #4) must be string, given: ' .. inspect(password) + ) + assert( + type(device_sn) == 'string', + 'device_sn (arg #5) must be string, given: ' .. inspect(device_sn) + ) + assert( + type(org_name) == 'string', + 'org_name (arg #6) must be string, given: ' .. inspect(org_name) + ) + + local self = setmetatable({}, { __index = SolarmanHTTP }) + + if email ~= nil then + self.email = email + elseif optional.username ~= nil then + self.username = optional.username + elseif optional.mobile ~= nil then + if optional.coutry_code ~= nil then + self.country_code = optional.coutry_code + else + return 'country code must be provided along with mobile number' + end + else + return nil, 'one of: email, username or mobile with country code must be provided' + end + + self.password = password + self.app_secret = app_secret + self.app_id = app_id + self.device_sn = device_sn + self.org_name = org_name + + self.url = 'https://globalapi.solarmanpv.com/' + self.client = http.client({ timeout = 5 }) + + return self +end + +function SolarmanHTTP: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 SolarmanHTTP: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 SolarmanHTTP:set_token() + local body = {} + + if self.email ~= nil then + body.email = self.email + elseif self.username ~= nil then + body.username = self.username + elseif self.mobile ~= nil then + if self.country_code ~= nil then + body.mobile = self.mobile + body.countryCode = self.country_code + else + return 'country code must be provided along with mobile number' + end + else + return 'one of: email, username or mobile with country code must be provided' + end + + body.appSecret = self.app_secret + body.orgId = self.org_id + body.password = string.lower(sha256:new(self.password):hexdigest()) + + local url = net_url.parse(self.url) / 'account' / 'v1.0' / 'token' + url:setQuery({ appId = self.app_id }) + + local headers = {} + headers['Content-Type'] = 'application/json' + + local response, err = self:process_unauthorized('POST', headers, tostring(url), json.encode(body)) + if err then + return 'set_token failed: ' .. tostring(err) + end + + if response ~= nil then + if response['success'] == false then + return response['msg'] + end + if self.access_token ~= response['access_token'] and self.access_token ~= nil then + enapter.log('Bussiness access tokens are obtained', 'info') + else + enapter.log('Tokens are obtained', 'info') + end + 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 SolarmanHTTP:bussiness_relation() + local url = net_url.parse(self.url) / 'account' / 'v1.0' / 'info' + + local body = '' + + local response, err = self:process_authorized('POST', tostring(url), body) + + if err then + return nil, 'bussiness_relation failed: ' .. tostring(err) + end + + if response ~= nil then + if response['success'] then + if response['orgInfoList'] ~= nil then + for _, org in pairs(response['orgInfoList']) do + if org['companyName'] == self.org_name then + self.org_id = org['companyId'] + break + end + end + else + return 'empty orgInfoList' + end + else + return response['msg'] + end + else + return 'no response' + end +end + +function SolarmanHTTP:get_realtime_data() + local url = net_url.parse(self.url) / 'device' / 'v1.0' / 'currentData' + + local body = json.encode({ + deviceSn = self.device_sn, + }) + + local response, err = self:process_authorized('POST', tostring(url), body) + + if err then + return nil, 'get_realtime_data failed: ' .. tostring(err) + end + + local function map_by_key(t) + local tt = {} + for _, el in pairs(t) do + if tonumber(el.value) then + tt[el.key] = tonumber(el.value) + else + tt[el.key] = el.value + end + end + return tt + end + + local telemetry = {} + if response ~= nil then + if response['success'] then + if response['dataList'] ~= nil then + local metrics = map_by_key(response['dataList']) + telemetry['DV1'] = metrics['DV1'] + telemetry['DC1'] = metrics['DC1'] + telemetry['DP1'] = metrics['DP1'] + telemetry['DV2'] = metrics['DV2'] + telemetry['DC2'] = metrics['DC2'] + telemetry['DP2'] = metrics['DP2'] + telemetry['S_P_T'] = metrics['S_P_T'] + telemetry['G_V_L1'] = metrics['G_V_L1'] + telemetry['G_C_L1'] = metrics['G_C_L1'] + telemetry['G_P_L1'] = metrics['G_P_L1'] + telemetry['G_V_L2'] = metrics['G_V_L2'] + telemetry['G_C_L2'] = metrics['G_C_L2'] + telemetry['G_P_L2'] = metrics['G_P_L2'] + telemetry['G_V_L3'] = metrics['G_V_L3'] + telemetry['G_C_L3'] = metrics['G_C_L3'] + telemetry['G_P_L3'] = metrics['G_P_L3'] + telemetry['PG_F1'] = metrics['PG_F1'] + telemetry['PG_Pt1'] = metrics['PG_Pt1'] + telemetry['CT1_P_E'] = metrics['CT1_P_E'] + telemetry['CT2_P_E'] = metrics['CT2_P_E'] + telemetry['CT3_P_E'] = metrics['CT3_P_E'] + telemetry['CT_T_E'] = metrics['CT_T_E'] + telemetry['L_F'] = metrics['L_F'] + telemetry['LPP_A'] = metrics['LPP_A'] + telemetry['LPP_B'] = metrics['LPP_B'] + telemetry['LPP_C'] = metrics['LPP_C'] + telemetry['LPP_C'] = metrics['LPP_C'] + telemetry['E_Puse_t1'] = metrics['E_Puse_t1'] + telemetry['B_V1'] = metrics['B_V1'] + telemetry['B_C1'] = metrics['B_C1'] + telemetry['B_P1'] = metrics['B_P1'] + telemetry['B_left_cap1'] = metrics['B_left_cap1'] + telemetry['ST_PG1'] = metrics['ST_PG1'] + telemetry['B_ST1'] = metrics['B_ST1'] + telemetry['Etdy_use1'] = metrics['Etdy_use1'] + telemetry['Etdy_dcg1'] = metrics['Etdy_dcg1'] + telemetry['Etdy_ge1'] = metrics['Etdy_ge1'] + telemetry['GRID_RELAY_ST1'] = metrics['GRID_RELAY_ST1'] + telemetry['status'] = 'ok' + telemetry['alerts'] = {} + else + telemetry['status'] = 'warning' + telemetry['alerts'] = { 'no_data' } + end + else + telemetry['status'] = 'warning' + telemetry['alerts'] = { 'invalid_request' } + return telemetry, response['msg'] + end + else + telemetry['status'] = 'warning' + telemetry['alerts'] = { 'no_response' } + end + + return telemetry, nil +end + +return SolarmanHTTP diff --git a/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/main.lua b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/main.lua new file mode 100644 index 00000000..55424ae9 --- /dev/null +++ b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/fw/main.lua @@ -0,0 +1,149 @@ +local config = require('enapter.ucm.config') +local SolarmanHTTP = require('http_connection') + +-- Configuration variables must be also defined +-- in `write_configuration` command arguments in manifest.yml +APP_ID = 'app_id' +APP_SECRET = 'app_secret' +EMAIL = 'email' +USERNAME = 'username' +PASSWORD = 'password' +MOBILE = 'mobile' +COUNTRY_CODE = 'country_code' +DEVICE_SN = 'device_sn' +ORG_NAME = 'org_name' + +NOT_CONFIGURED = true + +-- Initiate device firmware. Called at the end of the file. +function main() + scheduler.add(30000, send_properties) + scheduler.add(1000, send_telemetry) + + config.init({ + [EMAIL] = { type = 'string', required = true }, + [PASSWORD] = { type = 'string', required = true }, + [APP_ID] = { type = 'string', required = true }, + [APP_SECRET] = { type = 'string', required = true }, + [DEVICE_SN] = { type = 'string', required = true }, + [ORG_NAME] = { type = 'string', required = true }, + [USERNAME] = { type = 'string', required = false }, + [MOBILE] = { type = 'string', required = false }, + [COUNTRY_CODE] = { type = 'number', required = false }, + }, { + after_write = function(args) + if args.password == nil then + return 'password is required' + else + local solarman, err = connect_solarman() + if not err then + solarman:set_token() + end + end + end, + }) +end + +function send_properties() + local properties = {} + local sn, err = config.read(DEVICE_SN) + if err == nil then + properties['serial_number'] = sn + end + + properties['vendor'] = 'Deye' + enapter.send_properties(properties) +end + +function send_telemetry() + local values, err = config.read_all() + if next(values) then + NOT_CONFIGURED = false + end + if err ~= nil then + enapter.log(err, 'error', true) + enapter.send_telemetry({ + status = 'warning', + alerts = { 'invalid_config' }, + }) + return + end + + if NOT_CONFIGURED then + enapter.send_telemetry({ + status = 'warning', + alerts = { 'not_configured' }, + }) + return + else + local solarman, err = connect_solarman() + if err then + enapter.log("Can't connect to Solarman: " .. err, true) + enapter.send_telemetry({ + status = 'warning', + alerts = { 'connection_error' }, + }) + return + else + local telemetry, err = solarman:get_realtime_data() + if err ~= nil then + enapter.log(err, 'error', true) + end + enapter.send_telemetry(telemetry) + end + end +end + +-- holds global Solarman connection +local solarman + +function connect_solarman() + if solarman and solarman.expires ~= nil then + if solarman.expires <= os.time() then + local err = solarman:set_token() + if err ~= nil then + return nil, err + end + end + + return solarman, 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 appSecret, appId = values[APP_SECRET], values[APP_ID] + local email, password = values[EMAIL], values[PASSWORD] + local deviceSn, org_name = values[DEVICE_SN], values[ORG_NAME] + + if not appSecret or not appId or not password or not deviceSn or not org_name then + return nil, 'not_configured' + else + local optional = { + username = values[USERNAME], + mobile = values[MOBILE], + country_code = values[COUNTRY_CODE], + } + solarman = SolarmanHTTP.new(appId, appSecret, email, password, deviceSn, org_name, optional) + NOT_CONFIGURED = false + + local err = solarman:set_token() + if err ~= nil then + return nil, err + end + local err = solarman:bussiness_relation() + if err ~= nil then + return nil, err + end + local err = solarman:set_token() + if err ~= nil then + return nil, err + end + return solarman, nil + end + end +end + +main() diff --git a/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/manifest.yml b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/manifest.yml new file mode 100644 index 00000000..061b1df2 --- /dev/null +++ b/solar_inverters/deye_sun-10k-sg04lp3-eu_solarman/manifest.yml @@ -0,0 +1,328 @@ +blueprint_spec: device/1.0 +display_name: Deye Inverter (HTTP API) +description: Three phase hybrid inverter with low battery voltage 48V. +icon: enapter-home +vendor: deye +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: fw + dependencies: + - enapter-ucm + - net-url ~> 1.1-1 + - lua-hashings ~> scm-1 + allow_dev_dependencies: true + +properties: + vendor: + type: string + display_name: Vendor + serial_number: + type: string + display_name: Serial number + +telemetry: + status: + type: string + display_name: State + enum: + - ok + - warning + ST_PG1: + type: string + display_name: Grid Status + enum: + - Static + - Purchasing energy + B_ST1: + type: string + display_name: Battery Status + enum: + - Discharging + - Charging + GRID_RELAY_ST1: + type: string + display_name: Grid Relay Status + enum: + - Pull-in + - Pull-out + Etdy_use1: + type: float + display_name: Daily Consumption + unit: kWh + Etdy_ge1: + type: float + display_name: Daily Production (Active) + unit: kWh + Etdy_dcg1: + type: float + display_name: Daily Discharging Energy + unit: kWh + DV1: + type: float + unit: V + display_name: DC Voltage PV1 + DV2: + type: float + unit: V + display_name: DC Voltage PV2 + DC1: + type: float + unit: A + display_name: DC Current PV1 + DC2: + type: float + unit: A + display_name: DC Current PV2 + DP1: + type: float + unit: W + display_name: DC Power PV1 + DP2: + type: float + unit: W + display_name: DC Power PV2 + S_P_T: + type: float + unit: W + display_name: Total Solar Power + G_V_L1: + type: float + unit: V + display_name: Grid Voltage L1 + G_V_L2: + type: float + unit: V + display_name: Grid Voltage L2 + G_V_L3: + type: float + unit: V + display_name: Grid Voltage L3 + G_C_L1: + type: float + unit: A + display_name: Grid Current L1 + G_C_L2: + type: float + unit: A + display_name: Grid Current L2 + G_C_L3: + type: float + unit: A + display_name: Grid Current L3 + G_P_L1: + type: float + unit: W + display_name: Grid Power L1 + G_P_L2: + type: float + unit: W + display_name: Grid Power L2 + G_P_L3: + type: float + unit: W + display_name: Grid Power L3 + PG_F1: + type: float + unit: Hz + display_name: Grid Frequency + PG_Pt1: + type: float + unit: W + display_name: Total Grid Power + CT1_P_E: + type: float + unit: W + display_name: External CT1 Power + CT2_P_E: + type: float + unit: W + display_name: External CT2 Power + CT3_P_E: + type: float + unit: W + display_name: External CT3 Power + CT_T_E: + type: float + unit: W + display_name: Total External CT Power + L_F: + type: float + unit: Hz + display_name: Load Frequency + LPP_A: + type: float + unit: W + display_name: Load phase power A + LPP_B: + type: float + unit: W + display_name: Load phase power B + LPP_C: + type: float + unit: W + display_name: Load phase power C + E_Puse_t1: + type: float + unit: W + display_name: Total Consumption Power + B_V1: + type: float + unit: V + display_name: Battery Voltage + B_C1: + type: float + unit: A + display_name: Battery Current + B_P1: + type: float + unit: W + display_name: Battery Power + B_left_cap1: + type: float + unit: '%' + display_name: SoC + +alerts: + no_data: + severity: warning + display_name: No data from device + description: > + Can't read data from device. + Please check Solarman connection parameters. + connection_error: + severity: warning + display_name: Connection Error + description: > + Please use "Set Up Connection" command to set up your + Solarman account configuration. + not_configured: + severity: info + display_name: Solarman account parameters are absent. + description: > + Please use "Set Up Connection" command to set up your + Solarman account parameters. + invalid_request: + severity: warning + display_name: Invalid request + description: Invalid API request. + no_response: + severity: warning + display_name: No response from device + description: Please check `get_realtime_data` request. + +command_groups: + connection: + display_name: Connection + +commands: + # TODO: mark commands containing secrets + write_configuration: + display_name: Set Up Connection + description: Set your Solarman account parameters to access OpenAPI. + group: connection + populate_values_command: read_configuration + ui: + icon: file-document-edit-outline + arguments: + app_id: + display_name: Solarman App ID + type: string + required: true + app_secret: + display_name: Solarman App Secret + type: string + required: true + device_sn: + display_name: Solarman Device Serial Number + type: string + required: true + email: + display_name: Solarman account email + type: string + required: true + password: + display_name: Solarman account password + type: string + required: true + org_name: + display_name: Solarman organization name + type: string + required: true + username: + display_name: Solarman account username + description: Set this instead of email + type: string + required: false + mobile: + display_name: Solarman account mobile number + description: Set this instead of email or username + type: string + required: false + country_code: + display_name: Solarman account country code + description: Set this along with mobile number + type: integer + required: false + read_configuration: + display_name: Read Connection Parameters + group: connection + ui: + icon: file-check-outline + +.cloud: + category: batteries + mobile_main_chart: E_Puse_t1 + mobile_charts: + - DV1 + - DV2 + - DC1 + - DC2 + - DP1 + - DP2 + - G_V_L1 + - G_V_L2 + - G_V_L3 + - G_C_L1 + - G_C_L2 + - G_C_L3 + - G_P_L1 + - G_P_L2 + - G_P_L3 + - PG_F1 + - PG_Pt1 + - CT1_P_E + - CT2_P_E + - CT3_P_E + - L_F + - LPP_A + - LPP_B + - LPP_C + - B_V1 + - B_C1 + - B_P1 + - E_Puse_t1 + - B_left_cap1 + - Etdy_use1 + - Etdy_dcg1 + - Etdy_ge1 + mobile_telemetry: + - S_P_T + - B_V1 + - B_ST1 + - E_Puse_t1 + - PG_Pt1 + - B_P1 + - B_left_cap1 + - Etdy_dcg1 + - Etdy_ge1 + - Etdy_use1