From fadea0a48333f96b2e8139775957d9ba77d1ebc4 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 20 Jun 2024 10:19:05 +0200 Subject: [PATCH] update NodeRed folder --- NodeRed/NodeRedFiles/dbus-fzsonick-48tl.py | 1044 ---- NodeRed/NodeRedFiles/flows.json | 7 +- NodeRed/dvcc.py | 1287 ----- NodeRed/flows.json | 5660 -------------------- NodeRed/rc.local | 26 - NodeRed/settings-user.js | 31 - 6 files changed, 3 insertions(+), 8052 deletions(-) delete mode 100755 NodeRed/NodeRedFiles/dbus-fzsonick-48tl.py delete mode 100644 NodeRed/dvcc.py delete mode 100644 NodeRed/flows.json delete mode 100755 NodeRed/rc.local delete mode 100644 NodeRed/settings-user.js diff --git a/NodeRed/NodeRedFiles/dbus-fzsonick-48tl.py b/NodeRed/NodeRedFiles/dbus-fzsonick-48tl.py deleted file mode 100755 index f7ff10556..000000000 --- a/NodeRed/NodeRedFiles/dbus-fzsonick-48tl.py +++ /dev/null @@ -1,1044 +0,0 @@ -#!/usr/bin/python3 -u -# coding=utf-8 - -import re -import sys -import logging -from gi.repository import GLib - -import config as cfg -import convert as c - -from pymodbus.register_read_message import ReadInputRegistersResponse -from pymodbus.client.sync import ModbusSerialClient as Modbus -from pymodbus.other_message import ReportSlaveIdRequest -from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse - -from dbus.mainloop.glib import DBusGMainLoop -from data import BatteryStatus, Signal, Battery, LedColor, CsvSignal, LedState - -from collections import Iterable -from os import path - -app_dir = path.dirname(path.realpath(__file__)) -sys.path.insert(1, path.join(app_dir, 'ext', 'velib_python')) - -from vedbus import VeDbusService as DBus - -import time -import os -import csv - -import requests -import hmac -import hashlib -import base64 -from datetime import datetime -import io -import json - -import requests -import hmac -import hashlib -import base64 -from datetime import datetime -import pika -import time - - -# zip-comp additions -import zipfile -import io - -def compress_csv_data(csv_data, file_name="data.csv"): - - memory_stream = io.BytesIO() - - # Create a zip archive in the memory buffer - with zipfile.ZipFile(memory_stream, 'w', zipfile.ZIP_DEFLATED) as archive: - # Add CSV data to the ZIP archive - with archive.open('data.csv', 'w') as entry_stream: - entry_stream.write(csv_data.encode('utf-8')) - - # Get the compressed byte array from the memory buffer - compressed_bytes = memory_stream.getvalue() - - # Encode the compressed byte array as a Base64 string - base64_string = base64.b64encode(compressed_bytes).decode('utf-8') - - return base64_string - -class S3config: - def __init__(self): - self.bucket = cfg.S3BUCKET - self.region = "sos-ch-dk-2" - self.provider = "exo.io" - self.key = cfg.S3KEY - self.secret = cfg.S3SECRET - self.content_type = "application/base64; charset=utf-8" - - @property - def host(self): - return f"{self.bucket}.{self.region}.{self.provider}" - - @property - def url(self): - return f"https://{self.host}" - - def create_put_request(self, s3_path, data): - headers = self._create_request("PUT", s3_path) - url = f"{self.url}/{s3_path}" - response = requests.put(url, headers=headers, data=data) - return response - - def _create_request(self, method, s3_path): - date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') - auth = self._create_authorization(method, self.bucket, s3_path, date, self.key, self.secret, self.content_type) - headers = { - "Host": self.host, - "Date": date, - "Authorization": auth, - "Content-Type": self.content_type - } - return headers - - @staticmethod - def _create_authorization(method, bucket, s3_path, date, s3_key, s3_secret, content_type="", md5_hash=""): - payload = f"{method}\n{md5_hash}\n{content_type}\n{date}\n/{bucket.strip('/')}/{s3_path.strip('/')}" - signature = base64.b64encode( - hmac.new(s3_secret.encode(), payload.encode(), hashlib.sha1).digest() - ).decode() - return f"AWS {s3_key}:{signature}" - -def read_csv_as_string(file_path): - """ - Reads a CSV file from the given path and returns its content as a single string. - """ - try: - with open(file_path, 'r', encoding='utf-8') as file: - return file.read() - except FileNotFoundError: - print(f"Error: The file {file_path} does not exist.") - return None - except IOError as e: - print(f"IO error occurred: {str(e)}") - return None - -CSV_DIR = "/data/csv_files/" -#CSV_DIR = "csv_files/" - -# Define the path to the file containing the installation name -INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name' - -# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime -# noinspection PyUnreachableCode -if False: - from typing import Callable - -def interpret_limb_bitmap(bitmap_value): - # The bit for string 1 also monitors all 5 strings: 0000 0000 means All 5 strings activated. 0000 0001 means string 1 disabled. - string1_disabled = int((bitmap_value & 0b00001) != 0) - string2_disabled = int((bitmap_value & 0b00010) != 0) - string3_disabled = int((bitmap_value & 0b00100) != 0) - string4_disabled = int((bitmap_value & 0b01000) != 0) - string5_disabled = int((bitmap_value & 0b10000) != 0) - - n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled - - return n_limb_strings - -def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int): - # type: (float, float, float, float) -> float - dv = v_limit - v - di = dv / r_int - p_limit = v_limit * (i + di) - return p_limit - -def calc_power_limit_imposed_by_current_limit(v, i, i_limit, r_int): - # type: (float, float, float, float) -> float - di = i_limit - i - dv = di * r_int - p_limit = i_limit * (v + dv) - return p_limit - -def read_switch_closed(status): - value = c.read_bool(register=1013, bit=0)(status) - if value: - return False - return True - -def read_alarm_out_active(status): - value = c.read_bool(register=1013, bit=1)(status) - if value: - return False - return True - -def read_aux_relay(status): - value = c.read_bool(register=1013, bit=4)(status) - if value: - return False - return True - -def hex_string_to_ascii(hex_string): - # Ensure the hex_string is correctly formatted without spaces - hex_string = hex_string.replace(" ", "") - # Convert every two characters (a byte) in the hex string to ASCII - ascii_string = ''.join([chr(int(hex_string[i:i+2], 16)) for i in range(0, len(hex_string), 2)]) - return ascii_string - -battery_status_reader = c.read_hex_string(1060,2) - -def read_eoc_reached(status): - battery_status_string = battery_status_reader(status) - return hex_string_to_ascii(battery_status_string) == "EOC_" - -def return_led_state(status, color): - led_state = c.read_led_state(register=1004, led=color)(status) - if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow: - return "Blinking" - elif led_state == LedState.on: - return "On" - elif led_state == LedState.off: - return "Off" - return "Unknown" - -def return_led_state_blue(status): - return return_led_state(status, LedColor.blue) - -def return_led_state_red(status): - return return_led_state(status, LedColor.red) - -def return_led_state_green(status): - return return_led_state(status, LedColor.green) - -def return_led_state_amber(status): - return return_led_state(status, LedColor.amber) - -def read_serial_number(status): - serial_regs = [1055, 1056, 1057, 1058] - serial_parts = [] - for reg in serial_regs: - # reading each register as a single hex value - hex_value_fun = c.read_hex_string(reg, 1) - hex_value = hex_value_fun(status) - # append without spaces and leading zeros stripped if any - serial_parts.append(hex_value.replace(' ', '')) - # concatenate all parts to form the full serial number - serial_number = ''.join(serial_parts).rstrip('0') - return serial_number - -def time_since_toc_in_time_format(status): - time_in_minutes = c.read_float(register=1052)(status) - # Convert minutes to total seconds - total_seconds = int(time_in_minutes * 60) - # Calculate days, hours, minutes, and seconds - days = total_seconds // (24 * 3600) - total_seconds = total_seconds % (24 * 3600) - hours = total_seconds // 3600 - total_seconds %= 3600 - minutes = total_seconds // 60 - seconds = total_seconds % 60 - # Format the string to show days.hours:minutes:seconds - return f"{days}.{hours:02}:{minutes:02}:{seconds:02}" - -def create_csv_signals(firmware_version): - read_voltage = c.read_float(register=999, scale_factor=0.01, offset=0, places=2) - read_current = c.read_float(register=1000, scale_factor=0.01, offset=-10000, places=2) - read_limb_bitmap = c.read_bitmap(1059) - - def read_power(status): - return int(read_current(status) * read_voltage(status)) - - def string1_disabled(status): - bitmap_value = read_limb_bitmap(status) - return int((bitmap_value & 0b00001) != 0) - - def string2_disabled(status): - bitmap_value = read_limb_bitmap(status) - return int((bitmap_value & 0b00010) != 0) - - def string3_disabled(status): - bitmap_value = read_limb_bitmap(status) - return int((bitmap_value & 0b00100) != 0) - - def string4_disabled(status): - bitmap_value = read_limb_bitmap(status) - return int((bitmap_value & 0b01000) != 0) - - def string5_disabled(status): - bitmap_value = read_limb_bitmap(status) - return int((bitmap_value & 0b10000) != 0) - - def limp_strings_value(status): - return interpret_limb_bitmap(read_limb_bitmap(status)) - - def calc_max_charge_power(status): - # type: (BatteryStatus) -> int - n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status) - i_max = n_strings * cfg.I_MAX_PER_STRING - v_max = cfg.V_MAX - r_int_min = cfg.R_STRING_MIN / n_strings - r_int_max = cfg.R_STRING_MAX / n_strings - - v = read_voltage(status) - i = read_current(status) - - p_limits = [ - calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_min), - calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_max), - calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_min), - calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_max), - ] - - p_limit = min(p_limits) # p_limit is normally positive here (signed) - p_limit = max(p_limit, 0) # charge power must not become negative - - return int(p_limit) - - def calc_max_discharge_power(status): - n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status) - max_discharge_current = n_strings*cfg.I_MAX_PER_STRING - return int(max_discharge_current*read_voltage(status)) - - total_current = c.read_float(register=1062, scale_factor=0.01, offset=-10000, places=1) - - def read_total_current(status): - return total_current(status) - - def read_heating_current(status): - return total_current(status) - read_current(status) - - def read_heating_power(status): - return read_voltage(status) * read_heating_current(status) - - soc_ah = c.read_float(register=1002, scale_factor=0.1, offset=-10000, places=1) - - def read_soc_ah(status): - return soc_ah(status) - - return [ - CsvSignal('/Battery/Devices/FwVersion', firmware_version), - CsvSignal('/Battery/Devices/Dc/Power', read_power, 'W'), - CsvSignal('/Battery/Devices/Dc/Voltage', read_voltage, 'V'), - CsvSignal('/Battery/Devices/Soc', c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), '%'), - CsvSignal('/Battery/Devices/Temperatures/Cells/Average', c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), 'C'), - CsvSignal('/Battery/Devices/Dc/Current', read_current, 'A'), - CsvSignal('/Battery/Devices/BusCurrent', read_total_current, 'A'), - CsvSignal('/Battery/Devices/CellsCurrent', read_current, 'A'), - CsvSignal('/Battery/Devices/HeatingCurrent', read_heating_current, 'A'), - CsvSignal('/Battery/Devices/HeatingPower', read_heating_power, 'W'), - CsvSignal('/Battery/Devices/SOCAh', read_soc_ah), - CsvSignal('/Battery/Devices/Leds/Blue', return_led_state_blue), - CsvSignal('/Battery/Devices/Leds/Red', return_led_state_red), - CsvSignal('/Battery/Devices/Leds/Green', return_led_state_green), - CsvSignal('/Battery/Devices/Leds/Amber', return_led_state_amber), - CsvSignal('/Battery/Devices/BatteryStrings/String1Active', string1_disabled), - CsvSignal('/Battery/Devices/BatteryStrings/String2Active', string2_disabled), - CsvSignal('/Battery/Devices/BatteryStrings/String3Active', string3_disabled), - CsvSignal('/Battery/Devices/BatteryStrings/String4Active', string4_disabled), - CsvSignal('/Battery/Devices/BatteryStrings/String5Active', string5_disabled), - CsvSignal('/Battery/Devices/IoStatus/ConnectedToDcBus', read_switch_closed), - CsvSignal('/Battery/Devices/IoStatus/AlarmOutActive', read_alarm_out_active), - CsvSignal('/Battery/Devices/IoStatus/InternalFanActive', c.read_bool(register=1013, bit=2)), - CsvSignal('/Battery/Devices/IoStatus/VoltMeasurementAllowed', c.read_bool(register=1013, bit=3)), - CsvSignal('/Battery/Devices/IoStatus/AuxRelayBus', read_aux_relay), - CsvSignal('/Battery/Devices/IoStatus/RemoteStateActive', c.read_bool(register=1013, bit=5)), - CsvSignal('/Battery/Devices/IoStatus/RiscActive', c.read_bool(register=1013, bit=6)), - CsvSignal('/Battery/Devices/Eoc', read_eoc_reached), - CsvSignal('/Battery/Devices/SerialNumber', read_serial_number), - CsvSignal('/Battery/Devices/TimeSinceTOC', time_since_toc_in_time_format), - CsvSignal('/Battery/Devices/MaxChargePower', calc_max_charge_power), - CsvSignal('/Battery/Devices/MaxDischargePower', calc_max_discharge_power), - ] - -def init_signals(hardware_version, firmware_version, n_batteries): - # type: (str,str,int) -> Iterable[Signal] - """ - A Signal holds all information necessary for the handling of a - certain datum (e.g. voltage) published by the battery. - - Signal(dbus_path, aggregate, get_value, get_text = str) - - dbus_path: str - object_path on DBus where the datum needs to be published - - aggregate: Iterable[object] -> object - function that combines the values of multiple batteries into one. - e.g. sum for currents, or mean for voltages - - get_value: (BatteryStatus) -> object [optional] - function to extract the datum from the modbus record, - alternatively: a constant - - get_text: (object) -> unicode [optional] - function to render datum to text, needed by DBus - alternatively: a constant - - The conversion functions use the same parameters (e.g scale_factor, offset) - as described in the document 'T48TLxxx ModBus Protocol Rev.7.1' which can - be found in the /doc folder - """ - - product_id_hex = '0x{0:04x}'.format(cfg.PRODUCT_ID) - - read_voltage = c.read_float(register=999, scale_factor=0.01, offset=0, places=2) - read_current = c.read_float(register=1000, scale_factor=0.01, offset=-10000, places=2) - read_limb_bitmap = c.read_bitmap(1059) - - def read_power(status): - return int(read_current(status) * read_voltage(status)) - - def limp_strings_value(status): - return interpret_limb_bitmap(read_limb_bitmap(status)) - - def max_discharge_current(status): - return (cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status))*cfg.I_MAX_PER_STRING - - def max_charge_current(status): - return status.battery.ampere_hours/2 - - def calc_max_charge_power(status): - # type: (BatteryStatus) -> int - n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status) - i_max = n_strings * cfg.I_MAX_PER_STRING - v_max = cfg.V_MAX - r_int_min = cfg.R_STRING_MIN / n_strings - r_int_max = cfg.R_STRING_MAX / n_strings - - v = read_voltage(status) - i = read_current(status) - - p_limits = [ - calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_min), - calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_max), - calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_min), - calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_max), - ] - - p_limit = min(p_limits) # p_limit is normally positive here (signed) - p_limit = max(p_limit, 0) # charge power must not become negative - - return int(p_limit) - - product_name = cfg.PRODUCT_NAME - if n_batteries > 1: - product_name = cfg.PRODUCT_NAME + ' x' + str(n_batteries) - - return [ - # Node Red related dbus paths - Signal('/TimeToTOCRequest', max, c.read_float(register=1052)), - Signal('/EOCReached', c.return_in_list, read_eoc_reached), - Signal('/NumOfLimbStrings', c.return_in_list, get_value=limp_strings_value), - Signal('/NumOfBatteries', max, get_value=n_batteries), - Signal('/Dc/0/Voltage', c.mean, get_value=read_voltage, get_text=c.append_unit('V')), - Signal('/Dc/0/Current', c.ssum, get_value=read_current, get_text=c.append_unit('A')), - Signal('/Dc/0/Power', c.ssum, get_value=read_power, get_text=c.append_unit('W')), - Signal('/BussVoltage', c.mean, c.read_float(register=1001, scale_factor=0.01, offset=0, places=2), c.append_unit('V')), - Signal('/Soc', min, c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), c.append_unit('%')), - Signal('/LowestSoc', min, c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), c.append_unit('%')), - Signal('/Dc/0/Temperature', c.mean, c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), c.append_unit(u'°C')), - Signal('/Dc/0/LowestTemperature', min, c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), c.append_unit(u'°C')), - # Charge/Discharge current, voltage and power - Signal('/Info/MaxDischargeCurrent', c.ssum, max_discharge_current,c.append_unit('A')), - Signal('/Info/MaxChargeCurrent', c.ssum, max_charge_current, c.append_unit('A')), - Signal('/Info/MaxChargeVoltage', min, cfg.MAX_CHARGE_VOLTAGE, c.append_unit('V')), - Signal('/Info/MaxChargePower', c.ssum, calc_max_charge_power), - # Victron mandatory dbus paths - Signal('/Mgmt/ProcessName', c.first, __file__), - Signal('/Mgmt/ProcessVersion', c.first, cfg.SOFTWARE_VERSION), - Signal('/Mgmt/Connection', c.first, cfg.CONNECTION), - Signal('/DeviceInstance', c.first, cfg.DEVICE_INSTANCE), - Signal('/ProductName', c.first, product_name), - Signal('/ProductId', c.first, cfg.PRODUCT_ID, product_id_hex), - Signal('/Connected', c.first, 1), - Signal('/FirmwareVersion', c.return_in_list, firmware_version), - Signal('/HardwareVersion', c.first, cfg.HARDWARE_VERSION, hardware_version), - # Diagnostics - Signal('/Diagnostics/BmsVersion', c.first, lambda s: s.battery.bms_version), - # Warnings - Signal('/WarningFlags/TaM1', c.return_in_list, c.read_bool(register=1005, bit=1)), - Signal('/WarningFlags/TbM1', c.return_in_list, c.read_bool(register=1005, bit=4)), - Signal('/WarningFlags/VBm1', c.return_in_list, c.read_bool(register=1005, bit=6)), - Signal('/WarningFlags/VBM1', c.return_in_list, c.read_bool(register=1005, bit=8)), - Signal('/WarningFlags/IDM1', c.return_in_list, c.read_bool(register=1005, bit=10)), - Signal('/WarningFlags/vsm1', c.return_in_list, c.read_bool(register=1005, bit=22)), - Signal('/WarningFlags/vsM1', c.return_in_list, c.read_bool(register=1005, bit=24)), - Signal('/WarningFlags/iCM1', c.return_in_list, c.read_bool(register=1005, bit=26)), - Signal('/WarningFlags/iDM1', c.return_in_list, c.read_bool(register=1005, bit=28)), - Signal('/WarningFlags/MID1', c.return_in_list, c.read_bool(register=1005, bit=30)), - Signal('/WarningFlags/BLPW', c.return_in_list, c.read_bool(register=1005, bit=32)), - Signal('/WarningFlags/CCBF', c.return_in_list, c.read_bool(register=1005, bit=33)), - Signal('/WarningFlags/Ah_W', c.return_in_list, c.read_bool(register=1005, bit=35)), - Signal('/WarningFlags/MPMM', c.return_in_list, c.read_bool(register=1005, bit=38)), - Signal('/WarningFlags/TCdi', c.return_in_list, c.read_bool(register=1005, bit=40)), - Signal('/WarningFlags/LMPW', c.return_in_list, c.read_bool(register=1005, bit=44)), - Signal('/WarningFlags/TOCW', c.return_in_list, c.read_bool(register=1005, bit=47)), - Signal('/WarningFlags/BUSL', c.return_in_list, c.read_bool(register=1005, bit=49)), - # Alarms - Signal('/AlarmFlags/Tam', c.return_in_list, c.read_bool(register=1005, bit=0)), - Signal('/AlarmFlags/TaM2', c.return_in_list, c.read_bool(register=1005, bit=2)), - Signal('/AlarmFlags/Tbm', c.return_in_list, c.read_bool(register=1005, bit=3)), - Signal('/AlarmFlags/TbM2', c.return_in_list, c.read_bool(register=1005, bit=5)), - Signal('/AlarmFlags/VBm2', c.return_in_list, c.read_bool(register=1005, bit=7)), - Signal('/AlarmFlags/VBM2', c.return_in_list, c.read_bool(register=1005, bit=9)), - Signal('/AlarmFlags/IDM2', c.return_in_list, c.read_bool(register=1005, bit=11)), - Signal('/AlarmFlags/ISOB', c.return_in_list, c.read_bool(register=1005, bit=12)), - Signal('/AlarmFlags/MSWE', c.return_in_list, c.read_bool(register=1005, bit=13)), - Signal('/AlarmFlags/FUSE', c.return_in_list, c.read_bool(register=1005, bit=14)), - Signal('/AlarmFlags/HTRE', c.return_in_list, c.read_bool(register=1005, bit=15)), - Signal('/AlarmFlags/TCPE', c.return_in_list, c.read_bool(register=1005, bit=16)), - Signal('/AlarmFlags/STRE', c.return_in_list, c.read_bool(register=1005, bit=17)), - Signal('/AlarmFlags/CME', c.return_in_list, c.read_bool(register=1005, bit=18)), - Signal('/AlarmFlags/HWFL', c.return_in_list, c.read_bool(register=1005, bit=19)), - Signal('/AlarmFlags/HWEM', c.return_in_list, c.read_bool(register=1005, bit=20)), - Signal('/AlarmFlags/ThM', c.return_in_list, c.read_bool(register=1005, bit=21)), - Signal('/AlarmFlags/vsm2', c.return_in_list, c.read_bool(register=1005, bit=23)), - Signal('/AlarmFlags/vsM2', c.return_in_list, c.read_bool(register=1005, bit=25)), - Signal('/AlarmFlags/iCM2', c.return_in_list, c.read_bool(register=1005, bit=27)), - Signal('/AlarmFlags/iDM2', c.return_in_list, c.read_bool(register=1005, bit=29)), - Signal('/AlarmFlags/MID2', c.return_in_list, c.read_bool(register=1005, bit=31)), - Signal('/AlarmFlags/HTFS', c.return_in_list, c.read_bool(register=1005, bit=42)), - Signal('/AlarmFlags/DATA', c.return_in_list, c.read_bool(register=1005, bit=43)), - Signal('/AlarmFlags/LMPA', c.return_in_list, c.read_bool(register=1005, bit=45)), - Signal('/AlarmFlags/HEBT', c.return_in_list, c.read_bool(register=1005, bit=46)), - Signal('/AlarmFlags/CURM', c.return_in_list, c.read_bool(register=1005, bit=48)), - # LedStatus - Signal('/Diagnostics/LedStatus/Red', c.first, c.read_led_state(register=1004, led=LedColor.red)), - Signal('/Diagnostics/LedStatus/Blue', c.first, c.read_led_state(register=1004, led=LedColor.blue)), - Signal('/Diagnostics/LedStatus/Green', c.first, c.read_led_state(register=1004, led=LedColor.green)), - Signal('/Diagnostics/LedStatus/Amber', c.first, c.read_led_state(register=1004, led=LedColor.amber)), - # IO Status - Signal('/Diagnostics/IoStatus/MainSwitchClosed', c.return_in_list, read_switch_closed), - Signal('/Diagnostics/IoStatus/AlarmOutActive', c.return_in_list, read_alarm_out_active), - Signal('/Diagnostics/IoStatus/InternalFanActive', c.return_in_list, c.read_bool(register=1013, bit=2)), - Signal('/Diagnostics/IoStatus/VoltMeasurementAllowed', c.return_in_list, c.read_bool(register=1013, bit=3)), - Signal('/Diagnostics/IoStatus/AuxRelay', c.return_in_list, read_aux_relay), - Signal('/Diagnostics/IoStatus/RemoteState', c.return_in_list, c.read_bool(register=1013, bit=5)), - Signal('/Diagnostics/IoStatus/RiscOn', c.return_in_list, c.read_bool(register=1013, bit=6)), - ] - -def init_modbus(tty): - # type: (str) -> Modbus - logging.debug('initializing Modbus') - return Modbus( - port='/dev/' + tty, - method=cfg.MODE, - baudrate=cfg.BAUD_RATE, - stopbits=cfg.STOP_BITS, - bytesize=cfg.BYTE_SIZE, - timeout=cfg.TIMEOUT, - parity=cfg.PARITY) - -def init_dbus(tty, signals): - # type: (str, Iterable[Signal]) -> DBus - logging.debug('initializing DBus service') - dbus = DBus(servicename=cfg.SERVICE_NAME_PREFIX + tty) - logging.debug('initializing DBus paths') - for signal in signals: - init_dbus_path(dbus, signal) - return dbus - -# noinspection PyBroadException -def try_get_value(sig): - # type: (Signal) -> object - try: - return sig.get_value(None) - except: - return None - -def init_dbus_path(dbus, sig): - # type: (DBus, Signal) -> () - dbus.add_path( - sig.dbus_path, - try_get_value(sig), - gettextcallback=lambda _, v: sig.get_text(v)) - -def init_main_loop(): - # type: () -> DBusGMainLoop - logging.debug('initializing DBusGMainLoop Loop') - DBusGMainLoop(set_as_default=True) - return GLib.MainLoop() - -def report_slave_id(modbus, slave_address): - # type: (Modbus, int) -> str - slave = str(slave_address) - logging.debug('requesting slave id from node ' + slave) - try: - modbus.connect() - request = ReportSlaveIdRequest(unit=slave_address) - response = modbus.execute(request) - if response is ExceptionResponse or issubclass(type(response), ModbusException): - raise Exception('failed to get slave id from ' + slave + ' : ' + str(response)) - return response.identifier - finally: - modbus.close() - -def identify_battery(modbus, slave_address): - # type: (Modbus, int) -> Battery - logging.info('identifying battery...') - hardware_version, bms_version, ampere_hours = parse_slave_id(modbus, slave_address) - firmware_version = read_firmware_version(modbus, slave_address) - specs = Battery( - slave_address=slave_address, - hardware_version=hardware_version, - firmware_version=firmware_version, - bms_version=bms_version, - ampere_hours=ampere_hours) - logging.info('battery identified:\n{0}'.format(str(specs))) - return specs - -def identify_batteries(modbus): - # type: (Modbus) -> list[Battery] - def _identify_batteries(): - address_range = range(1, cfg.MAX_SLAVE_ADDRESS + 1) - for slave_address in address_range: - try: - yield identify_battery(modbus, slave_address) - except Exception as e: - logging.info('failed to identify battery at {0} : {1}'.format(str(slave_address), str(e))) - return list(_identify_batteries()) # force that lazy iterable! - -def parse_slave_id(modbus, slave_address): - # type: (Modbus, int) -> (str, str, int) - slave_id = report_slave_id(modbus, slave_address) - sid = re.sub(b'[^\x20-\x7E]', b'', slave_id) # remove weird special chars - match = re.match('(?P48TL(?P\d+)) *(?P.*)', sid.decode('ascii')) - if match is None: - raise Exception('no known battery found') - return match.group('hw'), match.group('bms'), int(match.group('ah')) - -def read_firmware_version(modbus, slave_address): - # type: (Modbus, int) -> str - logging.debug('reading firmware version') - try: - modbus.connect() - response = read_modbus_registers(modbus, slave_address, base_address=1054, count=1) - register = response.registers[0] - return '{0:0>4X}'.format(register) - finally: - modbus.close() # close in any case - -def read_modbus_registers(modbus, slave_address, base_address=cfg.BASE_ADDRESS, count=cfg.NO_OF_REGISTERS): - # type: (Modbus, int) -> ReadInputRegistersResponse - logging.debug('requesting modbus registers {0}-{1}'.format(base_address, base_address + count)) - return modbus.read_input_registers( - address=base_address, - count=count, - unit=slave_address) - -def read_battery_status(modbus, battery): - # type: (Modbus, Battery) -> BatteryStatus - """ - Read the modbus registers containing the battery's status info. - """ - logging.debug('reading battery status') - try: - modbus.connect() - data = read_modbus_registers(modbus, battery.slave_address) - return BatteryStatus(battery, data.registers) - finally: - modbus.close() # close in any case - -def publish_values(dbus, signals, statuses): - # type: (DBus, Iterable[Signal], Iterable[BatteryStatus]) -> () - for s in signals: - values = [s.get_value(status) for status in statuses] - with dbus as srv: - srv[s.dbus_path] = s.aggregate(values) - -previous_warnings = {} -previous_alarms = {} - -class MessageType: - ALARM_OR_WARNING = "AlarmOrWarning" - HEARTBEAT = "Heartbeat" - -class AlarmOrWarning: - def __init__(self, description, created_by): - self.date = datetime.now().strftime('%Y-%m-%d') - self.time = datetime.now().strftime('%H:%M:%S') - self.description = description - self.created_by = created_by - - def to_dict(self): - return { - "Date": self.date, - "Time": self.time, - "Description": self.description, - "CreatedBy": self.created_by - } - -def SubscribeToQueue(): - try: - connection = pika.BlockingConnection(pika.ConnectionParameters(host="10.2.0.11", - port=5672, - virtual_host="/", - credentials=pika.PlainCredentials("producer", "b187ceaddb54d5485063ddc1d41af66f"))) - channel = connection.channel() - channel.queue_declare(queue="statusQueue", durable=True) - print("Subscribed to queue") - except Exception as ex: - print("An error occurred while connecting to the RabbitMQ queue:", ex) - return channel - -is_first_update = True -first_subscribe = False -prev_status=0 -subscribed_to_queue_first_time=False -channel = SubscribeToQueue() -heartbit_interval = 0 -# Create an S3config instance -s3_config = S3config() -INSTALLATION_ID=int(s3_config.bucket.split('-')[0]) -PRODUCT_ID = 1 - -def update_state_from_dictionaries(current_warnings, current_alarms, node_numbers): - global previous_warnings, previous_alarms, INSTALLATION_ID, PRODUCT_ID, is_first_update, first_subscribe, channel,prev_status,heartbit_interval,subscribed_to_queue_first_time - - heartbit_interval+=1 - - if is_first_update: - changed_warnings = current_warnings - changed_alarms = current_alarms - is_first_update = False - else: - changed_alarms={} - changed_warnings={} - # calculate the diff in warnings and alarms - prev_alarm_value_list=list(previous_alarms.values()) - alarm_keys=list(previous_alarms.keys()) - - for i, alarm in enumerate(current_alarms.values()): - if alarm!=prev_alarm_value_list[i]: - changed_alarms[alarm_keys[i]]=True - else: - changed_alarms[alarm_keys[i]]=False - - prev_warning_value_list=list(previous_warnings.values()) - warning_keys=list(previous_warnings.keys()) - - for i, warning in enumerate(current_warnings.values()): - if warning!=prev_warning_value_list[i]: - changed_warnings[warning_keys[i]]=True - else: - changed_warnings[warning_keys[i]]=False - - status_message = { - "InstallationId": INSTALLATION_ID, - "Product": PRODUCT_ID, - "Status": 0, - "Type": 1, - "Warnings": [], - "Alarms": [] - } - - alarms_number_list = [] - for node_number in node_numbers: - cnt = 0 - for i, alarm_value in enumerate(current_alarms.values()): - if int(list(current_alarms.keys())[i].split("/")[3]) == int(node_number): - if alarm_value: - cnt+=1 - alarms_number_list.append(cnt) - - - warnings_number_list = [] - for node_number in node_numbers: - cnt = 0 - for i, warning_value in enumerate(current_warnings.values()): - if int(list(current_warnings.keys())[i].split("/")[3]) == int(node_number): - if warning_value: - cnt+=1 - warnings_number_list.append(cnt) - - # Evaluate alarms - if any(changed_alarms.values()): - for i, changed_alarm in enumerate(changed_alarms.values()): - if changed_alarm and list(current_alarms.values())[i]: - description = list(current_alarms.keys())[i].split("/")[-1] - device_created = "Battery node " + list(current_alarms.keys())[i].split("/")[3] - status_message["Alarms"].append(AlarmOrWarning(description, device_created).to_dict()) - - if any(changed_warnings.values()): - for i, changed_warning in enumerate(changed_warnings.values()): - if changed_warning and list(current_warnings.values())[i]: - description = list(current_warnings.keys())[i].split("/")[-1] - device_created = "Battery node " + list(current_warnings.keys())[i].split("/")[3] - status_message["Warnings"].append(AlarmOrWarning(description, device_created).to_dict()) - - if any(current_alarms.values()): - status_message["Status"]=2 - - if not any(current_alarms.values()) and any(current_warnings.values()): - status_message["Status"]=1 - - if not any(current_alarms.values()) and not any(current_warnings.values()): - status_message["Status"]=0 - - if status_message["Status"]!=prev_status or len(status_message["Warnings"])>0 or len(status_message["Alarms"])>0: - prev_status=status_message["Status"] - status_message["Type"]=0 - status_message = json.dumps(status_message) - channel.basic_publish(exchange="", routing_key="statusQueue", body=status_message) - print(status_message) - print("Message sent successfully") - elif heartbit_interval>=15 or not subscribed_to_queue_first_time: - print("Send heartbit message to rabbitmq") - heartbit_interval=0 - subscribed_to_queue_first_time=True - status_message = json.dumps(status_message) - channel.basic_publish(exchange="", routing_key="statusQueue", body=status_message) - - previous_warnings = current_warnings.copy() - previous_alarms = current_alarms.copy() - - return status_message, alarms_number_list, warnings_number_list - -def read_warning_and_alarm_flags(): - return [ - # Warnings - CsvSignal('/Battery/Devices/WarningFlags/TaM1', c.read_bool(register=1005, bit=1)), - CsvSignal('/Battery/Devices/WarningFlags/TbM1', c.read_bool(register=1005, bit=4)), - CsvSignal('/Battery/Devices/WarningFlags/VBm1', c.read_bool(register=1005, bit=6)), - CsvSignal('/Battery/Devices/WarningFlags/VBM1', c.read_bool(register=1005, bit=8)), - CsvSignal('/Battery/Devices/WarningFlags/IDM1', c.read_bool(register=1005, bit=10)), - CsvSignal('/Battery/Devices/WarningFlags/vsm1', c.read_bool(register=1005, bit=22)), - CsvSignal('/Battery/Devices/WarningFlags/vsM1', c.read_bool(register=1005, bit=24)), - CsvSignal('/Battery/Devices/WarningFlags/iCM1', c.read_bool(register=1005, bit=26)), - CsvSignal('/Battery/Devices/WarningFlags/iDM1', c.read_bool(register=1005, bit=28)), - CsvSignal('/Battery/Devices/WarningFlags/MID1', c.read_bool(register=1005, bit=30)), - CsvSignal('/Battery/Devices/WarningFlags/BLPW', c.read_bool(register=1005, bit=32)), - CsvSignal('/Battery/Devices/WarningFlags/CCBF', c.read_bool(register=1005, bit=33)), - CsvSignal('/Battery/Devices/WarningFlags/Ah_W', c.read_bool(register=1005, bit=35)), - CsvSignal('/Battery/Devices/WarningFlags/MPMM', c.read_bool(register=1005, bit=38)), - CsvSignal('/Battery/Devices/WarningFlags/TCdi', c.read_bool(register=1005, bit=40)), - CsvSignal('/Battery/Devices/WarningFlags/LMPW', c.read_bool(register=1005, bit=44)), - CsvSignal('/Battery/Devices/WarningFlags/TOCW', c.read_bool(register=1005, bit=47)), - CsvSignal('/Battery/Devices/WarningFlags/BUSL', c.read_bool(register=1005, bit=49)), - ], [ - # Alarms - CsvSignal('/Battery/Devices/AlarmFlags/Tam', c.read_bool(register=1005, bit=0)), - CsvSignal('/Battery/Devices/AlarmFlags/TaM2', c.read_bool(register=1005, bit=2)), - CsvSignal('/Battery/Devices/AlarmFlags/Tbm', c.read_bool(register=1005, bit=3)), - CsvSignal('/Battery/Devices/AlarmFlags/TbM2', c.read_bool(register=1005, bit=5)), - CsvSignal('/Battery/Devices/AlarmFlags/VBm2', c.read_bool(register=1005, bit=7)), - CsvSignal('/Battery/Devices/AlarmFlags/VBM2', c.read_bool(register=1005, bit=9)), - CsvSignal('/Battery/Devices/AlarmFlags/IDM2', c.read_bool(register=1005, bit=11)), - CsvSignal('/Battery/Devices/AlarmFlags/ISOB', c.read_bool(register=1005, bit=12)), - CsvSignal('/Battery/Devices/AlarmFlags/MSWE', c.read_bool(register=1005, bit=13)), - CsvSignal('/Battery/Devices/AlarmFlags/FUSE', c.read_bool(register=1005, bit=14)), - CsvSignal('/Battery/Devices/AlarmFlags/HTRE', c.read_bool(register=1005, bit=15)), - CsvSignal('/Battery/Devices/AlarmFlags/TCPE', c.read_bool(register=1005, bit=16)), - CsvSignal('/Battery/Devices/AlarmFlags/STRE', c.read_bool(register=1005, bit=17)), - CsvSignal('/Battery/Devices/AlarmFlags/CME', c.read_bool(register=1005, bit=18)), - CsvSignal('/Battery/Devices/AlarmFlags/HWFL', c.read_bool(register=1005, bit=19)), - CsvSignal('/Battery/Devices/AlarmFlags/HWEM', c.read_bool(register=1005, bit=20)), - CsvSignal('/Battery/Devices/AlarmFlags/ThM', c.read_bool(register=1005, bit=21)), - CsvSignal('/Battery/Devices/AlarmFlags/vsm2', c.read_bool(register=1005, bit=23)), - CsvSignal('/Battery/Devices/AlarmFlags/vsM2', c.read_bool(register=1005, bit=25)), - CsvSignal('/Battery/Devices/AlarmFlags/iCM2', c.read_bool(register=1005, bit=27)), - CsvSignal('/Battery/Devices/AlarmFlags/iDM2', c.read_bool(register=1005, bit=29)), - CsvSignal('/Battery/Devices/AlarmFlags/MID2', c.read_bool(register=1005, bit=31)), - CsvSignal('/Battery/Devices/AlarmFlags/HTFS', c.read_bool(register=1005, bit=42)), - CsvSignal('/Battery/Devices/AlarmFlags/DATA', c.read_bool(register=1005, bit=43)), - CsvSignal('/Battery/Devices/AlarmFlags/LMPA', c.read_bool(register=1005, bit=45)), - CsvSignal('/Battery/Devices/AlarmFlags/HEBT', c.read_bool(register=1005, bit=46)), - CsvSignal('/Battery/Devices/AlarmFlags/CURM', c.read_bool(register=1005, bit=48)), - CsvSignal('/Battery/Devices/AlarmFlags/2 or more string are disabled',c.read_limb_string(1059)), - ] - -def update(modbus, batteries, dbus, signals, csv_signals): - # type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool - """ - Main update function - - 1. requests status record each battery via modbus, - 2. parses the data using Signal.get_value - 3. aggregates the data from all batteries into one datum using Signal.aggregate - 4. publishes the data on the dbus - """ - logging.debug('starting update cycle') - warnings_signals, alarm_signals = read_warning_and_alarm_flags() - current_warnings = {} - current_alarms= {} - statuses = [read_battery_status(modbus, battery) for battery in batteries] - node_numbers = [battery.slave_address for battery in batteries] - # Iterate over each node and signal to create rows in the new format - for i, node in enumerate(node_numbers): - for s in warnings_signals: - signal_name = insert_id(s.name, node) - value = s.get_value(statuses[i]) - current_warnings[signal_name] = value - for s in alarm_signals: - signal_name = insert_id(s.name, node) - value = s.get_value(statuses[i]) - current_alarms[signal_name] = value - #print(update_state_from_dictionaries(current_warnings, current_alarms)) - status_message, alarms_number_list, warnings_number_list = update_state_from_dictionaries(current_warnings, current_alarms, node_numbers) - publish_values(dbus, signals, statuses) - create_csv_files(csv_signals, statuses, node_numbers, alarms_number_list, warnings_number_list) - logging.debug('finished update cycle\n') - return True - -def print_usage(): - print('Usage: ' + __file__ + ' ') - print('Example: ' + __file__ + ' ttyUSB0') - -def parse_cmdline_args(argv): - # type: (list[str]) -> str - if len(argv) == 0: - logging.info('missing command line argument for tty device') - print_usage() - sys.exit(1) - return argv[0] - -alive = True # global alive flag, watchdog_task clears it, update_task sets it -ALLOW = False - -def create_update_task(modbus, dbus, batteries, signals, csv_signals, main_loop): - # type: (Modbus, DBus, Iterable[Battery], Iterable[Signal], DBusGMainLoop) -> Callable[[],bool] - """ - Creates an update task which runs the main update function - and resets the alive flag - """ - def update_task(): - # type: () -> bool - global alive, ALLOW - if ALLOW: - ALLOW = False - else: - ALLOW = True - alive = update(modbus, batteries, dbus, signals, csv_signals) - #alive = update_for_testing(modbus, batteries, dbus, signals, csv_signals) - if not alive: - logging.info('update_task: quitting main loop because of error') - main_loop.quit() - return alive - return update_task - -def create_watchdog_task(main_loop): - # type: (DBusGMainLoop) -> Callable[[],bool] - """ - Creates a Watchdog task that monitors the alive flag. - The watchdog kills the main loop if the alive flag is not periodically reset by the update task. - Who watches the watchdog? - """ - def watchdog_task(): - # type: () -> bool - global alive - if alive: - logging.debug('watchdog_task: update_task is alive') - alive = False - return True - else: - logging.info('watchdog_task: killing main loop because update_task is no longer alive') - main_loop.quit() - return False - return watchdog_task - -def get_installation_name(file_path): - with open(file_path, 'r') as file: - return file.read().strip() - -def manage_csv_files(directory_path, max_files=20): - csv_files = [f for f in os.listdir(directory_path) if os.path.isfile(os.path.join(directory_path, f))] - csv_files.sort(key=lambda x: os.path.getctime(os.path.join(directory_path, x))) - # Remove oldest files if exceeds maximum - while len(csv_files) > max_files: - file_to_delete = os.path.join(directory_path, csv_files.pop(0)) - os.remove(file_to_delete) - -def serialize_for_csv(value): - if isinstance(value, (dict, list, tuple)): - return json.dumps(value, ensure_ascii=False) - return str(value) - -def insert_id(path, id_number): - parts = path.split("/") - insert_position = parts.index("Devices") + 1 - parts.insert(insert_position, str(id_number)) - return "/".join(parts) - -def create_csv_files(signals, statuses, node_numbers, alarms_number_list, warnings_number_list): - global s3_config - timestamp = int(time.time()) - if timestamp % 2 != 0: - timestamp -= 1 - # Create CSV directory if it doesn't exist - if not os.path.exists(CSV_DIR): - os.makedirs(CSV_DIR) - csv_filename = f"{timestamp}.csv" - csv_path = os.path.join(CSV_DIR, csv_filename) - # Append values to the CSV file - with open(csv_path, 'a', newline='') as csvfile: - csv_writer = csv.writer(csvfile, delimiter=';') - # Add a special row for the nodes configuration - nodes_config_path = "/Config/Devices/BatteryNodes" - nodes_list = ",".join(str(node) for node in node_numbers) - config_row = [nodes_config_path, nodes_list, ""] - csv_writer.writerow(config_row) - # Iterate over each node and signal to create rows in the new format - for i, node in enumerate(node_numbers): - csv_writer.writerow([f"/Battery/Devices/{str(i+1)}/Alarms", alarms_number_list[i], ""]) - csv_writer.writerow([f"/Battery/Devices/{str(i+1)}/Warnings", warnings_number_list[i], ""]) - for s in signals: - signal_name = insert_id(s.name, i+1) - value = s.get_value(statuses[i]) - row_values = [signal_name, value, s.get_text] - csv_writer.writerow(row_values) - # Manage CSV files, keep a limited number of files - # Create the CSV as a string - csv_data = read_csv_as_string(csv_path) - - if csv_data is None: - print(" error while reading csv as string") - return - - # zip-comp additions - compressed_csv = compress_csv_data(csv_data) - compressed_filename = f"{timestamp}.csv" - - - - response = s3_config.create_put_request(compressed_filename, compressed_csv) - if response.status_code == 200: - #os.remove(csv_path) - print("Success") - else: - failed_dir = os.path.join(CSV_DIR, "failed") - if not os.path.exists(failed_dir): - os.makedirs(failed_dir) - failed_path = os.path.join(failed_dir, csv_filename) - os.rename(csv_path, failed_path) - print("Uploading failed") - manage_csv_files(failed_dir, 10) - manage_csv_files(CSV_DIR) - -def main(argv): - # type: (list[str]) -> () - logging.basicConfig(level=cfg.LOG_LEVEL) - logging.info('starting ' + __file__) - tty = parse_cmdline_args(argv) - modbus = init_modbus(tty) - batteries = identify_batteries(modbus) - n = len(batteries) - logging.info('found ' + str(n) + (' battery' if n == 1 else ' batteries')) - if n <= 0: - sys.exit(2) - bat = c.first(batteries) # report hw and fw version of first battery found - signals = init_signals(bat.hardware_version, bat.firmware_version, n) - csv_signals = create_csv_signals(bat.firmware_version) - main_loop = init_main_loop() # must run before init_dbus because gobject does some global magic - dbus = init_dbus(tty, signals) - update_task = create_update_task(modbus, dbus, batteries, signals, csv_signals, main_loop) - watchdog_task = create_watchdog_task(main_loop) - GLib.timeout_add(cfg.UPDATE_INTERVAL * 2, watchdog_task) # add watchdog first - GLib.timeout_add(cfg.UPDATE_INTERVAL, update_task) # call update once every update_interval - logging.info('starting GLib.MainLoop') - main_loop.run() - logging.info('GLib.MainLoop was shut down') - sys.exit(0xFF) # reaches this only on error - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/NodeRed/NodeRedFiles/flows.json b/NodeRed/NodeRedFiles/flows.json index 3fa924b39..5025e75d1 100644 --- a/NodeRed/NodeRedFiles/flows.json +++ b/NodeRed/NodeRedFiles/flows.json @@ -1729,7 +1729,6 @@ }, "name": "", "onlyChanges": false, - "roundValues": "0", "x": 210, "y": 1320, "wires": [ @@ -1873,7 +1872,7 @@ "type": "function", "z": "58aeeaac02a3a4c7", "name": "Battery Controller", - "func": "// get inverter num of phases\nif(msg.payload.num_phases == null){\n num_phases = 10000000000;// mimic to make power setpoint be 0 when there is no inverter phase there \n}else{\n num_phases = msg.payload.num_phases;\n}\n\n// get max charge power\nif(msg.payload.max_configured_charge_power == null ||msg.payload.max_configured_charge_power<0){\n max_charge_power=msg.payload.max_battery_charge_power;\n}else{\n max_charge_power=Math.min(msg.payload.max_configured_charge_power,msg.payload.max_battery_charge_power);\n}\n\n// get battery number\nif(msg.payload.num_batteries == null){\n n_batteries = 0;\n}else{\n n_batteries = msg.payload.num_batteries;\n}\n\n// get current battery power\nif(msg.payload.battery_power == null){\n battery_power = 0;\n}else{\n battery_power = msg.payload.battery_power;\n}\n\n// get current power setpoint\nif(msg.payload.L1_AcPowerSetpoint == null){\n L1_AcPowerSetpoint = 0;\n}else{\n L1_AcPowerSetpoint=msg.payload.L1_AcPowerSetpoint;\n}\n\nif(msg.payload.L2_AcPowerSetpoint == null){\n L2_AcPowerSetpoint = 0;\n}else{\n L2_AcPowerSetpoint=msg.payload.L2_AcPowerSetpoint;\n}\n\nif(msg.payload.L3_AcPowerSetpoint == null){\n L3_AcPowerSetpoint = 0;\n}else{\n L3_AcPowerSetpoint=msg.payload.L3_AcPowerSetpoint;\n}\n\ninverter_power_setpoint= L1_AcPowerSetpoint+L2_AcPowerSetpoint+L3_AcPowerSetpoint;\n\n// get AC Out whihc is critical loads\nif(msg.payload.L1_AC_Out == null ||msg.payload.L2_AC_Out == null || msg.payload.L3_AC_Out == null){\n AC_out=0;\n}else{\n AC_out = msg.payload.L1_AC_Out + msg.payload.L2_AC_Out+msg.payload.L3_AC_Out;\n}\n\n// get PV production\nif(msg.payload.PVs_Power == null){\n PV_production = 0;\n}else{\n PV_production = msg.payload.PVs_Power;\n}\n\n// cal calculated max inverter power based on limb strings<=1 and DC Bus voltage >=44V when discharging, further details in flow 3\nconfigured_max_inverter_power = num_phases*3000;//3000W for each phase\nmax_discharge_current_batteries = 15*(5*n_batteries-msg.payload.num_limb_string);\nDC_BUS_Voltage = msg.payload.DC_BUS_Voltage;\n\nif(44.1=44V when discharging, further details in flow 3\nconfigured_max_inverter_power = num_phases*10000;//3000W for each phase\nmax_discharge_current_batteries = 15*(5*n_batteries-msg.payload.num_limb_string);\nDC_BUS_Voltage = msg.payload.DC_BUS_Voltage;\n\nif(44.1 55: - # 48V battery (16 cells.) Assume BMS knows what it's doing. - return (charge_voltage, charge_current, feedback_allowed, False) - if charge_voltage > 30: - # 48V battery (15 cells) - return (min(charge_voltage, 52.4), charge_current, feedback_allowed, False) - if charge_voltage > 20: - # 24V battery (8 cells). 24V batteries send CCL=0 when they are full, - # whereas the 48V batteries reduce CCL by 50% when the battery is full. - # Do the same for 24V batteries. The normal limit is C/2, so put the - # limit to C/4. Note that this is just a nicety, the important part is - # to clip the charge voltage to 27.8 volts. That fixes the sawtooth - # issue. - capacity = bms.capacity or 55 - return (min(charge_voltage, 27.8), max(charge_current, round(capacity/4.0)), feedback_allowed, False) - - # Not known, probably a 12V battery. - return (charge_voltage, charge_current, feedback_allowed, False) - -def _pylontech_pelio_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed): - """ Quirk for Pelio-L batteries. This is a 16-cell battery. 56V is 3.5V per - cell which is where this battery registers 100% SOC. Battery sends - CCL=0 at 3.55V per cell, to ensure good feed-in of excess DC coupled - PV, set the lower limit to 20% of capacity, which is what the battery - itself imposes at around 98% SOC. - """ - capacity = bms.capacity or 100.0 - return (min(charge_voltage, 56.0), max(charge_current, round(capacity/5.0)), feedback_allowed, False) - -def _lynx_smart_bms_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed): - """ When the Lynx Smart BMS sends CCL=0, it wants all chargers to stop. """ - return (charge_voltage, charge_current, feedback_allowed, True) - -QUIRKS = { - 0xB004: _lg_quirk, - 0xB009: _pylontech_quirk, - 0xB00A: _byd_quirk, - 0xB015: _byd_quirk, - 0xB019: _byd_quirk, - 0xB029: _pylontech_pelio_quirk, - 0xA3E5: _lynx_smart_bms_quirk, - 0xA3E6: _lynx_smart_bms_quirk, -} - -def distribute(current_values, max_values, increment): - """ current_values and max_values are lists of equal size containing the - current limits, and the maximum they can be increased to. increment - contains the amount by which we want to increase the total, ie the sum - of the values in current_values, while staying below max_values. - - This is done simply by first attempting to spread the increment - equally. If a value exceeds the max in that process, the remainder is - thrown back into the pot and distributed equally among the rest. - - Negative values are also handled, and zero is assumed to be the - implicit lower limit. """ - n = cn = len(current_values) - new_values = [-1] * n - for j in range(0, n): - for i, mv, av in zip(count(), max_values, current_values): - assert mv >= 0 - if new_values[i] == mv or new_values[i] == 0: - continue - nv = av + float(increment) / cn - - if nv >= mv: - increment += av - mv - cn -= 1 - new_values[i] = mv - break - elif nv < 0: - increment += av - cn -= 1 - new_values[i] = 0 - break - - new_values[i] = nv - else: - break - continue - return new_values - -class LowPassFilter(object): - """ Low pass filter, with a cap. """ - def __init__(self, omega, value): - self.omega = omega - self._value = value - - def update(self, newvalue): - self._value += (newvalue - self._value) * self.omega - return self._value - - @property - def value(self): - return self._value - -class BaseCharger(object): - def __init__(self, monitor, service): - self.monitor = monitor - self.service = service - - def _get_path(self, path): - return self.monitor.get_value(self.service, path) - - def _set_path(self, path, v): - if self.monitor.seen(self.service, path): - self.monitor.set_value_async(self.service, path, v) - - @property - def firmwareversion(self): - return self.monitor.get_value(self.service, '/FirmwareVersion') - - @property - def product_id(self): - return self.monitor.get_value(self.service, '/ProductId') or 0 - - @property - def chargecurrent(self): - return self._get_path('/Dc/0/Current') - - @property - def n2k_device_instance(self): - return self.monitor.get_value(self.service, '/N2kDeviceInstance') - - @property - def connection(self): - return self._get_path('/Mgmt/Connection') - - @property - def active(self): - return self._get_path('/State') != 0 - - @property - def has_externalcontrol_support(self): - """ Override this to implement detection of external control support. - """ - return False - - @property - def want_bms(self): - """ Indicates whether this solar charger was previously - controlled by a BMS and therefore expects one to - be present. """ - return 0 - - @property - def maxchargecurrent(self): - v = self._get_path('/Link/ChargeCurrent') - return v if v is not None else self.currentlimit - - @maxchargecurrent.setter - def maxchargecurrent(self, v): - v = max(0, min(v, self.currentlimit)) - self._set_path('/Link/ChargeCurrent', v) - - @property - def chargevoltage(self): - return self._get_path('/Link/ChargeVoltage') - - @chargevoltage.setter - def chargevoltage(self, v): - self._set_path('/Link/ChargeVoltage', v) - - @property - def currentlimit(self): - return self._get_path('/Settings/ChargeCurrentLimit') - - def maximize_charge_current(self): - """ Max out the charge current of this solar charger by setting - ChargeCurrent to the configured limit in settings. """ - if self.monitor.seen(self.service, '/Link/ChargeCurrent'): - copy_dbus_value(self.monitor, - self.service, '/Settings/ChargeCurrentLimit', - self.service, '/Link/ChargeCurrent') - - @property - def smoothed_current(self): - # For chargers that are not solar-chargers, the generated current - # should be fairly stable already - return self.chargecurrent or 0 - -class Networkable(object): - """ Mix into BaseCharger to support network paths. """ - @property - def networkmode(self): - return self._get_path('/Link/NetworkMode') - - @networkmode.setter - def networkmode(self, v): - self._set_path('/Link/NetworkMode', 0) - self._set_path('/Settings/BmsPresent',0) - -class SolarCharger(BaseCharger, Networkable): - """ Encapsulates a solar charger on dbus. Exposes dbus paths as convenient - attributes. """ - - def __init__(self, monitor, service): - super().__init__(monitor, service) - self._smoothed_current = LowPassFilter((2 * pi)/20, self.chargecurrent or 0) - self._has_externalcontrol_support = False - - @property - def has_externalcontrol_support(self): - # If we have previously determined that there is support, re-use that. - # If the firmware is ever to be downgraded, the solarcharger must necessarily - # disconnect and reconnect, so this is completely safe. - if self._has_externalcontrol_support: - return True - - # These products are known to have support, but may have older firmware - # See https://github.com/victronenergy/venus/issues/655 - if 0xA102 <= self.product_id <= 0xA10E: - self._has_externalcontrol_support = True - return True - - v = self.firmwareversion - - # If the firmware version is not known, don't raise a false - # warning. - if v is None: - return True - - # New VE.Can controllers have 24-bit version strings. One would - # hope that any future VE.Direct controllers with 24-bit firmware - # versions will 1) have a version larger than 1.02 and 2) support - # external control. - if v & 0xFF0000: - self._has_externalcontrol_support = (v >= VECAN_FIRMWARE_REQUIRED) - else: - self._has_externalcontrol_support = (v >= VEDIRECT_FIRMWARE_REQUIRED) - return self._has_externalcontrol_support - - @property - def smoothed_current(self): - """ Returns the internal low-pass filtered current value. """ - return self._smoothed_current.value - - def update_values(self): - # This is called periodically from a timer to maintain - # a smooth current value. - v = self.monitor.get_value(self.service, '/Dc/0/Current') - if v is not None: - self._smoothed_current.update(v) - -class Alternator(BaseCharger, Networkable): - """ This also includes other DC/DC converters. """ - @property - def has_externalcontrol_support(self): - # If it has the ChargeCurrent path, we assume it has - # external control support - return self.monitor.seen(self.service, '/Link/ChargeCurrent') - -class InverterCharger(SolarCharger): - """ Encapsulates an inverter/charger object, currently the inverter RS, - which has a solar input and can charge the battery like a solar - charger, but is also an inverter. - """ - def __init__(self, monitor, service): - super(InverterCharger, self).__init__(monitor, service) - - @property - def has_externalcontrol_support(self): - # Inverter RS always had support - return True - - @property - def maxdischargecurrent(self): - """ Returns discharge current setting. This does nothing except - return the previously set value. """ - return self.monitor.get_value(self.service, '/Link/DischargeCurrent') - - @maxdischargecurrent.setter - def maxdischargecurrent(self, limit): - self.monitor.set_value_async(self.service, '/Link/DischargeCurrent', limit) - - def set_maxdischargecurrent(self, limit): - """ Write the maximum discharge limit across. The firmware - already handles a zero by turning off. """ - self.maxdischargecurrent = limit - - @property - def active(self): - # The charger part is active, as long as the maximum charging - # power value is more than zero. - return (self.monitor.get_value(self.service, - '/Settings/ChargeCurrentLimit') or 0) > 0 - -class InverterSubsystem(object): - """ Encapsulate collection of inverters. """ - def __init__(self, monitor): - self.monitor = monitor - self._inverters = {} - - def _add_inverter(self, ob): - self._inverters[ob.service] = ob - return ob - - def remove_inverter(self, service): - del self._inverters[service] - - def __iter__(self): - return iter(self._inverters.values()) - - def __len__(self): - return len(self._inverters) - - def __contains__(self, k): - return k in self._inverters - - def set_maxdischargecurrent(self, limit): - # Inverters only care about limit=0, so simply send - # it to all. - for inverter in self: - inverter.set_maxdischargecurrent(limit) - -class ChargerSubsystem(object): - """ Encapsulates a collection of chargers or devices that incorporate a - charger, to collectively make up a charging system (sans Multi). - Properties related to the whole system or some combination of the - individual chargers are exposed here as attributes. """ - def __init__(self, monitor): - self.monitor = monitor - self._solarchargers = {} - self._otherchargers = {} - - def add_solar_charger(self, service): - self._solarchargers[service] = charger = SolarCharger(self.monitor, service) - return charger - - def add_alternator(self, service): - self._otherchargers[service] = charger = Alternator(self.monitor, service) - return charger - - def add_invertercharger(self, service): - self._solarchargers[service] = inverter = InverterCharger(self.monitor, service) - return inverter - - def remove_charger(self, service): - for di in (self._solarchargers, self._otherchargers): - try: - del di[service] - except KeyError: - pass - - def __iter__(self): - return iter(chain(self._solarchargers.values(), self._otherchargers.values())) - - def __len__(self): - return len(self._solarchargers) + len(self._otherchargers) - - def __contains__(self, k): - return k in self._solarchargers or k in self._otherchargers - - @property - def has_externalcontrol_support(self): - # Only consider solarchargers. This is used for firmware warning - # above, and we only care about the solar chargers there. - return all(s.has_externalcontrol_support for s in self._solarchargers.values()) - - @property - def has_vecan_chargers(self): - """ Returns true if we have any VE.Can chargers in the system. This is - used elsewhere to enable broadcasting charge voltages on the relevant - can device. """ - return any((s.connection == 'VE.Can' for s in self)) - - @property - def want_bms(self): - """ Return true if any of our solar chargers expect a BMS to - be present. """ - return any((s.want_bms for s in self)) - - @property - def totalcapacity(self): - """ Total capacity if all chargers are running at full power. """ - return safeadd(*(c.currentlimit for c in self)) - - @property - def smoothed_current(self): - """ Total smoothed current, calculated by adding the smoothed current - of the individual chargers. """ - return safeadd(*(c.smoothed_current for c in self)) or 0 - - @property - def solar_current(self): - return safeadd(*(c.smoothed_current for c in self._solarchargers.values())) or 0 - - def maximize_charge_current(self): - """ Max out all chargers. """ - for charger in self: - charger.maximize_charge_current() - - def shutdown_chargers(self): - """ Shut down all chargers. """ - for charger in self: - charger.maxchargecurrent = 0 - - def set_networked(self, has_bms, bms_charge_voltage, charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0): - """ This is the main entry-point into the solar charger subsystem. This - sets all chargers to the same charge_voltage, and distributes - max_charge_current between the chargers. If feedback_allowed, then - we simply max out the chargers. We also don't bother with - distribution if there's only one charger in the system or if - it exceeds our total capacity. - """ - # Network mode: - # bit 0: Operated in network environment - # bit 2: Remote Hub-1 control (MPPT will accept charge voltage and max charge current) - # bit 3: Remote BMS control (MPPT enter BMS mode) - network_mode = 1 | (0 if charge_voltage is None and max_charge_current is None else 4) | (8 if has_bms else 0) - network_mode_written = False - for charger in self: - charger.networkmode = network_mode - network_mode_written = True - - # Distribute the voltage setpoint to all solar chargers. - # Non-solar chargers are controlled elsewhere. - voltage_written = 0 - if charge_voltage is not None: - voltage_written = int(len(self)>0) - for charger in self._solarchargers.values(): - charger.chargevoltage = charge_voltage - - # Distribute the original BMS voltage setpoint, if there is one, - # to the other chargers - if bms_charge_voltage is not None: - for charger in self._otherchargers.values(): - charger.chargevoltage = bms_charge_voltage - - # Do not limit max charge current when feedback is allowed. The - # rationale behind this is that MPPT charge power should match the - # capabilities of the battery. If the default charge algorithm is used - # by the MPPTs, the charge current should stay within limits. This - # avoids a problem that we do not know if extra MPPT power will be fed - # back to the grid when we decide to increase the MPPT max charge - # current. - # - # Additionally, don't bother with chargers that are disconnected. - chargers = [x for x in self._solarchargers.values() if x.active and x.maxchargecurrent is not None and x.n2k_device_instance in (0, None)] - if len(chargers) > 0: - if stop_on_mcc0 and max_charge_current == 0: - self.shutdown_chargers() - elif feedback_allowed: - self.maximize_charge_current() - elif max_charge_current is not None: - if len(chargers) == 1: - # The simple case: Only one charger. Simply assign the - # limit to the charger - sc = chargers[0] - cc = min(ceil(max_charge_current), sc.currentlimit) - sc.maxchargecurrent = cc - elif max_charge_current > self.totalcapacity * 0.95: - # Another simple case, we're asking for more than our - # combined capacity (with a 5% margin) - self.maximize_charge_current() - else: - # The hard case, we have more than one CC and we want - # less than our capacity. - self._distribute_current(chargers, max_charge_current) - - # Split remainder over other chargers, according to individual - # capacity. Only consider controllable devices. - if max_charge_current is not None: - remainder = max(0.0, max_charge_current - self.solar_current) - controllable = [c for c in self._otherchargers.values() if c.has_externalcontrol_support] - capacity = safeadd(*(c.currentlimit for c in controllable)) or 0 - if capacity > 0: - for c in controllable: - c.maxchargecurrent = remainder * c.currentlimit / capacity - - # Return flags of what we did - return voltage_written, int(network_mode_written and max_charge_current is not None), network_mode - - # The math for the below is as follows. Let c be the total capacity of the - # charger, l be the current limit, a the actual current it produces, k the - # total current limit for the two chargers, and m the margin (l - a) - # between the limit and what is produced. - # - # We want m/c to be the same for all our chargers. - # - # Expression 1: (l1-a1)/c1 == (l2-a2)/c2 - # Expression 2: l1 + l2 == k - # - # Solving that yields the expression below. - @staticmethod - def _balance_chargers(charger1, charger2, l1, l2): - c1, c2 = charger1.currentlimit, charger2.currentlimit - a1 = min(charger1.smoothed_current, c1) - a2 = min(charger2.smoothed_current, c2) - k = l1 + l2 - - try: - l1 = round((c2 * a1 - c1 * a2 + k * c1)/(c1 + c2), 1) - except ArithmeticError: - return l1, l2 # unchanged - else: - l1 = max(min(l1, c1), 0) - return l1, k - l1 - - @staticmethod - def _distribute_current(chargers, max_charge_current): - """ This is called if there are two or more solar chargers. It - distributes the charge current over all of them. """ - - # Take the difference between the values and spread it over all - # the chargers. The maxchargecurrents of the chargers should ideally - # always add up to the whole. - limits = [c.maxchargecurrent for c in chargers] - ceilings = [c.currentlimit for c in chargers] - - # We cannot have a max_charge_current higher than the sum of the - # ceilings. - max_charge_current = min(sum(ceilings), max_charge_current) - - - # Check how far we have to move our adjustment. If it doesn't have to - # move much (or at all), then just balance the charge limits. Our - # threshold for doing an additional distribution of charge is relative - # to the number of chargers, as it makes no sense to attempt a - # distribution if there is too little to be gained. The chosen value - # here is 100mA per charger. - delta = max_charge_current - sum(limits) - if abs(delta) > 0.1 * len(chargers): - limits = distribute(limits, ceilings, delta) - for charger, limit in zip(chargers, limits): - charger.maxchargecurrent = limit - else: - # Balance the limits so they have the same headroom at the top. - # Each charger is balanced against its neighbour, the one at the - # end is paired with the one at the start. - limits = [] - r = chargers[0].maxchargecurrent - for c1, c2 in zip(chargers, chargers[1:]): - l, r = ChargerSubsystem._balance_chargers(c1, c2, r, c2.maxchargecurrent) - limits.append(l) - l, limits[0] = ChargerSubsystem._balance_chargers(c2, chargers[0], r, limits[0]) - limits.append(l) - - for charger, limit in zip(chargers, limits): - charger.maxchargecurrent = limit - - def update_values(self): - # This is called periodically from a timer to update contained - # solar chargers with values that they track. - for charger in self: - try: - charger.update_values() - except AttributeError: - pass - -class BatteryOperationalLimits(object): - """ Only used to encapsulate this part of the Multi's functionality. - """ - def __init__(self, multi): - self._multi = multi - - def _property(path, self): - # Due to the use of partial, path and self is reversed. - return self._multi.monitor.get_value(self._multi.service, path) - - def _set_property(path, self, v): - # None of these values can be negative - if v is not None: - v = max(0, v) - self._multi.monitor.set_value_async(self._multi.service, path, v) - - chargevoltage = property( - partial(_property, '/BatteryOperationalLimits/MaxChargeVoltage'), - partial(_set_property, '/BatteryOperationalLimits/MaxChargeVoltage')) - maxchargecurrent = property( - partial(_property, '/BatteryOperationalLimits/MaxChargeCurrent'), - partial(_set_property, '/BatteryOperationalLimits/MaxChargeCurrent')) - maxdischargecurrent = property( - partial(_property, '/BatteryOperationalLimits/MaxDischargeCurrent'), - partial(_set_property, '/BatteryOperationalLimits/MaxDischargeCurrent')) - batterylowvoltage = property( - partial(_property, '/BatteryOperationalLimits/BatteryLowVoltage'), - partial(_set_property, '/BatteryOperationalLimits/BatteryLowVoltage')) - - -class Multi(object): - """ Encapsulates the multi. Makes access to dbus paths a bit neater by - exposing them as attributes. """ - def __init__(self, monitor, service): - self.monitor = monitor - self._service = service - self.bol = BatteryOperationalLimits(self) - self._dc_current = LowPassFilter((2 * pi)/30, 0) - self._v = object() - - @property - def service(self): - return getattr(MultiService.instance.vebus_service, 'service', None) - - @property - def active(self): - return self.service is not None - - @property - def ac_connected(self): - return self.monitor.get_value(self.service, '/Ac/ActiveIn/Connected') == 1 - - @property - def has_bolframe(self): - return self.monitor.get_value(self.service, '/FirmwareFeatures/BolFrame') == 1 - - @property - def has_ess_assistant(self): - # We do not analyse the content of /Devices/0/Assistants, because that - # would require us to keep a list of ESS assistant version numbers (see - # VebusSocWriter._hub2_assistant_ids). Because that list is expected to - # change (unlike the list of hub-2 assistants), we use - # /Hub4/AssistantId to check the presence. It is guaranteed that - # /Hub4/AssistantId will be published before /Devices/0/Assistants. - assistants = self.monitor.get_value(self.service, '/Devices/0/Assistants') - return assistants is not None and \ - self.monitor.get_value(self.service, '/Hub4/AssistantId') == 5 - - @property - def dc_current(self): - """ Return a low-pass smoothed current. """ - return self._dc_current.value - - @property - def hub_voltage(self): - return self.monitor.get_value(self.service, '/Hub/ChargeVoltage') - - @property - def maxchargecurrent(self): - return self.monitor.get_value(self.service, '/Dc/0/MaxChargeCurrent') - - @maxchargecurrent.setter - def maxchargecurrent(self, v): - # If the Multi is not ready, don't write to it just yet - if self.active and self.maxchargecurrent is not None and v != self._v: - # The maximum present charge current is 6-parallel 12V 5kva units, 6*220 = 1320A. - # We will consider 10000A to be impossibly high. - self.monitor.set_value_async(self.service, '/Dc/0/MaxChargeCurrent', 10000 if v is None else v) - self._v = v - - @property - def state(self): - return self.monitor.get_value(self.service, '/State') - - @property - def feedin_enabled(self): - return self.monitor.get_value(self.service, - '/Hub4/L1/DoNotFeedInOvervoltage') == 0 - - @property - def firmwareversion(self): - return self.monitor.get_value(self.service, '/FirmwareVersion') - - @property - def allow_to_charge(self): - return self.monitor.get_value(self.service, '/Bms/AllowToCharge') != 0 - - @property - def has_vebus_bms(self): - """ This checks that we have a VE.Bus BMS. """ - return self.monitor.get_value(self.service, '/Bms/BmsType') == 2 - - @property - def has_vebus_bmsv2(self): - """ Checks that we have v2 of the VE.Bus BMS, but also that we can - properly use it, that is we also have an mk3. """ - version = self.monitor.get_value(self.service, '/Devices/Bms/Version') - atc = self.monitor.get_value(self.service, '/Bms/AllowToCharge') - - # If AllowToCharge is defined, but we have no version, then the Multi - # is off, but we still have a v2 BMS. V1 goes invalid if the multi - # is off. Yes, this is kludgy, but it is less kludgy than the - # fix the other end would require. - if self.has_vebus_bms and atc is not None and version is None: - return True - - # Otherwise, if the Multi is on, check the version to see if we should - # enable v2 functionality. - return (version or 0) >= 1146100 and \ - self.monitor.get_value(self.service, '/Interfaces/Mk2/ProductName') == 'MK3' - - def update_values(self, limit): - c = self.monitor.get_value(self.service, '/Dc/0/Current', 0) - if c is not None: - # Cap the filter at a limit. If we don't do this, dc currents - # in excess of our capacity causes a kind of wind-up that delays - # backing-off when the load drops suddenly. - if limit is not None: - c = max(c, -limit) - self._dc_current.update(c) - -class Dvcc(SystemCalcDelegate): - """ This is the main DVCC delegate object. """ - def __init__(self, sc): - super(Dvcc, self).__init__() - self.systemcalc = sc - self._chargesystem = None - self._vecan_services = [] - self._timer = None - self._tickcount = ADJUST - self._dcsyscurrent = LowPassFilter((2 * pi)/20, 0.0) - self._internal_mcp = ExpiringValue(3, None) # Max charging power - - def get_input(self): - return [ - ('com.victronenergy.vebus', [ - '/Ac/ActiveIn/Connected', - '/Hub/ChargeVoltage', - '/Dc/0/Current', - '/Dc/0/MaxChargeCurrent', - '/State', - '/BatteryOperationalLimits/BatteryLowVoltage', - '/BatteryOperationalLimits/MaxChargeCurrent', - '/BatteryOperationalLimits/MaxChargeVoltage', - '/BatteryOperationalLimits/MaxDischargeCurrent', - '/Bms/AllowToCharge', - '/Bms/BmsType', - '/Devices/Bms/Version', - '/FirmwareFeatures/BolFrame', - '/Hub4/L1/DoNotFeedInOvervoltage', - '/FirmwareVersion', - '/Interfaces/Mk2/ProductName']), - ('com.victronenergy.solarcharger', [ - '/ProductId', - '/Dc/0/Current', - '/Link/NetworkMode', - '/Link/ChargeVoltage', - '/Link/ChargeCurrent', - '/Settings/ChargeCurrentLimit', - '/State', - '/FirmwareVersion', - '/N2kDeviceInstance', - '/Mgmt/Connection', - '/Settings/BmsPresent']), - ('com.victronenergy.alternator', [ - '/ProductId', - '/Dc/0/Voltage', - '/Dc/0/Current', - '/Link/NetworkMode', - '/Link/ChargeVoltage', - '/Link/ChargeCurrent', - '/Settings/ChargeCurrentLimit', - '/State', - '/FirmwareVersion', - '/N2kDeviceInstance', - '/Mgmt/Connection', - '/Settings/BmsPresent']), - ('com.victronenergy.inverter', [ - '/ProductId', - '/Dc/0/Current', - '/IsInverterCharger', - '/Link/NetworkMode', - '/Link/ChargeVoltage', - '/Link/ChargeCurrent', - '/Link/DischargeCurrent', - '/Settings/ChargeCurrentLimit', - '/State', - '/N2kDeviceInstance', - '/Mgmt/Connection', - '/Settings/BmsPresent']), - ('com.victronenergy.multi', [ - '/ProductId', - '/Dc/0/Current', - '/IsInverterCharger', - '/Link/ChargeCurrent', - '/Link/DischargeCurrent', - '/Settings/ChargeCurrentLimit', - '/State', - '/N2kDeviceInstance', - '/Mgmt/Connection', - '/Settings/BmsPresent']), - ('com.victronenergy.vecan', [ - '/Link/ChargeVoltage', - '/Link/NetworkMode']), - ('com.victronenergy.settings', [ - '/Settings/CGwacs/OvervoltageFeedIn', - '/Settings/Services/Bol'])] - - def get_settings(self): - return [ - ('maxchargecurrent', '/Settings/SystemSetup/MaxChargeCurrent', -1, -1, 10000), - ('maxchargevoltage', '/Settings/SystemSetup/MaxChargeVoltage', 0.0, 0.0, 80.0), - ('bol', '/Settings/Services/Bol', 0, 0, 7) - ] - - def set_sources(self, dbusmonitor, settings, dbusservice): - SystemCalcDelegate.set_sources(self, dbusmonitor, settings, dbusservice) - self._chargesystem = ChargerSubsystem(dbusmonitor) - self._inverters = InverterSubsystem(dbusmonitor) - self._multi = Multi(dbusmonitor, dbusservice) - - self._dbusservice.add_path('/Control/SolarChargeVoltage', value=0) - self._dbusservice.add_path('/Control/SolarChargeCurrent', value=0) - self._dbusservice.add_path('/Control/EffectiveChargeVoltage', value=None) - self._dbusservice.add_path('/Control/BmsParameters', value=0) - self._dbusservice.add_path('/Control/MaxChargeCurrent', value=0) - self._dbusservice.add_path('/Control/Dvcc', value=self.has_dvcc) - self._dbusservice.add_path('/Debug/BatteryOperationalLimits/SolarVoltageOffset', value=0, writeable=True) - self._dbusservice.add_path('/Debug/BatteryOperationalLimits/VebusVoltageOffset', value=0, writeable=True) - self._dbusservice.add_path('/Debug/BatteryOperationalLimits/CurrentOffset', value=0, writeable=True) - self._dbusservice.add_path('/Dvcc/Alarms/FirmwareInsufficient', value=0) - self._dbusservice.add_path('/Dvcc/Alarms/MultipleBatteries', value=0) - - def device_added(self, service, instance, do_service_change=True): - service_type = service.split('.')[2] - if service_type == 'solarcharger': - self._chargesystem.add_solar_charger(service) - elif service_type in ('inverter', 'multi'): - if self._dbusmonitor.get_value(service, '/IsInverterCharger') == 1: - # Add to both the solarcharger and inverter collections. - # add_invertercharger returns an object that can be directly - # added to the inverter collection. - self._inverters._add_inverter( - self._chargesystem.add_invertercharger(service)) - elif service_type == 'vecan': - self._vecan_services.append(service) - elif service_type == 'alternator': - self._chargesystem.add_alternator(service) - elif service_type == 'battery': - pass # install timer below - else: - # Skip timer code below - return - - if self._timer is None: - self._timer = GLib.timeout_add(1000, exit_on_error, self._on_timer) - - def device_removed(self, service, instance): - if service in self._chargesystem: - self._chargesystem.remove_charger(service) - # Some solar chargers are inside an inverter - if service in self._inverters: - self._inverters.remove_inverter(service) - elif service in self._vecan_services: - self._vecan_services.remove(service) - elif service in self._inverters: - self._inverters.remove_inverter(service) - if len(self._chargesystem) == 0 and len(self._vecan_services) == 0 and \ - len(BatteryService.instance.batteries) == 0 and self._timer is not None: - GLib.source_remove(self._timer) - self._timer = None - - def _property(path, self): - # Due to the use of partial, path and self is reversed. - try: - return float(self._dbusservice[path]) - except ValueError: - return None - - solarvoltageoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/SolarVoltageOffset')) - invertervoltageoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/VebusVoltageOffset')) - currentoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/CurrentOffset')) - - @property - def internal_maxchargepower(self): - return self._internal_mcp.get() - - @internal_maxchargepower.setter - def internal_maxchargepower(self, v): - self._internal_mcp.set(v) - - @property - def dcsyscurrent(self): - """ Return non-zero DC system current, if it is based on - a real measurement. If an estimate/calculation, we cannot use it. - """ - if self._dbusservice['/Dc/System/MeasurementType'] == 1: - try: - v = self._dbusservice['/Dc/Battery/Voltage'] - return self._dcsyscurrent.update( - float(self._dbusservice['/Dc/System/Power'])/v) - except (TypeError, ZeroDivisionError): - pass - return 0.0 - - @property - def has_ess_assistant(self): - return self._multi.active and self._multi.has_ess_assistant - - @property - def has_dvcc(self): - # 0b00 = Off - # 0b01 = On - # 0b10 = Forced off - # 0b11 = Forced on - v = self._settings['bol'] - return bool(v & 1) - - @property - def bms(self): - return BatteryService.instance.bms - - @property - def bms_seen(self): - return self._chargesystem.want_bms - - def _on_timer(self): - def update_solarcharger_control_flags(voltage_written, current_written, chargevoltage): - self._dbusservice['/Control/SolarChargeVoltage'] = voltage_written - self._dbusservice['/Control/SolarChargeCurrent'] = current_written - self._dbusservice['/Control/EffectiveChargeVoltage'] = chargevoltage - - bol_support = self.has_dvcc - - self._tickcount -= 1; self._tickcount %= ADJUST - - if not bol_support: - if self._tickcount > 0: return True - - voltage_written, current_written = self._legacy_update_solarchargers() - update_solarcharger_control_flags(voltage_written, current_written, None) # Not tracking for non-DVCC case - self._dbusservice['/Control/BmsParameters'] = 0 - self._dbusservice['/Control/MaxChargeCurrent'] = 0 - self._dbusservice['/Control/Dvcc'] = 0 - self._dbusservice['/Dvcc/Alarms/FirmwareInsufficient'] = 0 - self._dbusservice['/Dvcc/Alarms/MultipleBatteries'] = 0 - return True - - - # BOL/DVCC support below - self._dbusservice['/Dvcc/Alarms/FirmwareInsufficient'] = int( - not self._chargesystem.has_externalcontrol_support or ( - self._multi.firmwareversion is not None and self._multi.firmwareversion < VEBUS_FIRMWARE_REQUIRED)) - self._dbusservice['/Dvcc/Alarms/MultipleBatteries'] = int( - len(BatteryService.instance.bmses) > 1) - - # Update subsystems - self._chargesystem.update_values() - self._multi.update_values(self._chargesystem.totalcapacity) - - # Below are things we only do every ADJUST seconds - if self._tickcount > 0: return True - - # Signal Dvcc support to other processes - self._dbusservice['/Control/Dvcc'] = 1 - - # Check that we have not lost the BMS, if we ever had one. If the BMS - # is lost, stop passing information to the solar chargers so that they - # might time out. - bms_service = self.bms - if self.bms_seen and bms_service is None and not self._multi.has_vebus_bmsv2: - # BMS is lost - update_solarcharger_control_flags(0, 0, None) - return True - - # Get the user current limit, if set - user_max_charge_current = self._settings['maxchargecurrent'] - if user_max_charge_current < 0: user_max_charge_current = None - - # If there is a BMS, get the charge voltage and current from it - max_charge_current = None - charge_voltage = None - feedback_allowed = self.feedback_allowed - stop_on_mcc0 = False - has_bms = bms_service is not None - if has_bms: - charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0 = \ - self._adjust_battery_operational_limits(bms_service, feedback_allowed) - - # Check /Bms/AllowToCharge on the VE.Bus service, and set - # max_charge_current to zero if charging is not allowed. Skip this if - # ESS is involved, then the Multi controls this through the charge - # voltage. If it is BMS v2, then also set BMS bit so that solarchargers - # go into #67 if we lose it. - if self._multi.has_vebus_bms: - stop_on_mcc0 = True - has_bms = has_bms or self._multi.has_vebus_bmsv2 - max_charge_current = 10000 if self._multi.allow_to_charge else 0 - - # Take the lesser of the BMS and user current limits, wherever they exist - maximae = [x for x in (user_max_charge_current, max_charge_current) if x is not None] - max_charge_current = min(maximae) if maximae else None - - # Override the battery charge voltage by taking the lesser of the - # voltage limits. Only override if the battery supplies one, to prevent - # a voltage being sent to a Multi in a system without a managed battery. - # Otherwise the Multi will go into passthru if the user disables this. - if charge_voltage is not None: - user_charge_voltage = self._settings['maxchargevoltage'] - if user_charge_voltage > 0: - charge_voltage = min(charge_voltage, user_charge_voltage) - - # @todo EV What if ESS + OvervoltageFeedIn? In that case there is no - # charge current control on the MPPTs, but we'll still indicate that - # the control is active here. Should we? - self._dbusservice['/Control/MaxChargeCurrent'] = \ - not self._multi.active or self._multi.has_bolframe - - # If there is a measured DC system, the Multi and solarchargers - # should add extra current for that. Round this to nearest 100mA. - if max_charge_current is not None and max_charge_current > 0 and not stop_on_mcc0: - max_charge_current = round(max_charge_current + self.dcsyscurrent, 1) - - # We need to keep a copy of the original value for later. We will be - # modifying one of them to compensate for vebus current. - _max_charge_current = max_charge_current - - # If we have vebus current, we have to compensate for it. But if we must - # stop on MCC=0, then only if the max charge current is above zero. - # Otherwise leave it unmodified so that the solarchargers are also - # stopped. - vebus_dc_current = self._multi.dc_current - if _max_charge_current is not None and vebus_dc_current is not None and \ - (not stop_on_mcc0 or _max_charge_current > 0) and vebus_dc_current < 0: - _max_charge_current = ceil(_max_charge_current - vebus_dc_current) - - # Try to push the solar chargers to the vebus-compensated value - voltage_written, current_written, effective_charge_voltage = \ - self._update_solarchargers_and_vecan(has_bms, charge_voltage, - _max_charge_current, feedback_allowed, stop_on_mcc0) - update_solarcharger_control_flags(voltage_written, current_written, effective_charge_voltage) - - # The Multi gets the remainder after subtracting what the solar - # chargers made. If there is a maximum charge power from another - # delegate (dynamicess), apply that here. - if max_charge_current is not None: - max_charge_current = max(0.0, round(max_charge_current - self._chargesystem.smoothed_current)) - - try: - internal_mcc = self.internal_maxchargepower / self._dbusservice['/Dc/Battery/Voltage'] - except (TypeError, ZeroDivisionError, ValueError): - pass - else: - try: - max_charge_current = min(x for x in (ceil(internal_mcc), max_charge_current) if x is not None) - except ValueError: - pass - - # Write the remainder to the Multi. - # There are two ways to limit the charge current of a VE.Bus system. If we have a BMS, - # the BOL parameter is used. - # If not, then the BOL parameters are not available, and the /Dc/0/MaxChargeCurrent path is - # used instead. This path relates to the MaxChargeCurrent setting as also available in - # VEConfigure, except that writing to it only changes the value in RAM in the Multi. - # Unlike VEConfigure it's not necessary to take the number of units in a system into account. - # - # Venus OS v2.30 fixes in mk2-dbus related to /Dc/0/MaxChargeCurrent: - # 1) Fix charge current too high in systems with multiple units per phase. mk2-bus was dividing - # the received current only by the number of phases in the system instead of dividing by the - # number of units in the system. - # 2) Fix setted charge current still active after disabling the "Limit charge current" setting. - # It used to be necessary to set a high current; and only then disable the setting or reset - # the VE.Bus system to re-initialise from the stored setting as per VEConfigure. - bms_parameters_written = 0 - if bms_service is None: - if max_charge_current is None: - self._multi.maxchargecurrent = None - else: - # Don't bother setting a charge current at 1A or less - self._multi.maxchargecurrent = max_charge_current if max_charge_current > 1 else 0 - else: - bms_parameters_written = self._update_battery_operational_limits(bms_service, charge_voltage, max_charge_current) - self._dbusservice['/Control/BmsParameters'] = int(bms_parameters_written or (bms_service is not None and voltage_written)) - - return True - - def _adjust_battery_operational_limits(self, bms_service, feedback_allowed): - """ Take the charge voltage and maximum charge current from the BMS - and adjust it as necessary. For now we only implement quirks - for batteries known to have them. - """ - cv = bms_service.chargevoltage - mcc = bms_service.maxchargecurrent - - quirk = QUIRKS.get(bms_service.product_id) - stop_on_mcc0 = False - if quirk is not None: - # If any quirks are registered for this battery, use that - # instead. - cv, mcc, feedback_allowed, stop_on_mcc0 = quirk(self, bms_service, cv, mcc, feedback_allowed) - - # Add debug offsets - if cv is not None: - cv = safeadd(cv, self.invertervoltageoffset) - if mcc is not None: - mcc = safeadd(mcc, self.currentoffset) - return cv, mcc, feedback_allowed, stop_on_mcc0 - - def _update_battery_operational_limits(self, bms_service, cv, mcc): - """ This function writes the bms parameters across to the Multi - if it exists. Also communicate DCL=0 to inverters. """ - written = 0 - if self._multi.active: - if cv is not None: - self._multi.bol.chargevoltage = cv - - if mcc is not None: - self._multi.bol.maxchargecurrent = mcc - # Also set the maxchargecurrent, to ensure this is not stuck - # at some lower value that overrides the intent here. - try: - self._multi.maxchargecurrent = max(self._multi.maxchargecurrent, mcc) - except TypeError: - pass - - # Copy the rest unmodified - self._multi.bol.maxdischargecurrent = bms_service.maxdischargecurrent - self._multi.bol.batterylowvoltage = bms_service.batterylowvoltage - written = 1 - - # Also control inverters if BMS stops discharge - if len(self._inverters): - self._inverters.set_maxdischargecurrent(bms_service.maxdischargecurrent) - written = 1 - - return written - - @property - def feedback_allowed(self): - # Feedback allowed is defined as 'ESS present and FeedInOvervoltage is - # enabled'. This ignores other setups which allow feedback: hub-1. - return self.has_ess_assistant and self._multi.ac_connected and \ - self._dbusmonitor.get_value('com.victronenergy.settings', - '/Settings/CGwacs/OvervoltageFeedIn') == 1 - - def _update_solarchargers_and_vecan(self, has_bms, bms_charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0): - """ This function updates the solar chargers only. Parameters - related to the Multi are handled elsewhere. """ - - # If the vebus service does not provide a charge voltage setpoint (so - # no ESS/Hub-1/Hub-4), we use the max charge voltage provided by the - # BMS (if any). This will probably prevent feedback, but that is - # probably not allowed anyway. - charge_voltage = None - if self._multi.active: - charge_voltage = self._multi.hub_voltage - if charge_voltage is None and bms_charge_voltage is not None: - charge_voltage = bms_charge_voltage - if charge_voltage is not None: - try: - charge_voltage += self.solarvoltageoffset - except (ValueError, TypeError): - pass - - if charge_voltage is None and max_charge_current is None: - return 0, 0, None - - voltage_written, current_written, network_mode = self._chargesystem.set_networked( - has_bms, bms_charge_voltage, charge_voltage, - max_charge_current, feedback_allowed, stop_on_mcc0) - - # Write the voltage to VE.Can. Also update the networkmode. - if charge_voltage is not None: - for service in self._vecan_services: - try: - # In case there is no path at all, the set_value below will - # raise an DBusException which we will ignore cheerfully. If we - # cannot set the NetworkMode there is no point in setting the - # ChargeVoltage. - self._dbusmonitor.set_value_async(service, '/Link/NetworkMode', network_mode) - self._dbusmonitor.set_value_async(service, '/Link/ChargeVoltage', charge_voltage) - voltage_written = 1 - except DBusException: - pass - - return voltage_written, current_written, charge_voltage - - def _legacy_update_solarchargers(self): - """ This is the old implementation we used before DVCC. It is kept - here so we can fall back to it where DVCC is not fully supported, - and to avoid maintaining two copies of systemcalc. """ - - max_charge_current = None - for battery in BatteryService.instance.batteries: - max_charge_current = safeadd(max_charge_current, battery.maxchargecurrent) - - # Workaround: copying the max charge current from BMS batteries to the solarcharger leads to problems: - # excess PV power is not fed back to the grid any more, and loads on AC-out are not fed with PV power. - # PV power is used for charging the batteries only. - # So we removed this feature, until we have a complete solution for solar charger support. Until then - # we set a 'high' max charge current to avoid 'BMS connection lost' alarms from the solarcharger. - if max_charge_current is not None: - max_charge_current = 1000 - - vebus_path = self._multi.service if self._multi.active else None - charge_voltage = None if vebus_path is None else \ - self._dbusmonitor.get_value(vebus_path, '/Hub/ChargeVoltage') - - if charge_voltage is None and max_charge_current is None: - return (0, 0) - - # Network mode: - # bit 0: Operated in network environment - # bit 2: Remote Hub-1 control - # bit 3: Remote BMS control - network_mode = 1 | (0 if charge_voltage is None else 4) | (0 if max_charge_current is None else 8) - voltage_written = 0 - current_written = 0 - for charger in self._chargesystem: - try: - # We use /Link/NetworkMode to detect Hub support in the solarcharger. Existence of this item - # implies existence of the other /Link/* fields. - if charger.networkmode is None: - continue - charger.networkmode = network_mode - - if charge_voltage is not None: - charger.chargevoltage = charge_voltage - voltage_written = 1 - - if max_charge_current is not None: - charger.maxchargecurrent = max_charge_current - current_written = 1 - except DBusException: - # If the charger for whatever reason doesn't have the /Link - # path, ignore it. This is the legacy implementation and - # better to keep it for the moment. - pass - - # The below is different to the non-legacy case above, where the voltage - # the com.victronenergy.vecan.* service instead. - if charge_voltage is not None and self._chargesystem.has_vecan_chargers: - # Charge voltage cannot by written directly to the CAN-bus solar chargers, we have to use - # the com.victronenergy.vecan.* service instead. - # Writing charge current to CAN-bus solar charger is not supported yet. - for service in self._vecan_services: - try: - # Note: we don't check the value of charge_voltage_item because it may be invalid, - # for example if the D-Bus path has not been written for more than 60 (?) seconds. - # In case there is no path at all, the set_value below will raise an DBusException - # which we will ignore cheerfully. - self._dbusmonitor.set_value_async(service, '/Link/ChargeVoltage', charge_voltage) - voltage_written = 1 - except DBusException: - pass - - return (voltage_written, current_written) diff --git a/NodeRed/flows.json b/NodeRed/flows.json deleted file mode 100644 index c4e81e053..000000000 --- a/NodeRed/flows.json +++ /dev/null @@ -1,5660 +0,0 @@ -[ - { - "id": "e2588b9d824334f7", - "type": "tab", - "label": "controller_calibration_charge", - "disabled": false, - "info": "", - "env": [] - }, - { - "id": "322b256f0daf33ef", - "type": "tab", - "label": "controller_hold_min_soc&&charge_to_min_soc", - "disabled": false, - "info": "", - "env": [] - }, - { - "id": "32b2f9d4415d82ce", - "type": "tab", - "label": "controller_max_discharge", - "disabled": false, - "info": "", - "env": [] - }, - { - "id": "812b3c1b3d3fa76b", - "type": "tab", - "label": "parse_warnings_and_alarms", - "disabled": false, - "info": "", - "env": [] - }, - { - "id": "dd75eef8547a776f", - "type": "tab", - "label": "Node Red Dashboard", - "disabled": false, - "info": "", - "env": [] - }, - { - "id": "victron-client-id", - "type": "victron-client" - }, - { - "id": "e177392401620838", - "type": "ui_group", - "name": "Controller and Battery Info", - "tab": "157862d37ae585b5", - "order": 2, - "disp": true, - "width": "13", - "collapse": false, - "className": "" - }, - { - "id": "157862d37ae585b5", - "type": "ui_tab", - "name": "Home", - "icon": "check", - "disabled": false, - "hidden": false - }, - { - "id": "e0e675d533a148b7", - "type": "ui_base", - "theme": { - "name": "theme-light", - "lightTheme": { - "default": "#0094CE", - "baseColor": "#0094CE", - "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", - "edited": true, - "reset": false - }, - "darkTheme": { - "default": "#097479", - "baseColor": "#097479", - "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", - "edited": false - }, - "customTheme": { - "name": "Untitled Theme 1", - "default": "#4B7930", - "baseColor": "#4B7930", - "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", - "reset": false - }, - "themeState": { - "base-color": { - "default": "#0094CE", - "value": "#0094CE", - "edited": false - }, - "page-titlebar-backgroundColor": { - "value": "#0094CE", - "edited": false - }, - "page-backgroundColor": { - "value": "#fafafa", - "edited": false - }, - "page-sidebar-backgroundColor": { - "value": "#ffffff", - "edited": false - }, - "group-textColor": { - "value": "#1bbfff", - "edited": false - }, - "group-borderColor": { - "value": "#ffffff", - "edited": false - }, - "group-backgroundColor": { - "value": "#ffffff", - "edited": false - }, - "widget-textColor": { - "value": "#111111", - "edited": false - }, - "widget-backgroundColor": { - "value": "#0094ce", - "edited": false - }, - "widget-borderColor": { - "value": "#ffffff", - "edited": false - }, - "base-font": { - "value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif" - } - }, - "angularTheme": { - "primary": "indigo", - "accents": "blue", - "warn": "red", - "background": "grey", - "palette": "light" - } - }, - "site": { - "name": "Node-RED Dashboard", - "hideToolbar": "false", - "allowSwipe": "false", - "lockMenu": "false", - "allowTempTheme": "true", - "dateFormat": "DD/MM/YYYY", - "sizes": { - "sx": 48, - "sy": 48, - "gx": 6, - "gy": 6, - "cx": 6, - "cy": 6, - "px": 0, - "py": 0 - } - } - }, - { - "id": "3290bd5996bd3175", - "type": "ui_group", - "name": "Easy Input", - "tab": "157862d37ae585b5", - "order": 3, - "disp": true, - "width": 13, - "collapse": false, - "className": "" - }, - { - "id": "d610b26df84f336e", - "type": "ui_group", - "name": "Calibration Charge", - "tab": "157862d37ae585b5", - "order": 1, - "disp": true, - "width": "13", - "collapse": false, - "className": "" - }, - { - "id": "1c76b68292d58d7a", - "type": "victron-input-custom", - "z": "e2588b9d824334f7", - "service": "com.victronenergy.battery/1", - "path": "/TimeToTOCRequest", - "serviceObj": { - "service": "com.victronenergy.battery/1", - "name": "FZS 48TL200 x2 (1)" - }, - "pathObj": { - "path": "/TimeToTOCRequest", - "name": "/TimeToTOCRequest", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 580, - "y": 280, - "wires": [ - [ - "b18eaae1b2cf532a" - ] - ] - }, - { - "id": "374a9784b13e6b91", - "type": "ui_switch", - "z": "e2588b9d824334f7", - "name": "Start Calibration Charge Now", - "label": "Start Calibration Charge Now", - "tooltip": "", - "group": "d610b26df84f336e", - "order": 5, - "width": 0, - "height": 0, - "passthru": true, - "decouple": "false", - "topic": "#:(file)::start_calibration_charge_now_button", - "topicType": "global", - "style": "", - "onvalue": "true", - "onvalueType": "bool", - "onicon": "", - "oncolor": "", - "offvalue": "false", - "offvalueType": "bool", - "officon": "", - "offcolor": "", - "animate": false, - "className": "", - "x": 2440, - "y": 100, - "wires": [ - [ - "0eda66dbeeaa1361", - "ff621c398de790e9" - ] - ] - }, - { - "id": "0eda66dbeeaa1361", - "type": "switch", - "z": "e2588b9d824334f7", - "name": "Button is on", - "property": "payload", - "propertyType": "msg", - "rules": [ - { - "t": "true" - } - ], - "checkall": "true", - "repair": false, - "outputs": 1, - "x": 2670, - "y": 100, - "wires": [ - [ - "38a3f85186c86064" - ] - ] - }, - { - "id": "e6c8eb42a10e21a3", - "type": "switch", - "z": "e2588b9d824334f7", - "name": "Need to do calibration charge or not", - "property": "payload", - "propertyType": "msg", - "rules": [ - { - "t": "eq", - "v": "0", - "vt": "num" - }, - { - "t": "eq", - "v": "1", - "vt": "num" - }, - { - "t": "else" - } - ], - "checkall": "true", - "repair": false, - "outputs": 3, - "x": 1620, - "y": 240, - "wires": [ - [ - "a0d686b515f76cae", - "65fc8a93c348bd1e", - "7404973d10f3a10a", - "644fe572f173602e" - ], - [ - "e3e9b1f4b7cabc16", - "8678a63acdb5ee29", - "985f0a278ffd922c" - ], - [ - "0eda2d25df727b9a", - "ce4254f159092244" - ] - ] - }, - { - "id": "3ff4ceaaebe9defb", - "type": "ui_text", - "z": "e2588b9d824334f7", - "group": "d610b26df84f336e", - "order": 2, - "width": 0, - "height": 0, - "name": "Time To Calibration Charge", - "label": "Time To Calibration Charge", - "format": "{{msg.payload}}", - "layout": "row-spread", - "className": "", - "style": false, - "font": "", - "fontSize": 16, - "color": "#000000", - "x": 3360, - "y": 420, - "wires": [] - }, - { - "id": "0b6f77eecb110736", - "type": "ui_text_input", - "z": "e2588b9d824334f7", - "name": "Calibration Charge Start Time (hh:mm)", - "label": "Calibration Charge Start Time (hh:mm:ss.sss)", - "tooltip": "", - "group": "d610b26df84f336e", - "order": 4, - "width": 0, - "height": 0, - "passthru": true, - "mode": "time", - "delay": "0", - "topic": "#:(file)::calibration_charge_start_time", - "sendOnBlur": false, - "className": "", - "topicType": "global", - "x": 510, - "y": 80, - "wires": [ - [ - "f32edc8e22e6c4a6" - ] - ] - }, - { - "id": "ff621c398de790e9", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::start_calibration_charge_now_button", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2780, - "y": 40, - "wires": [ - [ - "8cd49df4ce393b99" - ] - ] - }, - { - "id": "8cd49df4ce393b99", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for calibration button", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": true, - "complete": "payload", - "targetType": "msg", - "statusVal": "payload", - "statusType": "auto", - "x": 3180, - "y": 40, - "wires": [] - }, - { - "id": "f32edc8e22e6c4a6", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::calibration_charge_start_time", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 900, - "y": 80, - "wires": [ - [ - "c2e5b1ab69e8b817" - ] - ] - }, - { - "id": "38a3f85186c86064", - "type": "change", - "z": "e2588b9d824334f7", - "name": "Set \"Calibration charge now\" to Time To CalibrationCharge", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "Calibration charge now", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 3010, - "y": 100, - "wires": [ - [ - "3ff4ceaaebe9defb" - ] - ] - }, - { - "id": "28b4fe5478e59dcc", - "type": "victron-input-custom", - "z": "e2588b9d824334f7", - "service": "com.victronenergy.settings", - "path": "/Settings/Controller/LastEOC", - "serviceObj": { - "service": "com.victronenergy.settings", - "name": "com.victronenergy.settings" - }, - "pathObj": { - "path": "/Settings/Controller/LastEOC", - "name": "/Settings/Controller/LastEOC", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 530, - "y": 360, - "wires": [ - [ - "c08993a9535559b7", - "5909342727c04466" - ] - ] - }, - { - "id": "7404973d10f3a10a", - "type": "change", - "z": "e2588b9d824334f7", - "name": "Get current timestamp to update LastEoc", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "", - "tot": "date" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2140, - "y": 200, - "wires": [ - [ - "466d0ead739c355d" - ] - ] - }, - { - "id": "f0b91188bb162f98", - "type": "victron-output-custom", - "z": "e2588b9d824334f7", - "service": "com.victronenergy.settings", - "path": "/Settings/Controller/LastEOC", - "serviceObj": { - "service": "com.victronenergy.settings", - "name": "com.victronenergy.settings" - }, - "pathObj": { - "path": "/Settings/Controller/LastEOC", - "name": "/Settings/Controller/LastEOC", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 2790, - "y": 200, - "wires": [] - }, - { - "id": "466d0ead739c355d", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Millisecond_to_second", - "func": "current_timestamp_in_second=Math.floor(msg.payload/1000);\nmsg.payload = current_timestamp_in_second;\nreturn msg;", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 2440, - "y": 200, - "wires": [ - [ - "f0b91188bb162f98" - ] - ] - }, - { - "id": "7339dc97983bb77b", - "type": "comment", - "z": "e2588b9d824334f7", - "name": "EOC reached ", - "info": "", - "x": 1890, - "y": 220, - "wires": [] - }, - { - "id": "0fff2085b1eb8dcb", - "type": "comment", - "z": "e2588b9d824334f7", - "name": "Do calibration charge now", - "info": "", - "x": 2230, - "y": 400, - "wires": [] - }, - { - "id": "ed2bb3eadfa27747", - "type": "comment", - "z": "e2588b9d824334f7", - "name": "Still some time left to do calibration charge", - "info": "", - "x": 2160, - "y": 480, - "wires": [] - }, - { - "id": "615bdf17da1a6422", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for calibration charge function", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": true, - "complete": "payload", - "targetType": "msg", - "statusVal": "payload.count", - "statusType": "auto", - "x": 1630, - "y": 140, - "wires": [] - }, - { - "id": "8678a63acdb5ee29", - "type": "victron-output-custom", - "z": "e2588b9d824334f7", - "service": "com.victronenergy.hub4/0", - "path": "/Overrides/ForceCharge", - "serviceObj": { - "service": "com.victronenergy.hub4/0", - "name": "com.victronenergy.hub4 (0)" - }, - "pathObj": { - "path": "/Overrides/ForceCharge", - "name": "/Overrides/ForceCharge", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 2620, - "y": 460, - "wires": [] - }, - { - "id": "65fc8a93c348bd1e", - "type": "change", - "z": "e2588b9d824334f7", - "name": "EOC reached", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "EOC reached", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2060, - "y": 240, - "wires": [ - [ - "3ff4ceaaebe9defb" - ] - ] - }, - { - "id": "e3e9b1f4b7cabc16", - "type": "change", - "z": "e2588b9d824334f7", - "name": "Calibration charge now", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "Calibration charge now", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2520, - "y": 420, - "wires": [ - [ - "3ff4ceaaebe9defb" - ] - ] - }, - { - "id": "b077a48ff0831b2a", - "type": "ui_dropdown", - "z": "e2588b9d824334f7", - "name": "Calibration Charge Day", - "label": "Calibration Charge Day", - "tooltip": "", - "place": "", - "group": "d610b26df84f336e", - "order": 3, - "width": 0, - "height": 0, - "passthru": true, - "multiple": false, - "options": [ - { - "label": "Sunday", - "value": 0, - "type": "num" - }, - { - "label": "Monday", - "value": 1, - "type": "num" - }, - { - "label": "Tuesday", - "value": 2, - "type": "num" - }, - { - "label": "Wednesday", - "value": 3, - "type": "num" - }, - { - "label": "Thursday", - "value": 4, - "type": "num" - }, - { - "label": "Friday", - "value": 5, - "type": "num" - }, - { - "label": "Saturday", - "value": 6, - "type": "num" - } - ], - "payload": "", - "topic": "#:(file)::calibration_charge_weekday", - "topicType": "global", - "className": "", - "x": 510, - "y": 180, - "wires": [ - [ - "10605f48b99030d0" - ] - ] - }, - { - "id": "10605f48b99030d0", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::calibration_charge_start_weekday", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 890, - "y": 180, - "wires": [ - [ - "c2e5b1ab69e8b817" - ] - ] - }, - { - "id": "b18eaae1b2cf532a", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::TimeToTOC", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 960, - "y": 280, - "wires": [ - [ - "c2e5b1ab69e8b817" - ] - ] - }, - { - "id": "c08993a9535559b7", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for LastEOC", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "statusVal": "", - "statusType": "auto", - "x": 1010, - "y": 440, - "wires": [] - }, - { - "id": "6a3d4d1cb2651151", - "type": "inject", - "z": "e2588b9d824334f7", - "name": "", - "props": [ - { - "p": "payload" - }, - { - "p": "topic", - "vt": "str" - } - ], - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": 0.1, - "topic": "", - "payload": "#:(file)::calibration_charge_start_time", - "payloadType": "global", - "x": 150, - "y": 80, - "wires": [ - [ - "0b6f77eecb110736" - ] - ] - }, - { - "id": "fdd85619255f4e81", - "type": "inject", - "z": "e2588b9d824334f7", - "name": "", - "props": [ - { - "p": "payload" - }, - { - "p": "topic", - "vt": "str" - } - ], - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": 0.1, - "topic": "", - "payload": "#:(file)::calibration_charge_start_weekday", - "payloadType": "global", - "x": 160, - "y": 180, - "wires": [ - [ - "b077a48ff0831b2a" - ] - ] - }, - { - "id": "761a8f1f11727873", - "type": "inject", - "z": "e2588b9d824334f7", - "name": "", - "props": [ - { - "p": "payload" - }, - { - "p": "topic", - "vt": "str" - } - ], - "repeat": "5", - "crontab": "", - "once": true, - "onceDelay": "0", - "topic": "", - "payload": "#:(file)::start_calibration_charge_now_button", - "payloadType": "global", - "x": 2030, - "y": 80, - "wires": [ - [ - "374a9784b13e6b91" - ] - ] - }, - { - "id": "c2e5b1ab69e8b817", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Cal time left to do calibration charge", - "func": "// Get minutes per day\nvar minutes_per_day = 1440;\n\n// Battery setting\nmax_day_wihthout_EOC = 7;\nmax_minutes_without_EOC = max_day_wihthout_EOC*minutes_per_day;\n\n// Get TimeToTOC which stores minutes from last EOC reached\ntime_to_TOC=global.get('TimeToTOC','file');\n\nif (time_to_TOC ==0){//EOC reahced\n msg.payload=0;\n return msg;\n}\n\n// Get calibration charge time (hh:mm) from user setting\nif(global.get('calibration_charge_start_time','file')!= null){\n minutes_from_midnight_calibration_charge = Math.floor(global.get('calibration_charge_start_time','file'));\n}else{\n minutes_from_midnight_calibration_charge = 0;//default value from midnight\n}\n\n// Get calibration charge weekday from user setting\nif(global.get('calibration_charge_start_weekday','file')!=null){\n weekday_calibration_charge = global.get('calibration_charge_start_weekday','file');\n}else{\n weekday_calibration_charge = 0;//default value from Sunday\n}\n\nfunction nextScheduleDay(adate, w) {\n var daysToAdd = (w - adate.getDay() + 7) % 7;\n var nextDate = new Date(adate);\n nextDate.setDate(adate.getDate() + daysToAdd);\n nextDate.setHours(0);\n nextDate.setMinutes(0);\n nextDate.setSeconds(0);\n return nextDate;\n}\n\n\nfunction chargeWindows(currentTime, weekday, starttime, timeToTOC) {\n var d1 = nextScheduleDay(currentTime, weekday);\n\n // Convert starttime to a Date object\n var startTime = new Date(starttime);\n\n // Calculate the next ScheduleDay considering if the sum of timeToTOC and timeLeftMinutes is less than 7 days\n var timeLeftMinutes = Math.ceil((d1.getTime() - currentTime.getTime() + starttime) / (1000 * 60));\n\n if (timeToTOC + timeLeftMinutes < max_minutes_without_EOC) {\n // If the sum is less than 7 days, push next ScheduleDay to next week\n d1.setDate(d1.getDate() + 7);\n }\n\n var startDateTimeD1 = new Date(d1);\n startDateTimeD1.setHours(startTime.getUTCHours(), startTime.getUTCMinutes(), 0, 0);\n\n // Check if current time is within the charge window\n if (currentTime < startDateTimeD1) {\n // Calculate time left until the end of the window\n var timeLeftMillis = startDateTimeD1 - currentTime;\n var daysLeft = Math.floor(timeLeftMillis / (1000 * 60 * 60 * 24));\n var hoursLeft = Math.floor((timeLeftMillis % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n var minutesLeft = Math.ceil((timeLeftMillis % (1000 * 60 * 60)) / (1000 * 60));\n \n days_str = (daysLeft > 0) ? (daysLeft + \"d\") : \"\";\n hours_str = (hoursLeft > 0) ? (hoursLeft + \"h\") : \"\";\n minutes_str = (minutesLeft > 0) ? (minutesLeft + \"m\") : \"\";\n \n time_to_calibration_str = days_str+hours_str+minutes_str;\n\n return time_to_calibration_str;\n } else {\n return 1;\n }\n}\n\nvar today = new Date(); // Assuming today's date\nvar timeLeft = chargeWindows(today, weekday_calibration_charge, minutes_from_midnight_calibration_charge, time_to_TOC);\n\nmsg.payload = timeLeft;\nreturn msg;", - "outputs": 1, - "timeout": "", - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 1280, - "y": 200, - "wires": [ - [ - "615bdf17da1a6422", - "e6c8eb42a10e21a3" - ] - ] - }, - { - "id": "a0d686b515f76cae", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Turn off calibration charge now button when EOC", - "func": "if(global.get('start_calibration_charge_now_button','file')==true)\n{\n msg.payload = false;\n}else{\n msg.payload = false;\n}\n\nreturn msg;\n\n", - "outputs": 1, - "timeout": "", - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 2090, - "y": 140, - "wires": [ - [ - "374a9784b13e6b91" - ] - ] - }, - { - "id": "0eda2d25df727b9a", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Check whether the calibration charge now button is on", - "func": "if(global.get('start_calibration_charge_now_button','file')==true)\n{\n text= \"Calibration charge now\";\n}else{\n text = msg.payload;\n}\nmsg.payload = text;\n\nreturn msg;\n", - "outputs": 1, - "timeout": 0, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 2620, - "y": 560, - "wires": [ - [ - "3ff4ceaaebe9defb" - ] - ] - }, - { - "id": "5909342727c04466", - "type": "change", - "z": "e2588b9d824334f7", - "name": "LastEOC", - "rules": [ - { - "t": "set", - "p": "#:(file)::LastEOC", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 1000, - "y": 360, - "wires": [ - [ - "c2e5b1ab69e8b817" - ] - ] - }, - { - "id": "44264437fe17f23f", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Cal time left to do calibration charge_backup1", - "func": "// Get minutes per day\nvar minutes_per_day = 1440;\n\n// Battery setting\nmax_day_wihthout_EOC = 7;\nmax_minutes_without_EOC = max_day_wihthout_EOC*minutes_per_day;\n\n// Get TimeToTOC which stores minutes from last EOC reached\ntime_to_TOC=global.get('TimeToTOC','file');\n//time_to_TOC=global.get('TimeToTOC');\n\nif (time_to_TOC ==0){//EOC reahced\n msg.payload=0;\n return msg;\n}\n\n// Get calibration charge time (hh:mm) from user setting\nif(global.get('calibration_charge_start_time','file')!= null){\n minutes_from_midnight_calibration_charge = Math.floor(global.get('calibration_charge_start_time','file')/1000/60);\n}else{\n minutes_from_midnight_calibration_charge = 0;//default value from midnight\n}\n\n// Get calibration charge weekday from user setting\nif(global.get('calibration_charge_start_weekday','file')!=null){\n weekday_calibration_charge = global.get('calibration_charge_start_weekday','file');\n}else{\n weekday_calibration_charge = 0;//default value from Sunday\n}\n\n// Get today's date\nvar today = new Date();\n\n// Find the current day of the week (0 = Sunday, 1 = Monday, ..., 6 = Saturday)\nvar currentDay = today.getDay();\nvar minutes_from_today_midnight = today.getHours()*60+today.getMinutes();\n\n// Calculate the number of days and minutes until next calibration weekday\nvar weekday_diff = weekday_calibration_charge - currentDay;\nvar minutes_diff = minutes_from_midnight_calibration_charge - minutes_from_today_midnight;\n\nif (weekday_diff < 0) {\n weekday_diff += 7; \n}\n\nif(weekday_diff==0 && minutes_diff<0){\n weekday_diff += 7;\n}\n\n// Calculate time difference in minutes from now to the set calibration charge time\nminutes_diff_all_from_now_to_calibration=weekday_diff*minutes_per_day+minutes_diff;\n\n// Calculate time difference in minutes from LastEOC to the set calibration charge time\nminutes_diff_all_from_LastEOC_to_calibration = time_to_TOC+ minutes_diff_all_from_now_to_calibration;\n\n// Set the time to next calibration time\nvar nextCalibrationDate = new Date(today);\nvar_setHours = Math.floor(minutes_from_midnight_calibration_charge/60);\nvar_setMinutes = minutes_from_midnight_calibration_charge - var_setHours*60;\n\nif(minutes_diff_all_from_LastEOC_to_calibration=minutes_fromLastEOCtoNextCalibrationTimestamp){// need to do first time calibration charge;if the calibration setting is too close to last EOC time, then skip the first time and do it next week\n msg.payload =1;\n return msg;\n}else{\n time_left_minutes_all = minutes_diff_all_from_now_to_calibration;\n time_left_days = Math.floor(time_left_minutes_all/60/24);\n time_left_days_display = time_left_days + (minutes_diff_all_from_LastEOC_to_calibration 0) ? (time_left_days_display + \"d\") : \"\";\n hours_str = (time_left_hours > 0) ? (time_left_hours + \"h\") : \"\";\n minutes_str = (time_left_minutes > 0) ? (time_left_minutes + \"m\") : \"\";\n \n time_to_calibration_str = days_str+hours_str+minutes_str;\n msg.payload=time_to_calibration_str;\n}\n\nreturn msg;", - "outputs": 1, - "timeout": "", - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 560, - "wires": [ - [] - ] - }, - { - "id": "011bad015cb995db", - "type": "function", - "z": "e2588b9d824334f7", - "name": "Cal time left to do calibration charge_backup2", - "func": "// Get minutes per day\nvar minutes_per_day = 1440;\n\n// Battery setting\nmax_day_wihthout_EOC = 7;\nmax_minutes_without_EOC = max_day_wihthout_EOC*minutes_per_day;\n\n// Get TimeToTOC which stores minutes from last EOC reached\ntime_to_TOC=global.get('TimeToTOC','file');\n\nif (time_to_TOC ==0){//EOC reahced\n msg.payload=0;\n return msg;\n}\n\n// Get calibration charge time (hh:mm) from user setting\nif(global.get('calibration_charge_start_time','file')!= null){\n minutes_from_midnight_calibration_charge = Math.floor(global.get('calibration_charge_start_time','file'));\n}else{\n minutes_from_midnight_calibration_charge = 0;//default value from midnight\n}\n\n// Get calibration charge weekday from user setting\nif(global.get('calibration_charge_start_weekday','file')!=null){\n weekday_calibration_charge = global.get('calibration_charge_start_weekday','file');\n}else{\n weekday_calibration_charge = 0;//default value from Sunday\n}\n\nfunction nextScheduleDay(adate, w) {\n w = w % 7;\n var daysToAdd = (w - adate.getDay() - 1 + 7) % 7;\n var nextDate = new Date(adate);\n nextDate.setDate(adate.getDate() + daysToAdd);\n return nextDate;\n}\n\nfunction prevScheduleDay(adate, w) {\n w = w % 7;\n var daysToSubtract = (adate.getDay() + 7 - w) % 7 + 1;\n var prevDate = new Date(adate);\n prevDate.setDate(adate.getDate() - daysToSubtract);\n return prevDate;\n}\n\nfunction chargeWindows(currentTime, weekday, starttime, timeToTOC) {\n var d0 = prevScheduleDay(currentTime, weekday);\n var d1 = nextScheduleDay(currentTime, weekday);\n\n // Convert starttime to a Date object\n var startTime = new Date(starttime);\n\n // Calculate the next ScheduleDay considering if the sum of timeToTOC and timeLeftMinutes is less than 7 days\n var timeLeftMinutes = Math.ceil((d1.getTime() - currentTime.getTime() + starttime) / (1000 * 60));\n\n if (timeToTOC + timeLeftMinutes < max_minutes_without_EOC) {\n // If the sum is less than 7 days, push next ScheduleDay to next week\n d1.setDate(d1.getDate() + 7);\n }\n\n // Set the start time for d0 and d1\n var startDateTimeD0 = new Date(d0);\n startDateTimeD0.setHours(startTime.getUTCHours(), startTime.getUTCMinutes(), 0, 0);\n\n var startDateTimeD1 = new Date(d1);\n startDateTimeD1.setHours(startTime.getUTCHours(), startTime.getUTCMinutes(), 0, 0);\n\n // Check if current time is within the charge window\n if (currentTime >= startDateTimeD0 && currentTime < startDateTimeD1) {\n // Calculate time left until the end of the window\n var timeLeftMillis = startDateTimeD1 - currentTime;\n var daysLeft = Math.floor(timeLeftMillis / (1000 * 60 * 60 * 24));\n var hoursLeft = Math.floor((timeLeftMillis % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n var minutesLeft = Math.ceil((timeLeftMillis % (1000 * 60 * 60)) / (1000 * 60));\n\n return daysLeft + 'd' + hoursLeft + 'h' + minutesLeft + 'm';\n } else {\n return 1;\n }\n}\n\nvar today = new Date(); // Assuming today's date\nvar timeLeft = chargeWindows(today, weekday_calibration_charge, minutes_from_midnight_calibration_charge, time_to_TOC);\n\nmsg.payload = timeLeft;\n\nreturn msg;", - "outputs": 1, - "timeout": "", - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 600, - "y": 640, - "wires": [ - [] - ] - }, - { - "id": "985f0a278ffd922c", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::start_calibration_charge_now", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2580, - "y": 500, - "wires": [ - [ - "3dc0dde6cbbd97c0" - ] - ] - }, - { - "id": "de6a4357e8a1f15c", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::start_calibration_charge_now", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2900, - "y": 620, - "wires": [ - [ - "3e2692e252d4b7ce" - ] - ] - }, - { - "id": "ce4254f159092244", - "type": "change", - "z": "e2588b9d824334f7", - "name": "set start_calibration_charge_now to 0", - "rules": [ - { - "t": "set", - "p": "payload", - "pt": "msg", - "to": "0", - "tot": "num" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2570, - "y": 620, - "wires": [ - [ - "de6a4357e8a1f15c" - ] - ] - }, - { - "id": "644fe572f173602e", - "type": "change", - "z": "e2588b9d824334f7", - "name": "", - "rules": [ - { - "t": "set", - "p": "#:(file)::start_calibration_charge_now", - "pt": "global", - "to": "payload", - "tot": "msg" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 2440, - "y": 240, - "wires": [ - [ - "d1f75adc62fbfadb" - ] - ] - }, - { - "id": "d1f75adc62fbfadb", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for calibration", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": true, - "complete": "payload", - "targetType": "msg", - "statusVal": "payload", - "statusType": "auto", - "x": 2720, - "y": 240, - "wires": [] - }, - { - "id": "3dc0dde6cbbd97c0", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for calibration", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": true, - "complete": "payload", - "targetType": "msg", - "statusVal": "payload", - "statusType": "auto", - "x": 2860, - "y": 500, - "wires": [] - }, - { - "id": "3e2692e252d4b7ce", - "type": "debug", - "z": "e2588b9d824334f7", - "name": "Debug for calibration", - "active": false, - "tosidebar": true, - "console": false, - "tostatus": true, - "complete": "payload", - "targetType": "msg", - "statusVal": "payload", - "statusType": "auto", - "x": 3180, - "y": 620, - "wires": [] - }, - { - "id": "edf59fb9886b1048", - "type": "victron-input-custom", - "z": "322b256f0daf33ef", - "service": "com.victronenergy.settings", - "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit", - "serviceObj": { - "service": "com.victronenergy.settings", - "name": "com.victronenergy.settings" - }, - "pathObj": { - "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit", - "name": "/Settings/CGwacs/BatteryLife/MinimumSocLimit", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 310, - "y": 200, - "wires": [ - [ - "e31bd3d3a1c25da5" - ] - ] - }, - { - "id": "e31bd3d3a1c25da5", - "type": "change", - "z": "322b256f0daf33ef", - "name": "min_soc", - "rules": [ - { - "t": "set", - "p": "topic", - "pt": "msg", - "to": "min_soc", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 680, - "y": 200, - "wires": [ - [ - "ec4dfbf95393066c" - ] - ] - }, - { - "id": "e96ae0338cc426e7", - "type": "victron-input-custom", - "z": "322b256f0daf33ef", - "service": "com.victronenergy.battery/1", - "path": "/Dc/0/Power", - "serviceObj": { - "service": "com.victronenergy.battery/1", - "name": "com.victronenergy.battery (1)" - }, - "pathObj": { - "path": "/Dc/0/Power", - "name": "/Dc/0/Power", - "type": "number" - }, - "name": "", - "onlyChanges": false, - "x": 200, - "y": 260, - "wires": [ - [ - "86d2d524dcca3330" - ] - ] - }, - { - "id": "86d2d524dcca3330", - "type": "change", - "z": "322b256f0daf33ef", - "name": "battery_power", - "rules": [ - { - "t": "set", - "p": "topic", - "pt": "msg", - "to": "battery_power", - "tot": "str" - } - ], - "action": "", - "property": "", - "from": "", - "to": "", - "reg": false, - "x": 580, - "y": 260, - "wires": [ - [ - "ec4dfbf95393066c" - ] - ] - }, - { - "id": "c21a992cf80c2d6f", - "type": "function", - "z": "322b256f0daf33ef", - "name": "controller_hold_min_soc_&_charge_to_min_soc&heating", - "func": "// get max charge power\nif(msg.payload.max_configured_charge_power==null ||msg.payload.max_configured_charge_power<0){\n max_charge_power=msg.payload.max_battery_charge_power;\n}else{\n max_charge_power=Math.min(msg.payload.max_configured_charge_power,msg.payload.max_battery_charge_power);\n}\n\nmax_inverter_power = msg.payload.num_phases*msg.payload.inverter_power;\n\n// variables for hold_min_soc controller\nBatterySelfDischargePower=200;//W\nn_batteries=msg.payload.num_batteries;\nHoldSocZone=1;\na=-2*BatterySelfDischargePower*n_batteries/HoldSocZone;\nb=-a*(msg.payload.min_soc+HoldSocZone);\nP_CONST = 0.5;\n// min soc among batteries\nsoc = msg.payload.lowest_soc;\ntarget_dc_power_to_hold_min_soc=soc*a+b;\n\n// current power setpoint\ninverter_power_setpoint= msg.payload.L1_AcPowerSetpoint+msg.payload.L2_AcPowerSetpoint+msg.payload.L3_AcPowerSetpoint;\n\nAC_in = msg.payload.AC_In;\nAC_out = msg.payload.AC_Out;\nPV_production =msg.payload.PVs_Power;\n\nif(global.get('start_calibration_charge_now_button','file') == true || global.get('start_calibration_charge_now','file')==1){\n d_p = max_charge_power-n_batteries*msg.payload.battery_power;\n power = AC_out+d_p;\n msg.payload.ess_mode =3;\n msg.payload.controller_info = \"Calibrtaion charge\";\n powerperphase=power/3;\n powerperphase=Math.max(powerperphase,-max_inverter_power);\n powerperphase=Math.floor(Math.min(powerperphase,max_inverter_power));\n msg.payload.power=powerperphase;\n return msg;\n}\n\nif(msg.payload.min_soc<=soc&&soc<=msg.payload.min_soc+1){\n d_p = target_dc_power_to_hold_min_soc-n_batteries*msg.payload.battery_power;\n delta = d_p*P_CONST;\n if(msg.payload.grid_setpoint>0){\n power = inverter_power_setpoint+delta;\n msg.payload.ess_mode =1;\n msg.payload.controller_info = \"Hold min SOC - ESS control\";\n }else{\n power = AC_out+delta-PV_production;\n msg.payload.ess_mode =3;\n msg.payload.controller_info = \"Hold min SOC - external control\";\n }\n}else if(soc Battery Monitor \n", - "storeOutMessages": true, - "fwdInMessages": true, - "resendOnRefresh": true, - "templateScope": "local", - "className": "", - "x": 200, - "y": 480, - "wires": [ - [] - ], - "icon": "node-red/arrow-in.svg" - } -] \ No newline at end of file diff --git a/NodeRed/rc.local b/NodeRed/rc.local deleted file mode 100755 index c0c2248a8..000000000 --- a/NodeRed/rc.local +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -mount -o remount,rw / - -# Source directory -source_dir="/data/dbus-fzsonick-48tl" - -# Destination directory -destination_dir_upper="/opt/victronenergy/" -destination_dir="/opt/victronenergy/dbus-fzsonick-48tl/" - -# Check if the destination directory exists -if [ -d "$destination_dir" ]; then - # Remove the destination directory - rm -r "$destination_dir" -fi - -# Copy the contents of the source directory to the destination directory -cp -r "$source_dir" "$destination_dir_upper" - -# Set MPPT network mode to 0 -# sed -i "s|('/Link/NetworkMode', [^)]*)|('/Link/NetworkMode', 0)|g" /opt/victronenergy/dbus-systemcalc-py/delegates/dvcc.py -#sed -i "s|self._get_path('/Settings/BmsPresent') == 1|0|g" /opt/victronenergy/dbus-systemcalc-py/delegates/dvcc.py -sed -i "s/self._set_path('\/Link\/NetworkMode', v)/self._set_path('\/Link\/NetworkMode', 0)\n self._set_path('\/Settings\/BmsPresent',0)/" /opt/victronenergy/dbus-systemcalc-py/delegates/dvcc.py - -exit 0 diff --git a/NodeRed/settings-user.js b/NodeRed/settings-user.js deleted file mode 100644 index d76cdd8ec..000000000 --- a/NodeRed/settings-user.js +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = { - uiHost:"", - /* To password protect the Node-RED editor and admin API, the following - property can be used. See https://nodered.org/docs/security.html for details. - */ - adminAuth: { - sessionExpiryTime: 86400, - type: "credentials", - users: [{ - username: "admin", - password: "$2b$08$d7A0gwkDh4KtultiCAVH6eQ.tQUwVApq.tDVOOYQ51EpLIMbYy2GW",//salidomo - permissions: "*" - }] - }, - - /* Context Storage - The following property can be used to enable context storage. The configuration - provided here will enable file-based context that flushes to disk every 30 seconds. - Refer to the documentation for further options: https://nodered.org/docs/api/context/ - */ - //contextStorage: { - // default: { - // module:"localfilesystem" - // }, - //}, - contextStorage: { - default: "memoryOnly", - memoryOnly: { module: 'memory' }, - file: { module: 'localfilesystem' } - }, - }