diff --git a/Dockerfile b/Dockerfile index c2e2a79..4d58bf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.12.2-alpine +FROM python:3-alpine3.20 WORKDIR /opt/sofar2mqtt -COPY requirements.txt sofar2mqtt-v2.py *.json ./ +COPY requirements.txt ./ ARG TARGETOS ARG TARGETARCH @@ -25,5 +25,7 @@ ENV CONFIG_FILE=sofar-hyd-ep.json \ WRITE_RETRY_ATTEMPTS=5 \ WRITE_RETRY_DELAY=5 +COPY sofar2mqtt-v2.py *.json ./ + CMD [ "python", "sofar2mqtt-v2.py" ] diff --git a/sofar-hyd-ep.json b/SOFAR-HYD-3PH-AND-G3.json similarity index 55% rename from sofar-hyd-ep.json rename to SOFAR-HYD-3PH-AND-G3.json index 2554438..e202fe5 100644 --- a/sofar-hyd-ep.json +++ b/SOFAR-HYD-3PH-AND-G3.json @@ -1,18 +1,51 @@ { + "write_register_blocks": [ + { + "name": "battery_config", + "start_register": "0x1044", + "length": "16", + "registers": [ + "battery_config_id", + "battery_config_address", + "battery_config_protocol", + "battery_config_voltage_over", + "battery_config_voltage_charge", + "battery_config_voltage_lack", + "battery_config_voltage_discharge_stop", + "battery_config_current_charge_limit", + "battery_config_current_discharge_limit", + "battery_config_depth_of_discharge", + "battery_config_end_of_discharge", + "battery_config_capacity", + "battery_config_voltage_nominal", + "battery_config_cell_type", + "battery_config_eps_buffer" + ], + "append": [1] + }, + { + "name": "eps_config", + "start_register": "0x1029", + "length": "2", + "registers": [ + "eps_control", + "eps_wait_time" + ] + }, + { + "name": "rs485_config", + "start_register": "0x100B", + "length": "5", + "registers": [ + "rs485_config_address", + "rs485_config_baud", + "rs485_config_stop_bit", + "rs485_config_parity_bit" + ], + "append": [1] + } + ], "registers": [ - { - "name": "serial_number", - "register": "0x0445", - "read_type": "string", - "registers": 7, - "ha": { - "name": "Serial Number", - "icon": "mdi:numeric", - "entity_category": "diagnostic", - "value_template": "{{ value_json.serial_number }}" - }, - "refresh": 86400 - }, { "name": "hw_version", "register": "0x044D", @@ -24,7 +57,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.hw_version }}" }, - "refresh": 86400 + "refresh": 86400 }, { "name": "sw_version_com", @@ -37,7 +70,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.sw_version_com }}" }, - "refresh": 86400 + "refresh": 86400 }, { "name": "sw_version_master", @@ -50,7 +83,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.sw_version_master }}" }, - "refresh": 86400 + "refresh": 86400 }, { "name": "sw_version_slave", @@ -63,7 +96,492 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.sw_version_slave }}" }, - "refresh": 86400 + "refresh": 86400 + }, + { + "name": "rs485_config_address", + "register": "0x100B", + "desc": "RS 485 configuration- communication address", + "type": "U16", + "min": 1, + "max": 63, + "ha": { + "name": "RS485 Address", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 1, + "max": 63, + "initial": 0, + "mode": "box", + "command_topic": "sofar/rw/rs485_config/address", + "control": "number", + "value_template": "{{ value_json.rs485_config_address | int }}" + }, + "refresh": 1 + }, + { + "name": "rs485_config_baud", + "register": "0x100C", + "desc": "RS485 Baud Rate Selection", + "type": "U16", + "function": "mode", + "modes": { + "0": "4800", + "1": "9600", + "2": "19200", + "3": "38400", + "4": "57600" + }, + "ha": { + "name": "RS485 Baud", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 0, + "max": 4, + "initial": 0, + "mode": "box", + "unit_of_measurement": "bps", + "value_template": "{{ value_json.rs485_config_baud }}", + "command_topic": "sofar/rw/rs485_config/baud", + "options": [ + "4800", + "9600", + "19200", + "38400", + "57600" + ], + "control": "select" + }, + "refresh": 1 + }, + { + "name": "rs485_config_stop_bit", + "register": "0x100C", + "desc": "RS485 Stop Bit Selection", + "type": "U16", + "function": "mode", + "modes": { + "0": "1", + "1": "1.5", + "2": "2" + }, + "ha": { + "name": "RS485 Stop Bit", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 0, + "max": 2, + "initial": 0, + "value_template": "{{ value_json.rs485_config_stop_bit }}", + "command_topic": "sofar/rw/rs485_config/stop_bit", + "options": [ + "1", + "1.5", + "2" + ], + "control": "select" + }, + "refresh": 1 + }, + { + "name": "rs485_config_parity_bit", + "register": "0x100C", + "desc": "RS485 Parity Bit Selection", + "type": "U16", + "function": "mode", + "modes": { + "0": "None", + "1": "Even", + "2": "Odd", + "3": "High/Mark", + "4": "Low/Space" + }, + "ha": { + "name": "RS485 Parity Bit", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 0, + "max": 4, + "initial": 0, + "value_template": "{{ value_json.rs485_config_parity_bit }}", + "command_topic": "sofar/rw/rs485_config/parity_bit", + "options": [ + "None", + "Even", + "Odd", + "High/Mark", + "Low/Space" + ], + "control": "select" + }, + "refresh": 1 + }, + { + "name": "battery_config_id", + "register": "0x1044", + "desc": "battery id", + "type": "U16", + "min": 0, + "max": 7, + "ha": { + "name": "Battery ID", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 0, + "max": 7, + "step": 1, + "initial": 0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/id", + "control": "number", + "value_template": "{{ value_json.battery_config_id | int }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_address", + "register": "0x1045", + "desc": "battery address", + "type": "U16", + "min": 0, + "max": 99, + "ha": { + "name": "Battery Address", + "icon": "mdi:identifier", + "entity_category": "config", + "min": 0, + "max": 99, + "step": 1, + "initial": 0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/address", + "control": "number", + "value_template": "{{ value_json.battery_config_address }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_protocol", + "register": "0x1046", + "desc": "battery protocol", + "type": "U16", + "function": "mode", + "modes": { + "0": "Default", + "1": "Pylon", + "2": "General", + "3": "Amass", + "4": "LG", + "5": "Alpha.ESS", + "6": "CATL", + "7": "Weco", + "8": "Unknown", + "9": "EMS" + }, + "ha": { + "name": "Battery Protocol", + "icon": "mdi:protocol", + "entity_category": "config", + "value_template": "{{ value_json.battery_config_protocol }}", + "command_topic": "sofar/rw/battery_config/protocol", + "options": [ + "Default", + "Pylon", + "General", + "Amass", + "LG", + "Alpha.ESS", + "CATL", + "Weco", + "Unknown", + "EMS" + ], + "control": "select" + }, + "refresh": 1 + }, + { + "name": "battery_config_voltage_over", + "register": "0x1047", + "desc": "battery over voltage", + "type": "U16", + "function": "divide", + "factor": 10, + "min": 0.0, + "max": 65535.0, + "ha": { + "name": "Battery Over Voltage", + "icon": "mdi:alpha-v-box", + "entity_category": "config", + "min": 0.0, + "max": 65535.0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_over", + "control": "number", + "device_class": "voltage", + "unit_of_measurement": "V", + "value_template": "{{ value_json.battery_config_voltage_over }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_voltage_charge", + "register": "0x1048", + "desc": "battery charge voltage", + "type": "U16", + "function": "divide", + "factor": 10, + "ha": { + "name": "Battery Charge Voltage", + "icon": "mdi:alpha-v-box", + "entity_category": "config", + "min": 0.0, + "max": 65535.0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_charge", + "control": "number", + "device_class": "voltage", + "unit_of_measurement": "V", + "value_template": "{{ value_json.battery_config_voltage_charge }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_voltage_lack", + "register": "0x1049", + "desc": "battery lack voltage", + "type": "U16", + "function": "divide", + "factor": 10, + "min": 0.0, + "max": 65535.0, + "ha": { + "name": "Battery Lack Voltage", + "icon": "mdi:alpha-v-box", + "entity_category": "config", + "min": 0.0, + "max": 65535.0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_lack", + "control": "number", + "device_class": "voltage", + "unit_of_measurement": "V", + "value_template": "{{ value_json.battery_config_voltage_lack }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_voltage_discharge_stop", + "register": "0x104A", + "desc": "battery discharge stop voltage", + "type": "U16", + "function": "divide", + "factor": 10, + "min": 0.0, + "max": 65535.0, + "ha": { + "name": "Battery Discharge Stop Voltage", + "icon": "mdi:alpha-v-box", + "entity_category": "config", + "min": 0.0, + "max": 65535.0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_discharge_stop", + "control": "number", + "device_class": "voltage", + "unit_of_measurement": "V", + "value_template": "{{ value_json.battery_config_voltage_discharge_stop }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_current_charge_limit", + "register": "0x104B", + "desc": "battery charge current limit", + "type": "U16", + "function": "divide", + "factor": 100, + "min": 0, + "max": 65535, + "ha": { + "name": "Battery Charge Current Limit", + "icon": "mdi:alpha-a-box", + "entity_category": "config", + "min": 0, + "max": 65535, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_charge", + "control": "number", + "device_class": "current", + "unit_of_measurement": "A", + "value_template": "{{ value_json.battery_config_current_charge_limit }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_current_discharge_limit", + "register": "0x104C", + "desc": "battery discharge current limit", + "type": "U16", + "function": "divide", + "factor": 100, + "min": 0, + "max": 65535, + "ha": { + "name": "Battery Discharge Current Limit", + "icon": "mdi:alpha-a-box", + "entity_category": "config", + "min": 0, + "max": 65535, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_discharge_stop", + "control": "number", + "device_class": "current", + "unit_of_measurement": "A", + "value_template": "{{ value_json.battery_config_current_discharge_limit }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_depth_of_discharge", + "register": "0x104D", + "desc": "Dod indicates the max discharge power, when SOC<1-DOD, inverter will stop power discharge , inverter will stop power discharge caused by other issues. DOD<=EOD", + "function": "int", + "min": 1, + "max": 90, + "write": true, + "type": "U16", + "ha": { + "name": "Battery Depth of Discharge SOC", + "icon": "mdi:percent-outline", + "entity_category": "config", + "min": 1, + "max": 90, + "mode": "box", + "command_topic": "sofar/rw/battery_config/depth_of_discharge", + "control": "number", + "unit_of_measurement": "%", + "value_template": "{{ value_json.battery_config_depth_of_discharge | int }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_end_of_discharge", + "register": "0x104E", + "desc": "EOD indicate the max discharge power on off grid mode, when SOC<1-EOD, inverter will stop power discharge , inverter will stop power discharge caused by other issues", + "type": "U16", + "min": 1, + "max": 90, + "ha": { + "name": "Battery Off-Grid Depth of Discharge", + "icon": "mdi:percent-outline", + "entity_category": "config", + "min": 1, + "max": 90, + "mode": "box", + "command_topic": "sofar/rw/battery_config/end_of_discharge", + "control": "number", + "unit_of_measurement": "%", + "value_template": "{{ value_json.battery_config_end_of_discharge }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_capacity", + "register": "0x104F", + "desc": "battery capacity", + "type": "U16", + "ha": { + "name": "Battery Capacity", + "icon": "mdi:battery", + "entity_category": "config", + "min": 1, + "max": 65535, + "mode": "box", + "command_topic": "sofar/rw/battery_config/capacity", + "control": "number", + "unit_of_measurement": "Ah", + "value_template": "{{ value_json.battery_config_capacity }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_voltage_nominal", + "register": "0x1050", + "desc": "battery nominal voltage", + "type": "U16", + "function": "divide", + "factor": 10, + "ha": { + "name": "Battery Nominal Voltage", + "icon": "mdi:alpha-v-box", + "entity_category": "config", + "min": 0.0, + "max": 65535.0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/voltage_nominal", + "control": "number", + "device_class": "voltage", + "unit_of_measurement": "V", + "value_template": "{{ value_json.battery_config_voltage_nominal }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_cell_type", + "register": "0x1051", + "desc": "battery cell type", + "type": "U16", + "function": "mode", + "modes": { + "0": "Lead-acid", + "1": "Lithium Iron Phosphate (LiFePO4)", + "2": "Lithium Ternary (NCM)", + "3": "Lithium Titanate (LTO)" + }, + "ha": { + "name": "Battery Cell Type", + "icon": "mdi:battery-unknown", + "entity_category": "config", + "command_topic": "sofar/rw/battery_config/cell_type", + "options": [ + "Lead-acid", + "Lithium Iron Phosphate (LiFePO4)", + "Lithium Ternary (NCM)", + "Lithium Titanate (LTO)" + ], + "control": "select", + "value_template": "{{ value_json.battery_config_cell_type }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_eps_buffer", + "register": "0x1052", + "desc": "battery eps buffer", + "type": "U16", + "min": 5, + "max": 100, + "ha": { + "name": "Battery EPS Buffer", + "icon": "mdi:percent-outline", + "entity_category": "config", + "min": 5, + "max": 100, + "step": 1, + "initial": 0, + "mode": "box", + "command_topic": "sofar/rw/battery_config/eps_buffer", + "control": "number", + "unit_of_measurement": "%", + "value_template": "{{ value_json.battery_config_eps_buffer }}" + }, + "refresh": 1 + }, + { + "name": "battery_config_control", + "register": "0x1053", + "desc": "Battery Control - Always 1" }, { "name": "energy_storage_mode", @@ -79,6 +597,7 @@ "4": "Peak cut mode" }, "write": true, + "notify_on_change": true, "ha": { "command_topic": "sofar/rw/energy_storage_mode", "entity_category": "config", @@ -98,36 +617,36 @@ { "name": "charge_start_time", "register": "0x1113", - "refresh": 3600 + "refresh": 3600 }, { "name": "charge_end_time", "register": "0x1114", - "refresh": 3600 + "refresh": 3600 }, { "name": "discharge_start_time", "register": "0x1115", - "refresh": 3600 + "refresh": 3600 }, { "name": "discharge_end_time", "register": "0x1116", - "refresh": 3600 + "refresh": 3600 }, { "name": "charge_power", "register": "0x1117", "function": "multiply", "factor": 10, - "refresh": 3600 + "refresh": 3600 }, { "name": "discharge_power", "register": "0x1118", "function": "multiply", "factor": 10, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use", @@ -143,7 +662,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_charge_start_time", @@ -156,7 +675,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_charge_start_time }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_charge_end_time", @@ -169,7 +688,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_charge_end_time }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_charge_soc", @@ -188,7 +707,7 @@ "state_class": "measurement", "value_template": "{{ value_json.time_of_use_charge_soc }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_charge_power", @@ -203,7 +722,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_charge_power }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_start_date", @@ -216,7 +735,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_start_date }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_end_date", @@ -229,7 +748,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_end_date }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "time_of_use_dow", @@ -250,7 +769,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.time_of_use_dow }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "desired_power", @@ -418,6 +937,8 @@ "pv_2_power" ], "agg_function": "add", + "function": "multiply", + "factor": 10, "ha": { "device_class": "power", "unit_of_measurement": "W", @@ -465,8 +986,9 @@ "load_power", "active_power" ], + "function": "multiply", + "factor": 10, "agg_function": "subtract", - "invert": false, "ha": { "device_class": "power", "name": "On-Grid Power", @@ -487,7 +1009,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.insulation_resistance }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "ongrid_frequency", @@ -503,7 +1025,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.ongrid_frequency }}" }, - "refresh": 60 + "refresh": 60 }, { "name": "ongrid_voltage", @@ -519,7 +1041,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.ongrid_voltage }}" }, - "refresh": 60 + "refresh": 60 }, { "name": "inverter_temp_internal", @@ -576,7 +1098,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.offgrid_frequency }}" }, - "refresh": 60 + "refresh": 60 }, { "name": "offgrid_voltage", @@ -592,7 +1114,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.offgrid_voltage }}" }, - "refresh": 60 + "refresh": 60 }, { "name": "battery_current", @@ -640,7 +1162,23 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.today_generation }}" }, - "refresh": 60 + "refresh": 60 + }, + { + "name": "total_generation", + "register": "0x0687", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Solar Generation Total", + "icon": "mdi:solar-power-variant", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.total_generation }}" + }, + "refresh": 3600 }, { "name": "today_consumption", @@ -656,7 +1194,23 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.today_consumption }}" }, - "refresh": 60 + "refresh": 60 + }, + { + "name": "total_consumption", + "register": "0x068B", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Consumption Total", + "icon": "mdi:home-lightning-bolt-outline", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.total_consumption }}" + }, + "refresh": 3600 }, { "name": "today_import", @@ -672,7 +1226,23 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.today_import }}" }, - "refresh": 60 + "refresh": 60 + }, + { + "name": "total_import", + "register": "0x068F", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Import Total", + "icon": "mdi:transmission-tower-import", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.total_import }}" + }, + "refresh": 3600 }, { "name": "today_export", @@ -688,23 +1258,23 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.today_export }}" }, - "refresh": 60 + "refresh": 60 }, { - "name": "today_battery_discharge", - "register": "0x0699", + "name": "total_export", + "register": "0x0693", "function": "divide", "factor": 100, "ha": { "device_class": "energy", "unit_of_measurement": "kWh", - "name": "Battery Discharge Today", - "icon": "mdi:battery-minus-variant", + "name": "Export Total", + "icon": "mdi:transmission-tower-export", "state_class": "total_increasing", "entity_category": "diagnostic", - "value_template": "{{ value_json.today_battery_discharge }}" + "value_template": "{{ value_json.total_export }}" }, - "refresh": 60 + "refresh": 3600 }, { "name": "today_battery_charge", @@ -720,7 +1290,55 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.today_battery_charge }}" }, - "refresh": 60 + "refresh": 60 + }, + { + "name": "total_battery_charge", + "register": "0x0697", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Charge Total", + "icon": "mdi:battery-plus-variant", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.total_battery_charge }}" + }, + "refresh": 3600 + }, + { + "name": "today_battery_discharge", + "register": "0x0699", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Discharge Today", + "icon": "mdi:battery-minus-variant", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.today_battery_discharge }}" + }, + "refresh": 60 + }, + { + "name": "total_battery_discharge", + "register": "0x069B", + "function": "divide", + "factor": 100, + "ha": { + "device_class": "energy", + "unit_of_measurement": "kWh", + "name": "Battery Discharge Total", + "icon": "mdi:battery-minus-variant", + "state_class": "total_increasing", + "entity_category": "diagnostic", + "value_template": "{{ value_json.total_battery_discharge }}" + }, + "refresh": 3600 }, { "name": "battery_voltage", @@ -778,7 +1396,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.battery_soh }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "battery_cycles", @@ -792,7 +1410,7 @@ "entity_category": "diagnostic", "value_template": "{{ value_json.battery_cycles }}" }, - "refresh": 3600 + "refresh": 3600 }, { "name": "eps_control", @@ -804,9 +1422,41 @@ "0": "Off", "1": "On" }, - "write": true, - "untested": true, - "refresh": 86400 + "ha": { + "name": "EPS Control", + "icon": "mdi:toggle-switch", + "entity_category": "config", + "command_topic": "sofar/rw/eps_config/eps_control", + "options": [ + "Off", + "On" + ], + "control": "select", + "value_template": "{{ value_json.eps_control }}" + }, + "refresh": 1 + }, + { + "name": "eps_wait_time", + "register": "0x102A", + "desc": "Waiting time for emergency power start", + "type": "U16", + "min": 0, + "max": 65535, + "ha": { + "name": "EPS Wait Time", + "icon": "mdi:alpha-s-box", + "entity_category": "config", + "min": 0, + "max": 65535, + "mode": "box", + "command_topic": "sofar/rw/eps_config/eps_wait_time", + "control": "number", + "device_class": "duration", + "unit_of_measurement": "s", + "value_template": "{{ value_json.eps_wait_time }}" + }, + "refresh": 1 }, { "name": "remote_on_off_control", @@ -820,7 +1470,7 @@ }, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "power_control", @@ -831,7 +1481,7 @@ "max": 100, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "active_power_export_limit", @@ -842,7 +1492,7 @@ "max": 100, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "active_power_import_limit", @@ -853,7 +1503,7 @@ "max": 100, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "reactive_power_setting", @@ -865,7 +1515,7 @@ "signed": true, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "power_factor_setting", @@ -877,7 +1527,7 @@ "signed": true, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "active_power_limit_speed", @@ -889,7 +1539,7 @@ "signed": true, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "reactive_power_response_time", @@ -901,7 +1551,7 @@ "signed": true, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "passive_timeout", @@ -911,7 +1561,7 @@ "type": "U16", "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "passive_timeout_action", @@ -925,7 +1575,7 @@ }, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 }, { "name": "desired_power_grid", @@ -940,7 +1590,7 @@ "max": 999999, "write": true, "untested": true, - "refresh": 86400 + "refresh": 86400 } ] -} +} \ No newline at end of file diff --git a/sofar-me-3000.json b/SOFAR-HYD-ES-AND-ME3000-SP.json similarity index 100% rename from sofar-me-3000.json rename to SOFAR-HYD-ES-AND-ME3000-SP.json diff --git a/sofar2mqtt-v2.py b/sofar2mqtt-v2.py index 203330a..b70f5bd 100644 --- a/sofar2mqtt-v2.py +++ b/sofar2mqtt-v2.py @@ -15,6 +15,9 @@ from multiprocessing import Process import paho.mqtt.client as mqtt import requests +import os + +VERSION = "3.1.0" def load_config(config_file_path): """ Load configuration file """ @@ -28,16 +31,7 @@ class Sofar(): """ Sofar """ # pylint: disable=line-too-long,too-many-arguments - def __init__(self, config_file_path, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, topic, write_topic, log_level, device, legacy_publish): - self.config = load_config(config_file_path) - self.write_registers = [] - untested = False - for register in self.config['registers']: - if "untested" in register: - untested = register["untested"] - if "write" in register: - if register["write"] and not untested: - self.write_registers.append(register) + def __init__(self, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, ca_certs, topic, write_topic, log_level, device, legacy_publish): self.daemon = daemon self.retry = retry self.retry_delay = retry_delay @@ -48,33 +42,69 @@ def __init__(self, config_file_path, daemon, retry, retry_delay, write_retry, wr self.port = port self.username = username self.password = password + self.ca_certs = ca_certs self.topic = topic self.write_topic = write_topic self.requests = 0 self.failures = 0 self.failed = [] + self.failure_pattern = "" self.retries = 0 self.instrument = None self.device = device self.legacy_publish = legacy_publish - self.data = {} + self.raw_data = {} self.log_level = logging.getLevelName(log_level) + self.iteration = 0 logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.getLevelName(log_level)) + logging.info(f"Starting sofar2mqtt-python {VERSION}") self.mutex = threading.Lock() + self.setup_instrument() + self.raw_data['serial_number'] = self.determine_serial_number() self.client = mqtt.Client(client_id=f"sofar2mqtt-{socket.gethostname()}", userdata=None, protocol=mqtt.MQTTv5, transport="tcp") + if not self.raw_data['serial_number']: + logging.error("Failed to determine serial number. Exiting") + self.terminate(status_code=1) + self.raw_data['model'] = self.determine_model() + self.raw_data['protocol'] = self.determine_modbus_protocol() + + if self.raw_data.get('protocol') == "SOFAR-1-40KTL.json": + logging.error("Unsupported protocol detected. Exiting") + self.terminate(status_code=1) + + protocol_file = self.raw_data.get('protocol') + if not os.path.isfile(protocol_file): + logging.error(f"Protocol file {protocol_file} does not exist. Exiting") + self.terminate(status_code=1) + + self.config = load_config(protocol_file) + self.write_registers = [] + untested = False + for register in self.config['registers']: + if "untested" in register: + untested = register["untested"] + if "write" in register: + if register["write"] and not untested: + self.write_registers.append(register) + + logging.info(f"Starting sofar2mqtt-python for serial number: {self.raw_data.get('serial_number')}") self.setup_mqtt(logging) - self.setup_instrument() - self.iteration = 0 + self.update_state() def on_connect(self, client, userdata, flags, rc, properties=None): logging.info("MQTT "+mqtt.connack_string(rc)) if rc == 0: try: + self.publish_mqtt_discovery_bridge() + self.publish_mqtt_discovery() logging.info(f"Subscribing to homeassistant/status") client.subscribe(f"homeassistant/status", qos=0, options=None, properties=None) for register in self.write_registers: logging.info(f"Subscribing to {self.write_topic}/{register['name']}") client.subscribe(f"{self.write_topic}/{register['name']}", qos=0, options=None, properties=None) + for block in self.config.get('write_register_blocks', []): + logging.info(f"Subscribing to {self.write_topic}/{block['name']}") + client.subscribe(f"{self.write_topic}/{block['name']}/#", qos=0, options=None, properties=None) except Exception: logging.info(traceback.format_exc()) @@ -83,6 +113,9 @@ def on_disconnect(client, userdata, rc, properties=None): logging.info("MQTT un-expected disconnect") def on_message(self, client, userdata, message, properties=None): + if message.retain: + logging.info(f"Ignoring retained message on topic {message.topic}") + return found = False valid = False topic = message.topic @@ -90,65 +123,69 @@ def on_message(self, client, userdata, message, properties=None): if topic == "homeassistant/status": logging.info(f"Received message for {topic}:{payload}") if payload == "online": + self.publish_mqtt_discovery_bridge() self.publish_mqtt_discovery() return for register in self.write_registers: if register['name'] == topic.split('/')[-1]: + new_raw_value = self.translate_to_raw_value(register, payload) found = True if 'function' in register: if register['function'] == 'mode': - new_mode = False - for key in register['modes']: - if register['modes'][key] == payload: - new_mode = key - logging.info(f"Received a request for {register['name']} to set mode value to: {payload}({new_mode})") - if not new_mode: - logging.error(f"Received a request for {register['name']} but mode value: {payload} is not a known mode. Ignoring") - if register['name'] in self.data: + new_value = register['modes'].get(str(new_raw_value), None) + logging.info(f"Received a request for {register['name']} to set mode value to: {new_raw_value} ({new_value})") + if not new_value: + logging.error(f"Received a request for {register['name']} but mode value: {new_value} is not a known mode. Ignoring") + if register['name'] in self.raw_data: retry = self.write_retry + raw_value = self.raw_data.get(register.get('name'), None) + value = self.translate_from_raw_value(register, raw_value) while retry > 0: - if self.data[register['name']] == payload: - logging.info(f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {payload}. Ignoring") + if self.raw_data[register['name']] == int(new_raw_value): + logging.info(f"Current value for {register['name']}: {raw_value} ({value}). Matches desired value: {new_raw_value} ({new_value}).") retry = 0 else: - logging.info(f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to: {payload}. Retries remaining: {retry}") - self.write_value(register, int(new_mode)) + logging.info(f"Current value for {register['name']}: {raw_value} ({value}), attempting to set it to: {new_raw_value} ({new_value}). Retries remaining: {retry}") + self.write_register(register, int(new_raw_value)) time.sleep(self.write_retry_delay) retry = retry - 1 else: logging.error(f"No current read value for {register['name']} skipping write operation. Please try again.") elif register['function'] == 'int': - value = int(payload) - logging.info(f"Received a request for {register['name']} to set value to: {payload}({value})") - if value < register['min']: - logging.error(f"Received a request for {register['name']} but value: {value} is less than the min value: {register['min']}. Ignoring") - elif value > register['max']: - logging.error(f"Received a request for {register['name']} but value: {value} is more than the max value: {register['max']}. Ignoring") + logging.info(f"Received a request for {register['name']} to set value to: {payload}({new_raw_value})") + if int(new_raw_value) < register['min']: + logging.error(f"Received a request for {register['name']} but value: {new_raw_value} is less than the min value: {register['min']}. Ignoring") + elif int(new_raw_value) > register['max']: + logging.error(f"Received a request for {register['name']} but value: {new_raw_value} is more than the max value: {register['max']}. Ignoring") else: if register['name'] == 'desired_power': - if 'energy_storage_mode' in self.data: - if 'Passive mode' != self.data['energy_storage_mode']: - logging.info(f"Received a request for {register['name']} but not not in Passive mode. Ignoring") - continue - if register['name'] in self.data: + if int(self.raw_data.get('energy_storage_mode', None)) == 0: + logging.info(f"Received a request for {register['name']} but energy_storage_mode is not in Passive mode. Ignoring") + continue + if register['name'] in self.raw_data: retry = self.write_retry while retry > 0: - if self.data[register['name']] == value: - logging.info(f"Current value for {register['name']}={self.data[register['name']]} matches desired value: {value}. Ignoring") + if int(self.raw_data[register['name']]) == int(new_raw_value): + logging.info(f"Current value for {register['name']}: {self.raw_data[register['name']]} matches desired value: {new_raw_value}") retry = 0 else: - logging.info(f"Current value for {register['name']}={self.data[register['name']]}, attempting to set it to {value}. Retries remaining: {retry}") - self.write_value(register, value) + logging.info(f"Current value for {register['name']}: {self.raw_data[register['name']]}, attempting to set it to {new_raw_value}. Retries remaining: {retry}") + self.write_register(register, int(new_raw_value)) time.sleep(self.write_retry_delay) retry = retry - 1 else: logging.error(f"No current read value for {register['name']} skipping write operation. Please try again.") if not found: - logging.error(f"Received a request to set an unknown register: {register_name['name']} to {payload}") - + for block in self.config.get('write_register_blocks', []): + if block['name'] == topic.split('/')[-2]: + update_register = topic.split('/')[-1] + logging.info(f"Received a request to write block: {topic} {block['name']}_{update_register} {payload}") + self.write_register_block(block['name'], f"{block['name']}_{update_register}", payload) + return + logging.error(f"Received a request to set an unknown register or block: {topic} to {payload}") def setup_mqtt(self, logging): self.client.enable_logger(logger=logging) @@ -160,6 +197,9 @@ def setup_mqtt(self, logging): else: logging.info(f"MQTT connecting to broker {self.broker} port {self.port} without auth") self.client.reconnect_delay_set(min_delay=1, max_delay=300) + if self.port == 8883: + self.client.tls_set(ca_certs=self.ca_certs) + self.client.connect(self.broker, port=self.port, keepalive=60, bind_address="", bind_port=0, clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY, properties=None) self.client.loop_start() @@ -171,138 +211,112 @@ def setup_instrument(self): self.instrument.serial.bytesize = 8 self.instrument.serial.parity = serial.PARITY_NONE self.instrument.serial.stopbits = 1 - self.instrument.serial.timeout = 0.2 # seconds - self.instrument.close_port_after_each_call = True + self.instrument.serial.timeout = 0.1 # seconds + self.instrument.close_port_after_each_call = False + self.instrument.clear_buffers_before_each_transaction = True + + def combine_aggregate_registers(self, register): + """ Combine registers from the 'aggregate' field using the arithmetic function in 'agg_function' """ + raw_value = 0 + for register_name in register['aggregate']: + if register_name in self.raw_data: + if raw_value == 0: + raw_value = self.raw_data[register_name] + else: + if register['agg_function'] == 'add': + raw_value += self.raw_data[register_name] + elif register['agg_function'] == 'subtract': + raw_value -= self.raw_data[register_name] + elif register['agg_function'] == 'avg': + raw_value = int((raw_value + self.raw_data[register_name]) / 2) + self.raw_data[register['name']] = raw_value + return raw_value - def read_and_publish(self): + def update_state(self): for register in self.config['registers']: - refresh = 1 - if 'refresh' in register: - refresh = register['refresh'] + refresh = register.get('refresh', 1) if (self.iteration % refresh) != 0: logging.debug(f"Skipping {register['name']}") continue - value = None - signed = False + raw_value = None logging.debug('Reading %s', register['name']) - if 'signed' in register: - signed = register['signed'] if 'aggregate' in register: - value = 0 - for register_name in register['aggregate']: - if register_name in self.data: - if value == 0: - value = self.data[register_name] - else: - if register['agg_function'] == 'add': - value += self.data[register_name] - elif register['agg_function'] == 'subtract': - value -= self.data[register_name] - elif register['agg_function'] == 'avg': - value = int((value + self.data[register_name]) / 2) - if 'invert' in register: - if register['invert']: - if value > 0: - value = -abs(value) - else: - value = abs(value) + raw_value = self.combine_aggregate_registers(register) else: - read_type = 'register' - registers = 1 - if 'read_type' in register: - read_type = register['read_type'] - if 'registers' in register: - registers = register['registers'] - value = self.read_value( + raw_value = self.read_register( int(register['register'], 16), - read_type, - signed, - registers + register.get('read_type', 'register'), + register.get('signed', False), + register.get('registers', 1) ) - if value is None: + if raw_value is None: + logging.error(f"Value for {register['name']}: is none") continue else: + value = self.translate_from_raw_value(register, raw_value) # Inverter will return maximum 16-bit integer value when data not available (eg. grid usage when grid down) if value == 65535: value = 0 - if 'min' in register: - if value < register['min']: - logging.error(f"Value for {register['name']}: {str(value)} is lower than min allowed value: {register['min']}. Ignoring value") - continue - if 'max' in register: - if value > register['max']: - logging.error(f"Value for {register['name']}: {str(value)} is greater than max allowed value: {register['max']}. Ignoring value") - continue - if 'function' in register: - if register['function'] == 'multiply': - value = value * register['factor'] - elif register['function'] == 'divide': - value = value / register['factor'] - elif register['function'] == 'mode': - try: - value = register['modes'][str(value)] - except KeyError: - logging.error(f"Unknown mode value for {register['name']} value: {str(value)}") - elif register['function'] == 'bit_field': - length = len(register['fields']) - fields = [] - for n in reversed(range(length)): - if value & (1 << ((length-1)-n)): - fields.append(register['fields'][n]) - value = (','.join(fields)) - elif register['function'] == 'high_bit_low_bit': - high = value >> 8 # shift right - low = value & 255 # apply bitmask - value = f"{high:02}{register['join']}{low:02}" # combine and pad 2 zeros - logging.debug('Read %s:%s', register['name'], value) - - self.publish(register['name'], value) + if 'min' in register and value < register['min']: + logging.error(f"Value for {register['name']}: {str(value)} is lower than min allowed value: {register['min']}") + if 'max' in register and value > register['max']: + logging.error(f"Value for {register['name']}: {str(raw_value)} is greater than max allowed value: {register['max']}") + logging.debug(f"Read {register['name']} {value}") + if not self.raw_data.get(register.get('name')) == raw_value: + if register.get('notify_on_change', False): + from_raw = self.raw_data.get(register.get('name')) + from_value = self.translate_from_raw_value(register, from_raw) + logging.info(f"Notification - {register.get('name')} has changed from: {from_raw} ({from_value}) to: {raw_value} ({value})") + self.raw_data[register.get('name')] = raw_value failure_percentage = round(self.failures / (self.requests+self.retries)*100,2) retry_percentage = round(self.retries / (self.requests)*100,2) logging.info(f"Modbus Requests: {self.requests} Retries: {self.retries} ({retry_percentage}%) Failures: {self.failures} ({failure_percentage}%)") - self.data['modbus_failures'] = self.failures - self.data['modbus_requests'] = self.requests - self.data['modbus_retries'] = self.retries - self.data['modbus_failure_rate'] = failure_percentage - self.data['modbus_retry_rate'] = retry_percentage - - def read(self): - now = datetime.datetime.now() - """ Sleep for 35 seconds to allow the inverter to reset the stats at 00:00 """ - if (now.hour == 23 and now.minute == 59 and now.second >= 30): - logging.info('Snoozing 35 seconds') - time.sleep(35) - self.read_and_publish() - self.requests = 0 - self.failures = 0 - self.failed = [] - self.retries = 0 + logging.info(self.failure_pattern) + self.failure_pattern = "" + self.raw_data['modbus_failures'] = self.failures + self.raw_data['modbus_requests'] = self.requests + self.raw_data['modbus_retries'] = self.retries + self.raw_data['modbus_failure_rate'] = failure_percentage + self.raw_data['modbus_retry_rate'] = retry_percentage def publish_state(self): try: - data = json.dumps(self.data, indent=2) - self.client.publish("sofar2mqtt_python/bridge", "online", retain=False) - self.client.publish(self.topic + "state_all", data, retain=True) + data = {} + for register in self.config['registers']: + if register['name'] in self.raw_data: + raw_value = self.raw_data[register['name']] + value = self.translate_from_raw_value(register, raw_value) + data[register['name']] = value + + json_data = json.dumps(data, indent=2) + self.client.publish(self.topic + "state_all", json_data, retain=True) + with open("data.json", "w") as write_file: - write_file.write(data) + write_file.write(json_data) + if self.legacy_publish: + self.publish_legacy_state() except Exception: logging.info(traceback.format_exc()) time.sleep(self.refresh_interval) - def publish_mqtt_discovery(self): - if 'serial_number' not in self.data: - logging.error("Serial number could not be determined, skipping publish") - return False + def publish_legacy_state(self): + for register in self.config['registers']: + logging.debug('Publishing %s:%s', self.topic + register.get("name"), self.raw_data.get(register.get("name"))) + try: + value = self.translate_from_raw_value(register, self.raw_data.get(register.get("name"))) + self.client.publish(self.topic + register.get("name"), value, retain=False) + except Exception: + logging.debug(traceback.format_exc()) - sn = self.data['serial_number'] + def publish_mqtt_discovery_bridge(self): payload = { "device": { - "identifiers": [f"sofar2mqtt_python_bridge_{sn}"], + "identifiers": [f"sofar2mqtt_python_bridge_{self.raw_data.get('serial_number')}"], "manufacturer": "Sofar2Mqtt-Python", "model": "Bridge", "name": "Sofar2Mqtt Python Bridge", - "sw_version": "3.0.3" + "sw_version": VERSION }, "device_class": "connectivity", "entity_category": "diagnostic", @@ -311,33 +325,42 @@ def publish_mqtt_discovery(self): "payload_off": "offline", "payload_on": "online", "state_topic": "sofar2mqtt_python/bridge", - "unique_id": f"bridge_{sn}_connection_state_sofar2mqtt_python", + "unique_id": f"bridge_{self.raw_data.get('serial_number')}_connection_state_sofar2mqtt_python", } - topic = f"homeassistant/binary_sensor/{sn}/connection_state/config" + topic = f"homeassistant/binary_sensor/{self.raw_data.get('serial_number')}/connection_state/config" try: - logging.info(f"Publishing discovery to {topic}") + logging.info(f"Publishing bridge via MQTT to {topic}") self.client.publish(topic, json.dumps(payload), retain=False) + self.client.publish("sofar2mqtt_python/bridge", "online", retain=False) except Exception: logging.info(traceback.format_exc()) + + def publish_mqtt_discovery(self): + logging.info(f"Publishing controls via MQTT") + while not self.raw_data.get("sw_version_com") and not self.raw_data.get("hw_version"): + logging.info(f"SW Version and HW Version not available yet. Waiting for 5 seconds") + time.sleep(5) + for register in self.config['registers']: if 'ha' not in register: continue + try: default_payload = { "name": register['name'], "state_topic": "sofar/state_all", - "unique_id": f"{sn}_{register['name']}", + "unique_id": f"{self.raw_data.get('serial_number')}_{register['name']}", "entity_id": f"sofar_{register['name']}", "enabled_by_default": "true", "device": { "name": f"Sofar", - "sw_version": self.data["sw_version_com"], - "hw_version": self.data["hw_version"], - "manufacturer": "SOFAR", - "model": "HYD-6000-EP", + "sw_version": self.raw_data.get("sw_version_com"), + "hw_version": self.raw_data.get("hw_version"), + "manufacturer": "Sofar", + "model": self.raw_data.get('model'), "configuration_url": "https://github.com/rjpearce/sofar2mqtt-python", - "identifiers": [f"{sn}"] + "identifiers": [f"{self.raw_data.get('serial_number')}"] }, "availability": [ { @@ -359,42 +382,32 @@ def signal_handler(self, sig, _frame): logging.info(f"Received signal {sig}, attempting to stop") self.daemon = False - def terminate(self): + def terminate(self, status_code=0): logging.info("Terminating") logging.info(f"Publishing offline to sofar2mqtt_python/bridge") self.client.publish("sofar2mqtt_python/bridge", "offline", retain=False) self.client.loop_stop() - exit(0) + exit(status_code) def main(self): """ Main method """ signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler) if not self.daemon: - self.read_and_publish() + self.update_state() + self.publish_state() while (self.daemon): - self.read() - if self.iteration == 0: - self.publish_mqtt_discovery() + self.requests = 0 + self.failures = 0 + self.failed = [] + self.retries = 0 + self.update_state() self.publish_state() time.sleep(self.refresh_interval) self.iteration+=1 - self.terminate() - - def publish(self, key, value): - if key == 'energy_storage_mode': - if key in self.data: - if value != self.data[key]: - logging.info(f"energy_storage_mode has changed to: {value}") - self.data[key] = value - if self.legacy_publish: - logging.debug('Publishing %s:%s', self.topic + key, value) - try: - self.client.publish(self.topic + key, value, retain=True) - except Exception: - logging.debug(traceback.format_exc()) + self.terminate(status_code=0) - def write_value(self, register, value): + def write_register(self, register, value): """ Read value from register with a retry mechanism """ with self.mutex: retry = self.write_retry @@ -417,18 +430,18 @@ def write_value(self, register, value): high = struct.unpack(">H", bytearray([values[2], values[3]]))[0] # send the registers self.instrument.write_registers(int(register['register'],16), [0, 0, low, high, low, high]) - except minimalmodbus.NoResponseError: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + except minimalmodbus.NoResponseError as e: + logging.debug(f"Failed to write_register {register['name']} {str(e)}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) - except minimalmodbus.InvalidResponseError: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + except minimalmodbus.InvalidResponseError as e: + logging.debug(f"Failed to write_register {register['name']} {str(e)}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) - except serial.serialutil.SerialException: - logging.debug(f"Failed to write_register {register['name']} {traceback.format_exc()}") + except serial.serialutil.SerialException as e: + logging.debug(f"Failed to write_register {register['name']} {str(e)}") retry = retry - 1 retries = retries + 1 time.sleep(self.write_retry_delay) @@ -438,7 +451,40 @@ def write_value(self, register, value): else: logging.error('Modbus Write Request: %s failed. Retry exhausted. Retries: %d', register['name'], retries) - def read_value(self, registeraddress, read_type, signed, registers=1): + def write_registers_with_retry(self, start_register, values): + """ Write values with a retry mechanism """ + retry = self.write_retry + logging.info(f"Writing {start_register} with {values}") + signed = False + success = False + retries = 0 + failed = 0 + while retry > 0 and not success: + try: + with self.mutex: + self.instrument.write_registers(int(start_register,16), values) + except minimalmodbus.NoResponseError as e: + logging.debug(f"Failed to write_register {start_register} {str(e)}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + except minimalmodbus.InvalidResponseError as e: + logging.debug(f"Failed to write_register {start_register} {str(e)}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + except serial.serialutil.SerialException as e: + logging.debug(f"Failed to write_register {start_register} {str(e)}") + retry = retry - 1 + retries = retries + 1 + time.sleep(self.write_retry_delay) + success = True + if success: + logging.info('Modbus Write Request: %s successful. Retries: %d', start_register, retries) + else: + logging.error('Modbus Write Request: %s failed. Retry exhausted. Retries: %d', start_register, retries) + + def read_register(self, registeraddress, read_type, signed, registers=1): """ Read value from register with a retry mechanism """ with self.mutex: value = None @@ -455,34 +501,336 @@ def read_value(self, registeraddress, read_type, signed, registers=1): elif read_type == "string": value = self.instrument.read_string( registeraddress, functioncode=3, number_of_registers=registers) - except minimalmodbus.NoResponseError: - logging.debug(traceback.format_exc()) + except minimalmodbus.NoResponseError as e: + logging.debug(f"Failed to read_register {registeraddress} {str(e)}") retry = retry - 1 self.retries = self.retries + 1 + self.failure_pattern += "r" time.sleep(self.retry_delay) - except minimalmodbus.InvalidResponseError: - logging.debug(traceback.format_exc()) + except minimalmodbus.InvalidResponseError as e: + logging.debug(f"Failed to read_register {registeraddress} {str(e)}") retry = retry - 1 self.retries = self.retries + 1 + self.failure_pattern += "i" time.sleep(self.retry_delay) - except serial.serialutil.SerialException: - logging.debug(traceback.format_exc()) + except serial.serialutil.SerialException as e: + logging.debug(f"Failed to read_register {registeraddress} {str(e)}") retry = retry - 1 self.retries = self.retries + 1 + self.failure_pattern += "x" time.sleep(self.retry_delay) if retry == 0: self.failures = self.failures + 1 + self.failure_pattern += "f" self.failed.append(registeraddress) + if value: + self.failure_pattern += "." return value + def is_valid_serial_number(self, serial_number): + """ Check if the serial number is valid """ + logging.info(f"Checking validity of Serial number: {str(serial_number)} length: {len(serial_number)}") + if len(serial_number) == 14: + return serial_number[0] == 'S' and serial_number[1].isalpha() and serial_number[2:].isalnum() + elif len(serial_number) == 20: + return serial_number[0] == 'S' and serial_number[1:].isalnum() + return False + + def determine_serial_number(self): + """ Determine the serial number from the inverter """ + serial_number = None + + # Try first location: 0x2001 ... 0x2007 + try: + serial_number = ''.join([self.read_register(register, 'string', False, 1) for register in range(0x2001, 0x2008)]) + if self.is_valid_serial_number(serial_number): + logging.info(f"Valid Serial number found at first location: {serial_number}") + return serial_number + except Exception as e: + logging.info(f"Failed to validate serial number from first location: {str(e)}") + + # Try second location: 0x0445 ... 0x044B (14 digits) + try: + serial_number = ''.join([self.read_register(register, 'string', False, 1) for register in range(0x0445, 0x044C)]) + if self.is_valid_serial_number(serial_number): + logging.info(f"Valid Serial number found at second location: {serial_number}") + return serial_number + except Exception as e: + logging.info(f"Failed to validate serial number from second location: {str(e)}") + + # Try third location: 0x0445 ... 0x044C and 0x0470...0x0471 (20 digits) + try: + serial_number_part1 = ''.join([self.read_register(register, 'string', False, 1) for register in range(0x0445, 0x044C)]) + serial_number_part2 = ''.join([self.read_register(register, 'string', False, 1) for register in range(0x0470, 0x0472)]) + serial_number = serial_number_part1 + serial_number_part2 + if self.is_valid_serial_number(serial_number): + logging.info(f"Valid Serial number found at third location: {serial_number}") + return serial_number + except Exception as e: + logging.info(f"Failed to validate serial number from third location: {str(e)}") + + logging.error("Failed to determine serial number") + return None + + def determine_model(self): + """ Determine the model of the inverter based on the serial number """ + serial_number = self.raw_data.get('serial_number') + model = None + if len(serial_number) == 14: + code = serial_number[1:3] + model_mapping = { + "A1": "SOFAR 1000...3000TL", + "A3": "SOFAR 1100...3300TL-G3", + "B1": "SOFAR 3...6KTLM", + "C1": "SOFAR 10...20KTL", + "C2": "SOFAR 10...20KTL", + "C3": "SOFAR 10...20KTL", + "C4": "SOFAR 10...20KTL", + "D1": "SOFAR 10...20KTL", + "D2": "SOFAR 10...20KTL", + "D3": "SOFAR 10...20KTL", + "D4": "SOFAR 10...20KTL", + "E1": "SOFAR ME 3000-SP", + "F1": "SOFAR 3.3...12KTL-X", + "F2": "SOFAR 3.3...12KTL-X", + "F3": "SOFAR 3.3...12KTL-X", + "F4": "SOFAR 3.3...12KTL-X", + "G1": "SOFAR 30...40KTL-G2", + "G2": "SOFAR 30...40KTL-G2", + "H1": "SOFAR 3...6KTLM-G2", + "H3": "SOFAR 3...6KTLM-G3", + "H4": "SOFAR 7.5KTLM-G3", + "I1": "SOFAR 50...70KTL", + "J1": "SOFAR 50...70KTL-G2", + "J2": "SOFAR 50...70KTL-G2", + "J3": "SOFAR 50...70KTL-G2", + "K1": "SOFAR 7.5KTLM", + "L1": "SOFAR 20...33KTL-G2", + "M1": "SOFAR HYD 3000...6000-ES", + "M2": "SOFAR HYD 3000...6000-EP", + "N1": "SOFAR 10...15KTL-G2", + "P1": "SOFAR HYD 5...20KTL-3PH", + "P2": "SOFAR HYD 5...20KTL-3PH", + "Q1": "SOFAR 75...136KTL", + "R1": "SOFAR 255KTL-HV", + "S1": "SOFAR 15...24KTLX-G3", + "S2": "SOFAR 3.3...12KTLX-G3", + "S3": "SOFAR 25...50KTLX-G3", + "S4": "SOFAR 60...80KTLX-G3", + "T1": "SOFAR 7...10.5KTLM-G3", + "U1": "SOFAR ME 5...20KTL-3PH", + "U2": "SOFAR ME 5...20KTL-3PH", + "U3": "SOFAR ME 5...20KTL-3PH" + } + model = model_mapping.get(code) + elif len(serial_number) == 20: + code = serial_number[2:6] + model_mapping = { + "1012": "SOFAR 1100...3300TL-G3", + "1005": "SOFAR ME 3000-SP", + "1012": "SOFAR 3..6KTLM-G3", + "1018": "SOFAR 7.5KTLM-G3", + "1005": "SOFAR HYD 3000..6000-EP", + "1033": "SOFAR HYD 5..20KTL-3PH", + "1017": "SOFAR 255KTL-HV", + "1016": "SOFAR 3.3..24KTLX-G3", + "1021": "SOFAR 60...80KTLX-G3", + "1036": "SOFAR 100...125KTLX-G4", + "1018": "SOFAR 7...10.5KTLM-G3", + "1019": "SOFAR ME 5...20KTL-3PH", + "1025": "SOFAR ESI 2.5...5.0K" + } + model = model_mapping.get(code) + + if model: + logging.info(f"Model determined: {model}") + else: + logging.error("Failed to determine model from serial number") + return model + + def determine_modbus_protocol(self): + """ Determine the Modbus protocol based on the model """ + + protocol_mapping = { + "SOFAR 1000...3000TL": "SOFAR-1-40KTL.json", + "SOFAR 1100...3300TL-G3": "SOFAR-1-40KTL.json", + "SOFAR 3...6KTLM": "SOFAR-1-40KTL.json", + "SOFAR 10...20KTL": "SOFAR-1-40KTL.json", + "SOFAR ME 3000-SP": "SOFAR-HYD-ES-AND-ME3000-SP.json", + "SOFAR 3.3...12KTL-X": "SOFAR-1-40KTL.json", + "SOFAR 30...40KTL-G2": "SOFAR-1-40KTL.json", + "SOFAR 3...6KTLM-G2": "SOFAR-1-40KTL.json", + "SOFAR 7.5KTLM": "SOFAR-1-40KTL.json", + "SOFAR 20...33KTL-G2": "SOFAR-1-40KTL.json", + "SOFAR 10...15KTL-G2": "SOFAR-1-40KTL.json", + "SOFAR HYD 3000...6000-ES": "SOFAR-HYD-ES-AND-ME3000-SP.json", + "SOFAR HYD 3000...6000-EP": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR HYD 5...20KTL-3PH": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 75...136KTL": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 255KTL-HV": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 15...24KTLX-G3": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 3.3...12KTLX-G3": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 25...50KTLX-G3": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 60...80KTLX-G3": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR 7...10.5KTLM-G3": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR ME 5...20KTL-3PH": "SOFAR-HYD-3PH-AND-G3.json", + "SOFAR ESI 2.5...5.0K": "SOFAR-HYD-3PH-AND-G3.json" + } + + modbus_protocol = protocol_mapping.get(self.raw_data.get('model')) + if modbus_protocol: + logging.info(f"Modbus protocol determined: {modbus_protocol}") + else: + logging.error("Failed to determine Modbus protocol from model") + return modbus_protocol + + def convert_value(self, register, value): + """ + Convert value based on register modes. + + Examples: + - If the register has modes defined as: + { + "0": "Default", + "1": "Pylon", + "2": "General" + } + and the value is "Pylon", it will return "1". + - If the value does not match any mode, it will return the original value. + + Args: + register_name (str): The name of the register. + value (str): The value to be converted. + + Returns: + str: The converted value or the original value if no conversion is needed. + """ + if register and 'modes' in register: + return next((k for k, v in register['modes'].items() if v == value), value) + return value + + def write_register_block(self, block_name, update_register, new_value): + """ Write a specific register block from configuration to Modbus """ + block = next((b for b in self.config.get('write_register_blocks', []) if b['name'] == block_name), None) + if not block: + logging.error(f"Block {block_name} not found in configuration") + return + + start_register = int(block['start_register'], 16) + required_length = int(block['length']) + values = [] + raw_value = None + for register_name in block['registers']: + if register_name == update_register: + register = self.get_register(register_name) + if register: + new_raw_value = self.translate_to_raw_value(register, new_value) + if not validate_new_value(register, new_raw_value): + return + raw_value = new_raw_value + else: + logging.error(f"Register {register_name} not found in configuration") + continue + else: + raw_value = self.raw_data.get(register_name) + if raw_value is None: + logging.error(f"Value for {register_name} not found in raw data. Skipping block {block['name']}") + return + values.append(raw_value) + if 'append' in block: + for append_item in block['append']: + values.append(append_item) + + register = self.get_register(update_register) + new_raw_value = self.translate_to_raw_value(register, new_value) + + retry = self.write_retry + 10 + while retry > 0: + current_raw_value = self.raw_data[update_register] + current_value = self.translate_from_raw_value(register, current_raw_value) + if current_raw_value == new_raw_value: + logging.info(f"Current value for {register['name']}: {current_raw_value} ({current_value}). Matches desired value: {new_raw_value} ({new_value}).") + retry = 0 + else: + if len(values) < required_length: + logging.error(f"Length of values in block is less than required length for block {block['name']}. Skipping write operation. Values: {values}") + return + logging.info(f"Current value for {register['name']}: {current_raw_value} ({current_value}), attempting to set it to: {new_raw_value} ({new_value}). Retries remaining: {retry}") + logging.info(f"Would write {block['start_register']} with {values[:required_length]}") + self.write_registers_with_retry(block['start_register'], values[:required_length]) + #logging.info(f"Reference values: {[0, 0, 1, 560, 540, 425, 470, 10000, 10000, 90, 90, 250, 480, 1, 10, 1]}") + # [0, 0, 1, 540, 530, 425, 470, 10000, 10000, 89, 90, 250, 480, 1, 10, 1] + #self.write_registers_with_retry(block['start_register'], [0, 0, 1, 560, 540, 425, 470, 10000, 10000, 90, 90, 250, 480, 1, 10, 1]) + time.sleep(self.write_retry_delay + 5) + retry = retry - 1 + + def get_register(self, register_name): + """ Look up a register from self.config['registers'] """ + register = next((r for r in self.config['registers'] if r['name'] == register_name), None) + if register is None: + logging.error(f"Register {register_name} not found in configuration") + return register + + def translate_from_raw_value(self, register, raw_value): + """ Translate raw value to a normalized value using the function and factor """ + if 'function' in register: + if register['function'] == 'multiply': + return raw_value * register['factor'] + elif register['function'] == 'divide': + return raw_value / register['factor'] + elif register['function'] == 'mode': + return register['modes'].get(str(raw_value), raw_value) + elif register['function'] == 'bit_field': + length = len(register['fields']) + fields = [] + for n in reversed(range(length)): + if raw_value & (1 << ((length-1)-n)): + fields.append(register['fields'][n]) + return ','.join(fields) + elif register['function'] == 'high_bit_low_bit': + high = raw_value >> 8 # shift right + low = raw_value & 255 # apply bitmask + return f"{high:02}{register['join']}{low:02}" # combine and pad 2 zeros + return raw_value + + def translate_to_raw_value(self, register, value): + """ Undo the operation performed by translate_from_raw_value """ + if 'function' in register: + if register['function'] == 'int': + return int(value) + if register['function'] == 'multiply': + return int(float(value) / register['factor']) + elif register['function'] == 'divide': + return int(float(value) * register['factor']) + elif register['function'] == 'mode': + return int(next((k for k, v in register['modes'].items() if v == value), value)) + elif register['function'] == 'bit_field': + fields = value.split(',') + raw_value = 0 + for field in fields: + if field in register['fields']: + raw_value |= (1 << (len(register['fields']) - 1 - register['fields'].index(field))) + return raw_value + elif register['function'] == 'high_bit_low_bit': + high, low = map(int, value.split(register['join'])) + return (high << 8) | low + return int(value) + +def validate_new_value(register, new_value): + """ Validate the new value based on the register's min, max, and modes """ + if 'min' in register and new_value < register['min']: + logging.error(f"Value {new_value} is less than the minimum allowed value {register['min']} for register {register['name']}") + return False + if 'max' in register and new_value > register['max']: + logging.error(f"Value {new_value} is greater than the maximum allowed value {register['max']} for register {register['name']}") + return False + if 'function' in register and register['function'] == 'mode' and str(new_value) not in register['modes']: + logging.error(f"Value {new_value} is not a valid mode for register {register['name']}") + return False + return True + @click.command("cli", context_settings={'show_default': True}) -@click.option( - '--config-file', - envvar='CONFIG_FILE', - default='sofar-hyd-ep.json', - help='Configuration file to use', -) @click.option( '--daemon', envvar='DAEMON', @@ -515,7 +863,7 @@ def read_value(self, registeraddress, read_type, signed, registers=1): '--write-retry-delay', envvar='WRITE_RETRY_DELAY', default=5, - type=float, + type=int, help='Delay before retrying write', ) @click.option( @@ -550,6 +898,12 @@ def read_value(self, registeraddress, read_type, signed, registers=1): default=None, help='MQTT password' ) +@click.option( + '--ca-certs', + envvar='MQTT_CA_CERTS', + default=None, + help='MQTT CA Certs path' +) @click.option( '--topic', envvar='MQTT_TOPIC', @@ -582,9 +936,9 @@ def read_value(self, registeraddress, read_type, signed, registers=1): help='Publish each register to MQTT individually in addition to state which contains all values', ) # pylint: disable=too-many-arguments -def main(config_file, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, topic, write_topic, log_level, device, legacy_publish): +def main(daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, ca_certs, topic, write_topic, log_level, device, legacy_publish): """Main""" - sofar = Sofar(config_file, daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, topic, write_topic, log_level, device, legacy_publish) + sofar = Sofar(daemon, retry, retry_delay, write_retry, write_retry_delay, refresh_interval, broker, port, username, password, ca_certs, topic, write_topic, log_level, device, legacy_publish) sofar.main() # pylint: disable=no-value-for-parameter