diff --git a/.marketplace/devices/devices.yml b/.marketplace/devices/devices.yml index 376b6c59..8bbc60d6 100644 --- a/.marketplace/devices/devices.yml +++ b/.marketplace/devices/devices.yml @@ -577,6 +577,16 @@ - blueprint: solar_charge_controllers/srne_ml-2440 verification_level: verified +- id: voltronic-power--scc-mppt + display_name: Voltronic Power SCC MPPT + description: MPPT Solar Charge Controller + icon: enapter-inverter-solar + vendor: voltronic + category: solar_charge_controllers + blueprint_options: + - blueprint: solar_charge_controllers/voltronic_scc_mppt + verification_level: ready_for_testing + - id: mpp-solar display_name: MPP Solar Inverter description: MPP Solar Inverters. diff --git a/.marketplace/vendors/icons/voltronic.png b/.marketplace/vendors/icons/voltronic.png new file mode 100644 index 00000000..dff8d86c Binary files /dev/null and b/.marketplace/vendors/icons/voltronic.png differ diff --git a/.marketplace/vendors/vendors.yml b/.marketplace/vendors/vendors.yml index 0d4d619a..22f09a4d 100644 --- a/.marketplace/vendors/vendors.yml +++ b/.marketplace/vendors/vendors.yml @@ -232,3 +232,8 @@ display_name: Aquara icon_url: https://raw.githubusercontent.com/Enapter/marketplace/main/.marketplace/vendors/icons/aqara.png website: https://www.aqara.com/ + +- id: voltronic + display_name: Voltronic Power + icon_url: https://raw.githubusercontent.com/Enapter/marketplace/main/.marketplace/vendors/icons/voltronic.png + website: https://www.voltronicpower.com diff --git a/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-enp.png b/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-enp.png new file mode 100644 index 00000000..99499989 Binary files /dev/null and b/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-enp.png differ diff --git a/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-kit.png b/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-kit.png new file mode 100644 index 00000000..bdcc7461 Binary files /dev/null and b/solar_charge_controllers/voltronic_scc_mppt/.assets/wiring-scheme-kit.png differ diff --git a/solar_charge_controllers/voltronic_scc_mppt/README.md b/solar_charge_controllers/voltronic_scc_mppt/README.md new file mode 100644 index 00000000..18dc7a0c --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/README.md @@ -0,0 +1,47 @@ +# Voltronic Power SCC-MPPT Charge Controller + +This [Enapter Device Blueprint](https://go.enapter.com/marketplace-readme) integrates **Voltronic Power SCC-MPPT Charge Controller**. The Blueprint supports protocol over [RS-232 communication interface](https://go.enapter.com/developers-enapter-rs232). + +This Blueprint supports visualization of the following metrics in Enapter Cloud and Mobile App: + +- PV Input Voltage +- Battery Voltage +- Charging Current +- Charging Current 1 +- Charging Current 2 +- Charging Power +- Warnings and Errors + +For detailed connection and operation instructions check [Voltronic Power Website](https://voltronicpower.com/en-US/Product/Detail/SCC-MPPT) site. + +## Connect to Enapter + +- Sign up to the Enapter Cloud using the [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 the [Enapter ENP-RS232](https://go.enapter.com/handbook-enp-rs232) or [Enapter ENP-KIT-232-485-CAN](https://go.enapter.com/enp-kit-232-485-can) communication module for physical connection. +- [Add communication module to your site](https://go.enapter.com/handbook-mobile-app) using the mobile app. +- [Upload](https://go.enapter.com/developers-upload-blueprint) this blueprint to the communication module. + +## Physical Connection + +For physical RS-232 connection with the inverter you will need: + +- RS-232 communication module: + - _Either_ Enapter [**ENP-RS232** communication module](https://handbook.enapter.com/modules/ENP-RS232/ENP-RS232.html), + - _Or_ [**ENP-KIT-232-485-CAN** module](https://developers.enapter.com/docs/tutorial/ucm-kit/enp-kit-232-485-can) (check out UCM Kit [introduction](https://developers.enapter.com/docs/tutorial/ucm-kit/introduction) and simple [JLPCB ordering guide](https://developers.enapter.com/docs/tutorial/ucm-kit/ordering)) + ESP-32 development board. +- Communication cable with RJ45 connector on one side and plain wires on another side. You can use _RJ45 breakout connector_ to assemble such cable. + +### ENP-RS232 Connection Diagram + +

+ +### ENP-KIT-232-485-CAN Connection Diagram + +

+ +## Troubleshooting + +This is initial version of the Blueprint. In case you find out any issues, please contact us in [Discord](https://go.enapter.com/discord). + +## References + +- [Voltronic Power Website](https://voltronicpower.com/en-US/Product/Detail/SCC-MPPT) diff --git a/solar_charge_controllers/voltronic_scc_mppt/manifest.yml b/solar_charge_controllers/voltronic_scc_mppt/manifest.yml new file mode 100644 index 00000000..6a35a0c2 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/manifest.yml @@ -0,0 +1,132 @@ +blueprint_spec: device/1.0 + +display_name: Voltronic SCC-MPPT Charge Controller +description: MPPT Solar Charge Controller from Voltronic. +icon: enapter-solar-inverter +vendor: voltronic +license: MIT +author: enapter +support: + url: https://go.enapter.com/enapter-blueprint-support + email: support@enapter.com + +communication_module: + product: ENP-RS232 + lua: + dir: src + amalg_mode: nodebug + +properties: + serial_num: + display_name: Serial Number + type: string + fw_ver: + display_name: Firmware version + type: string + protocol_ver: + display_name: Protocol version + type: string + +telemetry: + status: + display_name: Status + type: string + enum: + - Error + - Charging + - Not Charging + pv_input_voltage: + display_name: PV Input Voltage + type: float + unit: watt + battery_voltage: + display_name: Battery Voltage + type: float + unit: watt + charging_current: + display_name: Charging Current + type: float + unit: watt + charging_current_1: + display_name: Charging Current 1 + type: float + unit: watt + charging_current_2: + display_name: Charging Current 2 + type: float + unit: watt + charging_power: + display_name: Charging Power + type: integer + unit: watt + +alerts: + no_data: + severity: error + display_name: No data from device + description: > + Can not get data from device, please check connection between Enapter + communication module and the inverter. + over_charge_current: + severity: error + display_name: Over charge current + description: Over charge current + over_temperature: + severity: error + display_name: Over temperature + description: Over temperature + battery_voltage_under: + severity: error + display_name: Battery voltage under + description: Battery voltage under + battery_voltage_high: + severity: error + display_name: Battery voltage high + description: Battery voltage high + pv_high_loss: + severity: error + display_name: PV high loss + description: PV high loss + battery_temperature_too_low: + severity: error + display_name: Battery temperature too low + description: Battery temperature too low + battery_temperature_too_high: + severity: error + display_name: Battery temperature too high + description: Battery temperature too high + pv_low_loss: + severity: warning + display_name: PV low loss + description: PV low loss + pv_high_derating: + severity: warning + display_name: PV high derating + description: PV high derating + temperature_high_derating: + severity: warning + display_name: Temperature high derating + description: Temperature high derating + battery_temperature_low_alarm: + severity: warning + display_name: Battery temperature low alarm + description: Battery temperature low alarm + battery_low_warning: + severity: warning + display_name: Battery low warning + description: Battery low warning + +.cloud: + category: renewable_energy_sources + mobile_main_chart: charging_power + mobile_telemetry: + - pv_input_voltage + - battery_voltage + - charging_power + mobile_charts: + - pv_input_voltage + - battery_voltage + - charging_power + - charging_current + - charging_current_1 + - charging_current_2 diff --git a/solar_charge_controllers/voltronic_scc_mppt/src/commands.lua b/solar_charge_controllers/voltronic_scc_mppt/src/commands.lua new file mode 100644 index 00000000..5db78333 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/src/commands.lua @@ -0,0 +1,76 @@ +-- In order to reduce telemetry size some metrics are commented out. +-- If needed uncomment them and add to manifest.yml. + +local commands = { + device_protocol = { + command = 'QPI', + }, + serial_number = { + command = 'QID', + }, + firmware_version = { + command = 'QVFW', + }, + device_rating_info = { + command = 'QPIRI', + data = { + max_output_power = 1, + nominal_battery_voltage = 2, + nominal_charging_current = 3, + absorption_voltage_per_unit = 4, + float_voltage_per_unit = 5, + battery_type = 6, + remote_battery_voltage_detect = 7, + battery_temperature_compensation = 8, + remote_temperature_detect = 9, + battery_rated_voltage_set = 10, + the_piece_of_battery_in_serial = 11, + battery_low_warning_voltage = 12, + battery_low_shutdown_detect = 13, + }, + }, + general_parameters = { + command = 'QPIGS', + data = { + pv_input_voltage = 1, + battery_voltage = 2, + charging_current = 3, + charging_current_1 = 4, + charging_current_2 = 5, + charging_power = 6, + -- unit_temperature = 7, + -- remote_battery_voltage = 8, + -- remote_battery_temperature = 9, + -- reserved = 10, + status = 11, + }, + }, + device_warning_status = { + command = 'QPIWS', + general = { + over_charge_current = 1, + over_temperature = 2, + battery_voltage_under = 3, + battery_voltage_high = 4, + pv_high_loss = 5, + battery_temperature_too_low = 6, + battery_temperature_too_high = 7, + -- reserved = 8, + -- reserved = 9, + -- reserved = 13, + -- reserved = 14, + -- reserved = 15, + -- reserved = 16, + -- reserved = 17, + -- reserved = 18, + -- reserved = 19, + pv_low_loss = 20, + pv_high_derating = 21, + temperature_high_derating = 22, + battery_temperature_low_alarm = 23, + battery_low_warning = 30, + }, + }, +} + +return commands diff --git a/solar_charge_controllers/voltronic_scc_mppt/src/main.lua b/solar_charge_controllers/voltronic_scc_mppt/src/main.lua new file mode 100644 index 00000000..d42e7f36 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/src/main.lua @@ -0,0 +1,76 @@ +local voltronic = require('voltronic') +local parser = require('parser') + +function main() + local err = rs232.init(voltronic.baudrate, voltronic.data_bits, voltronic.parity, voltronic.stop_bits) + if err ~= 0 then + enapter.log('RS232 init failed: ' .. rs232.err_to_str(err), 'error') + enapter.send_telemetry({ status = 'Error', alerts = { 'init_error' } }) + return + end + + scheduler.add(30000, send_properties) + scheduler.add(1000, send_telemetry) +end + +function send_properties() + local properties = {} + local result + local data + + result, data = parser:get_protocol_version() + if result then + properties['serial_num'] = data + end + + result, data = parser:get_serial_number() + if result then + properties['protocol_ver'] = data + end + + result, data = parser:get_firmware_version() + if result then + properties['fw_ver'] = data + end + + enapter.send_properties(properties) +end + +function send_telemetry() + local telemetry = {} + local alerts = {} + + local data, err = parser:get_device_general_status_params() + if data then + merge_tables(telemetry, data) + else + enapter.log('Failed to get general status params: ' .. err, 'error') + end + + local data = parser:get_device_alerts() + if data then + alerts = data + end + + if telemetry['status'] then + if string.sub(telemetry['status'], 2, 2) == '1' then + telemetry['status'] = 'Charging' + else + telemetry['status'] = 'Not Charging' + end + else + telemetry['status'] = 'Error' + end + + telemetry['alerts'] = alerts + enapter.send_telemetry(telemetry) + collectgarbage() +end + +function merge_tables(t1, t2) + for key, value in pairs(t2) do + t1[key] = value + end +end + +main() diff --git a/solar_charge_controllers/voltronic_scc_mppt/src/moving_average.lua b/solar_charge_controllers/voltronic_scc_mppt/src/moving_average.lua new file mode 100644 index 00000000..ede20bd6 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/src/moving_average.lua @@ -0,0 +1,23 @@ +local MA = {} +MA.period = 10 +MA.table = {} + +function MA:add_to_table(voltage) + if #MA.table == MA.period then + table.remove(MA.table, 1) + end + MA.table[#MA.table + 1] = voltage +end + +function MA:get_value() + local function sum(a, ...) + if a then + return a + sum(...) + else + return 0 + end + end + return sum(table.unpack(MA.table)) / #MA.table +end + +return MA diff --git a/solar_charge_controllers/voltronic_scc_mppt/src/parser.lua b/solar_charge_controllers/voltronic_scc_mppt/src/parser.lua new file mode 100644 index 00000000..06c0e787 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/src/parser.lua @@ -0,0 +1,117 @@ +local voltronic = require('voltronic') +local moving_average = require('moving_average') +local commands = require('commands') + +local device_protocol = commands.device_protocol +local serial_number = commands.serial_number +local firmware_version = commands.firmware_version +local device_rating_info = commands.device_rating_info +local general_parameters = commands.general_parameters +local device_warning_status = commands.device_warning_status + +local parser = {} + +function parser:get_protocol_version() + local result, data = voltronic:run_with_cache(device_protocol.command) + if result then + return data, nil + else + return nil, 'no_data' + end +end + +function parser:get_serial_number() + local result, data = voltronic:run_with_cache(serial_number.command) + if result then + return data, nil + else + return nil, 'no_data' + end +end + +function parser:get_firmware_version() + local result, data = voltronic:run_with_cache(firmware_version.command) + if result then + return data, nil + else + return nil, 'no_data' + end +end + +function parser:get_device_general_status_params() + local data = voltronic:run_command(general_parameters.command) + if data then + local telemetry = {} + data = split(data) + + for name, index in pairs(general_parameters.data) do + telemetry[name] = tonumber(data[index]) + end + + telemetry['battery_volt'] = parser:get_battery_voltage(telemetry['battery_volt']) + -- telemetry['pv_input_power'] = tonumber(data[general_parameters.data.pv_input_amp]) + -- * tonumber(data[general_parameters.data.pv_input_volt]) + return telemetry, nil + else + return nil, 'no_data' + end +end + +function parser:get_device_rating_info() + local data = voltronic:run_command(device_rating_info.command) + local telemetry = {} + if data then + for name, index in pairs(device_rating_info.data) do + telemetry[name] = tonumber(split(data)[index]) + end + + telemetry = parser:get_priorities(telemetry) + + return telemetry, nil + else + return nil, 'no_data' + end +end + +function parser:get_device_alerts() + local data = voltronic:run_command(device_warning_status.command) + if data then + local alerts = {} + for alert, pos in pairs(device_warning_status.general) do + if string.sub(data, pos, pos) == '1' then + table.insert(alerts, alert) + end + end + + return alerts + else + enapter.log('Warning status failure', 'error') + return nil + end +end + +function parser:get_battery_voltage(voltage) + if voltage then + moving_average:add_to_table(voltage) + return moving_average:get_value() + else + moving_average.table = {} + enapter.log('No battery voltage', 'error') + return nil + end +end + +function split(str, sep) + if sep == nil then + sep = '%s' + end + + local t = {} + for part in string.gmatch(str, '([^' .. sep .. ']+)') do + table.insert(t, part) + end + + return t +end + +return parser diff --git a/solar_charge_controllers/voltronic_scc_mppt/src/voltronic.lua b/solar_charge_controllers/voltronic_scc_mppt/src/voltronic.lua new file mode 100644 index 00000000..2fb12e00 --- /dev/null +++ b/solar_charge_controllers/voltronic_scc_mppt/src/voltronic.lua @@ -0,0 +1,200 @@ +local voltronic = {} + +voltronic.baudrate = 2400 +voltronic.data_bits = 8 +voltronic.parity = 'N' +voltronic.stop_bits = 1 + +function voltronic:run_with_cache(name) + if voltronic:is_in_cache(name) then + local data = voltronic:run_command(name) + if data and data ~= 'NAK' then + voltronic:add_to_cache(name, data, os.time()) + return true, data + end + else + local result, data = voltronic:read_cache(name) + if result then + return true, data + end + end + return false +end + +function voltronic:set_value(name) + local res = voltronic:run_command(name) + if res then + if res == 'ACK' then + return true, nil + elseif res == 'NAK' then + return false, 'Response: NAK' + else + return false, 'Response neither ACK or NAK' + end + else + return false, 'No response from device' + end +end + +function voltronic:run_command(name) + if name ~= nil then + local crc = voltronic:crc16(name) + name = name .. string.char((crc & 0xFF00) >> 8) + name = name .. string.char(crc & 0x00FF) + name = name .. string.char(0x0D) + rs232.send(name) + + local raw_data, result = rs232.receive(2000) + if raw_data and string.byte(raw_data, #raw_data) == 0x0d then + local data = string.sub(raw_data, 1, -4) + local r_crc = voltronic:crc16(data) + if (r_crc & 0xFF00) >> 8 == string.byte(raw_data, -3) and r_crc & 0x00FF == string.byte(raw_data, -2) then + local com_response = string.sub(data, 2) + voltronic:add_to_cache(name, com_response, os.time()) + return com_response + end + else + enapter.log(name .. ' command failed: ' .. rs232.err_to_str(result), 'error') + end + end + return nil +end + +COMMAND_CACHE = {} + +function voltronic:add_to_cache(command_name, data, updated) + COMMAND_CACHE[command_name] = { data = data, updated = updated } +end + +function voltronic:read_cache(command_name) + if COMMAND_CACHE[command_name] then + return true, COMMAND_CACHE[command_name].data + end + return false +end + +function voltronic:is_in_cache(command_name) + local com_data = COMMAND_CACHE[command_name] + if com_data == nil then + return true + end + if com_data.updated + 60 < os.time() then + return true + end + return false +end + +function voltronic:crc16(pck) + local index + local crc = 0 + local da + local t_da + local crc_ta = { + 0x0000, + 0x1021, + 0x2042, + 0x3063, + 0x4084, + 0x50a5, + 0x60c6, + 0x70e7, + 0x8108, + 0x9129, + 0xa14a, + 0xb16b, + 0xc18c, + 0xd1ad, + 0xe1ce, + 0xf1ef, + } + + for i = 1, #pck do + t_da = crc >> 8 + da = t_da >> 4 + crc = (crc << 4) & 0xFFFF + index = (da ~ (string.byte(pck, i) >> 4)) + 1 + crc = crc ~ crc_ta[index] + t_da = crc >> 8 + da = t_da >> 4 + crc = (crc << 4) & 0xFFFF + index = (da ~ (string.byte(pck, i) & 0x0F) & 0xFFFF) + 1 + crc = crc ~ crc_ta[index] + end + + local b_crc_low = crc & 0xFF + local b_crc_high = (crc >> 8) & 0xFF + + if b_crc_low == 0x28 or b_crc_low == 0x0D or b_crc_low == 0x0A then + b_crc_low = b_crc_low + 1 + end + if b_crc_high == 0x28 or b_crc_high == 0x0D or b_crc_high == 0x0A then + b_crc_high = b_crc_high + 1 + end + + crc = (b_crc_high & 0xFFFF) << 8 + crc = crc + b_crc_low + + return crc +end + +MA_VOLTAGE_PERIOD = 10 +MA_VOLTAGE_TABLE = {} + +function voltronic:get_battery_voltage() + local voltage, err = voltronic:run_qpigs_command(9) + if err then + MA_VOLTAGE_TABLE = {} + return nil, err + end + + voltronic:add_voltage_to_table(tonumber(voltage)) + return voltronic:get_ma_voltage() +end + +function voltronic:add_voltage_to_table(voltage) + if #MA_VOLTAGE_TABLE == MA_VOLTAGE_PERIOD then + table.remove(MA_VOLTAGE_TABLE, 1) + end + MA_VOLTAGE_TABLE[#MA_VOLTAGE_TABLE + 1] = voltage +end + +function voltronic:get_ma_voltage() + local function sum(a, ...) + if a then + return a + sum(...) + else + return 0 + end + end + return sum(table.unpack(MA_VOLTAGE_TABLE)) / #MA_VOLTAGE_TABLE +end + +function voltronic:run_qpigs_command(index) + local qpigs_data_len = 10 + if not (0 < index and index < qpigs_data_len + 1) then + return nil, 'QPIGS wrong index ' .. index + end + + local data = voltronic:run_command('QPIGS') + if not data then + return nil, 'QPIGS command was not successful' + end + + local qpigs_list = split(data, ' ') + return qpigs_list[index] +end + +function split(str, sep) + if sep == nil then + sep = '%s' + end + + local t = {} + for part in string.gmatch(str, '([^' .. sep .. ']+)') do + table.insert(t, part) + end + + return t +end + +return voltronic