From b5d11b2eaea324f4f4fdf5d30d4cff81861d0fc3 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Fri, 10 May 2024 10:59:07 +0200 Subject: [PATCH] Added Node Red folder --- .../__pycache__/config.cpython-38.pyc | Bin 0 -> 890 bytes .../__pycache__/convert.cpython-38.pyc | Bin 0 -> 3339 bytes .../__pycache__/data.cpython-38.pyc | Bin 0 -> 2344 bytes NodeRed/dbus-csv-files/config.py | 51 + NodeRed/dbus-csv-files/convert.py | 119 + NodeRed/dbus-csv-files/data.py | 63 + NodeRed/dbus-csv-files/dbus-csv-files.py | 731 +++ NodeRed/dbus-csv-files/start.sh | 7 + .../__pycache__/config.cpython-38.pyc | Bin 0 -> 894 bytes .../__pycache__/convert.cpython-38.pyc | Bin 0 -> 3343 bytes .../__pycache__/data.cpython-38.pyc | Bin 0 -> 3578 bytes .../dbus-fzsonick-48tl.cpython-38.pyc | Bin 0 -> 12185 bytes NodeRed/dbus-fzsonick-48tl/config.py | 51 + NodeRed/dbus-fzsonick-48tl/convert.py | 119 + NodeRed/dbus-fzsonick-48tl/data.py | 97 + .../dbus-fzsonick-48tl/dbus-fzsonick-48tl.py | 980 +++ .../__pycache__/ve_utils.cpython-38.pyc | Bin 0 -> 6666 bytes .../__pycache__/vedbus.cpython-38.pyc | Bin 0 -> 15886 bytes .../ext/velib_python/ve_utils.py | 276 + .../ext/velib_python/vedbus.py | 614 ++ NodeRed/dbus-fzsonick-48tl/start.sh | 7 + NodeRed/dvcc.py | 1287 ++++ NodeRed/flows.json | 5660 +++++++++++++++++ NodeRed/rc.local | 26 + NodeRed/settings-user.js | 31 + 25 files changed, 10119 insertions(+) create mode 100644 NodeRed/dbus-csv-files/__pycache__/config.cpython-38.pyc create mode 100644 NodeRed/dbus-csv-files/__pycache__/convert.cpython-38.pyc create mode 100644 NodeRed/dbus-csv-files/__pycache__/data.cpython-38.pyc create mode 100644 NodeRed/dbus-csv-files/config.py create mode 100644 NodeRed/dbus-csv-files/convert.py create mode 100644 NodeRed/dbus-csv-files/data.py create mode 100755 NodeRed/dbus-csv-files/dbus-csv-files.py create mode 100755 NodeRed/dbus-csv-files/start.sh create mode 100644 NodeRed/dbus-fzsonick-48tl/__pycache__/config.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/__pycache__/convert.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/__pycache__/data.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/__pycache__/dbus-fzsonick-48tl.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/config.py create mode 100644 NodeRed/dbus-fzsonick-48tl/convert.py create mode 100644 NodeRed/dbus-fzsonick-48tl/data.py create mode 100755 NodeRed/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py create mode 100644 NodeRed/dbus-fzsonick-48tl/ext/velib_python/__pycache__/ve_utils.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/ext/velib_python/__pycache__/vedbus.cpython-38.pyc create mode 100644 NodeRed/dbus-fzsonick-48tl/ext/velib_python/ve_utils.py create mode 100644 NodeRed/dbus-fzsonick-48tl/ext/velib_python/vedbus.py create mode 100755 NodeRed/dbus-fzsonick-48tl/start.sh create mode 100644 NodeRed/dvcc.py create mode 100644 NodeRed/flows.json create mode 100755 NodeRed/rc.local create mode 100644 NodeRed/settings-user.js diff --git a/NodeRed/dbus-csv-files/__pycache__/config.cpython-38.pyc b/NodeRed/dbus-csv-files/__pycache__/config.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8d562df64119ee904d3ca9fd8fc6f3b946f63ae GIT binary patch literal 890 zcmY*X%T60H6rD+C@)!~l!mH4SSb{1NRH_P9Rl&(PVbpmZfCQ`l zM!W8&yKcLy+8Hx#!$_ZO^rXV$o29{rtRh@;j|4za+VDDM9wc z9DWeM6sGnRGX1JVWhs&pGtJUOV;a$!PBMaLS%&0TmgHHE7@>MPLkcWUip(IhY=)FV z<+M!ZSb}S&1yMGFfJG~1{cnPE7Hk9s`YOQI<5N4(s$eCIvzA?-jPS)R+w`@lCJC`Q9`UD&`f zT4?*888k3;fdjd5%PfT2kDxzMWGZd@uxTCeGV+@E!Rn=;Z2~Oo zu*e5C*G*UtT0C!iEo#FKw0WTp5Tw8~eTWb*xgPbJ)Q2q#i4w%;9B@F94LWx@MjQAR zKBS&$^32rdS!_Ab3oy^uL10o}Y%1JCu{E-Ya1r*}v~FR1{>na zCh+HZEjb_69=w&Bd|8{_;pOH@+zW@bZqh%Aqw4t;&%HTI*hMdVE8pPom4KX@(==5} Vi8&p*WEpLu=$Ttp)ujG^`~}~$@(2I` literal 0 HcmV?d00001 diff --git a/NodeRed/dbus-csv-files/__pycache__/convert.cpython-38.pyc b/NodeRed/dbus-csv-files/__pycache__/convert.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da5f1680c44f5491eff1378ef3ed60314e6cc27d GIT binary patch literal 3339 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 Callable[[BatteryStatus], bool] + + def get_value(status): + # type: (BatteryStatus) -> bool + value = status.modbus_data[register - cfg.BASE_ADDRESS] + return value & (1 << bit) > 0 + + return get_value + + +def read_float(register, scale_factor=1.0, offset=0.0, places=2): + # type: (int, float, float) -> Callable[[BatteryStatus], float] + + def get_value(status): + # type: (BatteryStatus) -> float + value = status.modbus_data[register - cfg.BASE_ADDRESS] + + if value >= 0x8000: # convert to signed int16 + value -= 0x10000 # fiamm stores their integers signed AND with sign-offset @#%^&! + + result = (value+offset)*scale_factor + return round(result,places) + + return get_value + + +def read_hex_string(register, count): + # type: (int, int) -> Callable[[BatteryStatus], str] + """ + reads count consecutive modbus registers from start_address, + and returns a hex representation of it: + e.g. for count=4: DEAD BEEF DEAD BEEF. + """ + start = register - cfg.BASE_ADDRESS + end = start + count + + def get_value(status): + # type: (BatteryStatus) -> str + return ' '.join(['{0:0>4X}'.format(x) for x in status.modbus_data[start:end]]) + + return get_value + + +def read_led_state(register, led): + # type: (int, int) -> Callable[[BatteryStatus], int] + + read_lo = read_bool(register, led * 2) + read_hi = read_bool(register, led * 2 + 1) + + def get_value(status): + # type: (BatteryStatus) -> int + + lo = read_lo(status) + hi = read_hi(status) + + if hi: + if lo: + return LedState.blinking_fast + else: + return LedState.blinking_slow + else: + if lo: + return LedState.on + else: + return LedState.off + + return get_value + + +def read_bitmap(register): + # type: (int) -> Callable[[BatteryStatus], bitmap] + + def get_value(status): + # type: (BatteryStatus) -> bitmap + value = status.modbus_data[register - cfg.BASE_ADDRESS] + return value + + return get_value + + +def append_unit(unit): + # type: (unicode) -> Callable[[unicode], unicode] + + def get_text(v): + # type: (unicode) -> unicode + return "{0}{1}".format(str(v), unit) + + return get_text + + +def mean(numbers): + # type: (Iterable[float] | Iterable[int]) -> float + return float("{:.2f}".format(float(sum(numbers)) / len(numbers))) + +def ssum(numbers): + # type: (Iterable[float] | Iterable[int]) -> float + return float("{:.2f}".format(float(sum(numbers)))) + + +def first(ts): + return next(t for t in ts) + +def return_in_list(ts): + return ts + + diff --git a/NodeRed/dbus-csv-files/data.py b/NodeRed/dbus-csv-files/data.py new file mode 100644 index 000000000..6aed587d1 --- /dev/null +++ b/NodeRed/dbus-csv-files/data.py @@ -0,0 +1,63 @@ +import config as cfg +from collections import Iterable + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import Callable + + +class LedState(object): + """ + from page 6 of the '48TLxxx ModBus Protocol doc' + """ + off = 0 + on = 1 + blinking_slow = 2 + blinking_fast = 3 + + +class LedColor(object): + green = 0 + amber = 1 + blue = 2 + red = 3 + + + +class CsvSignal(object): + def __init__(self, name, get_value, get_text = None): + self.name = name + self.get_value = get_value if callable(get_value) else lambda _: get_value + self.get_text = get_text + + if get_text is None: + self.get_text = "" + +class Battery(object): + + """ Data record to hold hardware and firmware specs of the battery """ + + def __init__(self, slave_address, hardware_version, firmware_version, bms_version, ampere_hours): + # type: (int, str, str, str, int) -> None + self.slave_address = slave_address + self.hardware_version = hardware_version + self.firmware_version = firmware_version + self.bms_version = bms_version + self.ampere_hours = ampere_hours + + + def __str__(self): + return 'slave address = {0}\nhardware version = {1}\nfirmware version = {2}\nbms version = {3}\nampere hours = {4}'.format( + self.slave_address, self.hardware_version, self.firmware_version, self.bms_version, str(self.ampere_hours)) + + +class BatteryStatus(object): + """ + record holding the current status of a battery + """ + def __init__(self, battery, modbus_data): + # type: (Battery, list[int]) -> None + + self.battery = battery + self.modbus_data = modbus_data diff --git a/NodeRed/dbus-csv-files/dbus-csv-files.py b/NodeRed/dbus-csv-files/dbus-csv-files.py new file mode 100755 index 000000000..c1d4bd86f --- /dev/null +++ b/NodeRed/dbus-csv-files/dbus-csv-files.py @@ -0,0 +1,731 @@ +#! /usr/bin/python3 -u +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, 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 + +class S3config: + def __init__(self): + self.bucket = "1-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e" + self.region = "sos-ch-dk-2" + self.provider = "exo.io" + self.key = "EXOcc0e47a4c4d492888ff5a7f2" + self.secret = "79QG4unMh7MeVacMnXr5xGxEyAlWZDIdM-dg_nXFFr4" + self.content_type = "text/plain; 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_service/" +#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 create_csv_signals(firmware_version): + def read_power(status): + return int(read_current(status) * read_voltage(status)) + + 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 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_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 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)) + + def return_led_state_blue(status): + led_state = c.read_led_state(register=1004, led=LedColor.blue)(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_red(status): + led_state = c.read_led_state(register=1004, led=LedColor.red)(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_green(status): + led_state = c.read_led_state(register=1004, led=LedColor.green)(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_amber(status): + led_state = c.read_led_state(register=1004, led=LedColor.amber)(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" + + 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) + + 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) + #if hex_string_to_ascii(battery_status_string) == "EOC_": + #return True + #return False + return hex_string_to_ascii(battery_status_string) == "EOC_" + + 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 + + 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', c.read_bool(register=1013, bit=0)), + CsvSignal('/Battery/Devices/IoStatus/AlarmOutActive', c.read_bool(register=1013, bit=1)), + 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', c.read_bool(register=1013, bit=4)), + 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', c.read_float(register=1052)), + CsvSignal('/Battery/Devices/MaxChargePower', calc_max_charge_power), + CsvSignal('/Battery/Devices/MaxDischargePower', calc_max_discharge_power), + + # 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)), + + ] + +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 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_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 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 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 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 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 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)] + 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): + 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) + + #installation_name = get_installation_name(INSTALLATION_NAME_FILE) + 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): + for s in signals: + signal_name = insert_id(s.name, i+1) + #value = serialize_for_csv(s.get_value(statuses[i])) + 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) + + + # Create an S3config instance + s3_config = S3config() + response = s3_config.create_put_request(csv_filename, csv_data) + + 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 update(modbus, batteries, 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') + + statuses = [read_battery_status(modbus, battery) for battery in batteries] + node_numbers = [battery.slave_address for battery in batteries] + + create_csv_files(csv_signals, statuses, node_numbers) + + 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 + +def create_update_task(modbus, batteries, 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 + + alive = update(modbus, batteries, 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 main(argv): + # type: (list[str]) -> () + print("PAME") + 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 + + csv_signals = create_csv_signals(bat.firmware_version) + + main_loop = init_main_loop() # must run before init_dbus because gobject does some global magic + + # we do not use dbus this time. we only want modbus + update_task = create_update_task(modbus, batteries, 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/dbus-csv-files/start.sh b/NodeRed/dbus-csv-files/start.sh new file mode 100755 index 000000000..14a3b0390 --- /dev/null +++ b/NodeRed/dbus-csv-files/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +. /opt/victronenergy/serial-starter/run-service.sh + +app=/opt/victronenergy/dbus-csv-files/dbus-csv-files.py +args="$tty" +start $args diff --git a/NodeRed/dbus-fzsonick-48tl/__pycache__/config.cpython-38.pyc b/NodeRed/dbus-fzsonick-48tl/__pycache__/config.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbfefacfa1b8f8583f75de58d056fe96f9bc43c6 GIT binary patch literal 894 zcmY*X%Tn7g6txpO50j7(UWGn}Wm%9gh3U{vr$mXB;Aw1UEI9>jG!Pk&X^5F5bcU?@ z8|@!-*KK#5{y;26qJT(jk(Q%KbdvO>>{g4+MmS?{O9;6-UAqhrUPNvK-&?)lw@rX$ z9TxeZ#dQONcoex@0MBs_3a3Rn?^afBXgU`tzs& literal 0 HcmV?d00001 diff --git a/NodeRed/dbus-fzsonick-48tl/__pycache__/convert.cpython-38.pyc b/NodeRed/dbus-fzsonick-48tl/__pycache__/convert.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20d66bcb8d1ba60732a3c871574510cf60d83fa8 GIT binary patch literal 3343 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 VR3vQd#QtX7Znfifv;AiK%Fo#2Epz|? literal 0 HcmV?d00001 diff --git a/NodeRed/dbus-fzsonick-48tl/__pycache__/dbus-fzsonick-48tl.cpython-38.pyc b/NodeRed/dbus-fzsonick-48tl/__pycache__/dbus-fzsonick-48tl.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..437a1d771655386b5f05348159698007ee70ed7e GIT binary patch literal 12185 zcma)CTWlQHd7j%|xx9#?sH^3%EK6Ej(l>g|NZld@{jb;{mbIv4P5@$RYhS6Q(a|A z-qj^lJZnoD?wYIDj3q;rZK>KS*qwts2AsAt`g+JU75qMma{YX_GO)($NlsvTZBTsyLKM8Naz(b}=4W1`;Y zK2bZqbX-;JgZqnidhHypmzFhlik-fzF1^goup(MsVb8MXaDSDZWn;L%#>QC*_w#Im zJ&*e**bCML_98on`X|{->}A|9vRBxvxW8_{!CqU}zM}H4+OK1GldQnhpVSU3J1Taf zE-1zG4~ddb{NE;cFhNH2npeJC-}; zI(9t(bY$Lccsy8etqpsY&D*z|womXu*?VTQYBvJMs{^zzdFUFofBI6>zjDoT>Q_Cl zQ8c3dOICoHw-y2`X!=ol!C9$WZj_0^QTD3Mraaf<7%@AGo>s-BbY4hKB(PT!*sBTbwFGuPfqg=(@qpO(1yNVmM#a-70`9MG}?SV`MhrwA#dZ<24-uIbd5Jzld&e$BdFwSC{>TP9#;uxgvD zR-L&{eZ};aO)H8U#SY3FmfMW00YuyUr0EB|h&Pj0&J!e`%XyVGyBdg5 zOstu7i+N|&=C+tquB>j`*k0h76}z)643#cSms_$Jl2FpH@RK)I6I5? zEEfv34uV5@Zk;zRY_IMIRy~MULFmUKBre44&AL z%!~qamj$B>QyUe!*BhnfdZTp1wIF!TKVt;v-~R5SkN6;7tkMweAJq+9`*HarC<5i4 z0`XSwsZ@^OT2MYz$b6%O+gNUFoEzJ)$Dl)+n`0~WrfpT663O(~a_Dmok2bEX7CinRfwDKpD!bC0V zt67_ED!d6mdtpyKeqmj`L(SX_`%BYR5aFiOwvl=hvv;&clqCg1a@X!SB{fc@PKJH* z)f(8(0E?~s(l65QTd)5>eJ0!|-(0k5b6_WKJO4sV z+W6m(K5C+(eCKzj!o#I$$6Bd-e&AI7(k+YEVK6Sc){5_ZAAKGY-xf7aTpOjTkfk6z z*z?MyYw?l6ru>OV@A z>;>1VRhV`0U3C8sTuONS@dRmi43G3I92gA)#Hw5AK#I<>zZx(U>Q=nldcQyW5jDf@Z(>ZY;MlmuC*0D_4xLA zyXFNp?bCjk`Vr&2X`=_fUVoUTPWwvNR%$^BI-Y!+IarTyqz4o;S2!f0Q(hfz5E=h) zurzH`ATcZKW7US>U>Me{24a|e`)QUz24>+$m9ANv(~e(VwfKr13t%{!G)?_Tcr1@$ zz7#J3+%r_V?C{zhi`$)5zG-pR1t~rerKJZK>DA?xD1T%A`t(~0Xv-8(_#u-9~>263d6Ac0T0̛Dj5}0=WsKuU<}^xoDsviCuPB8N26C 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 Callable[[BatteryStatus], bool] + + def get_value(status): + # type: (BatteryStatus) -> bool + value = status.modbus_data[register - cfg.BASE_ADDRESS] + return value & (1 << bit) > 0 + + return get_value + + +def read_float(register, scale_factor=1.0, offset=0.0, places=2): + # type: (int, float, float) -> Callable[[BatteryStatus], float] + + def get_value(status): + # type: (BatteryStatus) -> float + value = status.modbus_data[register - cfg.BASE_ADDRESS] + + if value >= 0x8000: # convert to signed int16 + value -= 0x10000 # fiamm stores their integers signed AND with sign-offset @#%^&! + + result = (value+offset)*scale_factor + return round(result,places) + + return get_value + + +def read_hex_string(register, count): + # type: (int, int) -> Callable[[BatteryStatus], str] + """ + reads count consecutive modbus registers from start_address, + and returns a hex representation of it: + e.g. for count=4: DEAD BEEF DEAD BEEF. + """ + start = register - cfg.BASE_ADDRESS + end = start + count + + def get_value(status): + # type: (BatteryStatus) -> str + return ' '.join(['{0:0>4X}'.format(x) for x in status.modbus_data[start:end]]) + + return get_value + + +def read_led_state(register, led): + # type: (int, int) -> Callable[[BatteryStatus], int] + + read_lo = read_bool(register, led * 2) + read_hi = read_bool(register, led * 2 + 1) + + def get_value(status): + # type: (BatteryStatus) -> int + + lo = read_lo(status) + hi = read_hi(status) + + if hi: + if lo: + return LedState.blinking_fast + else: + return LedState.blinking_slow + else: + if lo: + return LedState.on + else: + return LedState.off + + return get_value + + +def read_bitmap(register): + # type: (int) -> Callable[[BatteryStatus], bitmap] + + def get_value(status): + # type: (BatteryStatus) -> bitmap + value = status.modbus_data[register - cfg.BASE_ADDRESS] + return value + + return get_value + + +def append_unit(unit): + # type: (unicode) -> Callable[[unicode], unicode] + + def get_text(v): + # type: (unicode) -> unicode + return "{0}{1}".format(str(v), unit) + + return get_text + + +def mean(numbers): + # type: (Iterable[float] | Iterable[int]) -> float + return float("{:.2f}".format(float(sum(numbers)) / len(numbers))) + +def ssum(numbers): + # type: (Iterable[float] | Iterable[int]) -> float + return float("{:.2f}".format(float(sum(numbers)))) + + +def first(ts): + return next(t for t in ts) + +def return_in_list(ts): + return ts + + diff --git a/NodeRed/dbus-fzsonick-48tl/data.py b/NodeRed/dbus-fzsonick-48tl/data.py new file mode 100644 index 000000000..05cdd1aa7 --- /dev/null +++ b/NodeRed/dbus-fzsonick-48tl/data.py @@ -0,0 +1,97 @@ +import config as cfg +from collections import Iterable + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import Callable + + +class LedState(object): + """ + from page 6 of the '48TLxxx ModBus Protocol doc' + """ + off = 0 + on = 1 + blinking_slow = 2 + blinking_fast = 3 + + +class LedColor(object): + green = 0 + amber = 1 + blue = 2 + red = 3 + + + +class CsvSignal(object): + def __init__(self, name, get_value, get_text = None): + self.name = name + self.get_value = get_value if callable(get_value) else lambda _: get_value + self.get_text = get_text + + if get_text is None: + self.get_text = "" + +class Signal(object): + + def __init__(self, dbus_path, aggregate, get_value, get_text=None): + # type: (str, Callable[[Iterable[object]],object], Callable[[BatteryStatus],object] | object, Callable[[object],unicode] | object)->None + """ + A Signal holds all information necessary for the handling of a + certain datum (e.g. voltage) published by the battery. + + :param dbus_path: str + object_path on DBus where the datum needs to be published + + :param aggregate: Iterable[object] -> object + function that combines the values of multiple batteries into one. + e.g. sum for currents, or mean for voltages + + :param get_value: (BatteryStatus) -> object + function to extract the datum from the modbus record, + alternatively: a constant + + :param get_text: (object) -> unicode [optional] + function to render datum to text, needed by DBus + alternatively: a constant + """ + + self.dbus_path = dbus_path + self.aggregate = aggregate + self.get_value = get_value if callable(get_value) else lambda _: get_value + self.get_text = get_text if callable(get_text) else lambda _: str(get_text) + + # if no 'get_text' provided use 'default_text' if available, otherwise str() + if get_text is None: + self.get_text = str + + +class Battery(object): + + """ Data record to hold hardware and firmware specs of the battery """ + + def __init__(self, slave_address, hardware_version, firmware_version, bms_version, ampere_hours): + # type: (int, str, str, str, int) -> None + self.slave_address = slave_address + self.hardware_version = hardware_version + self.firmware_version = firmware_version + self.bms_version = bms_version + self.ampere_hours = ampere_hours + + + def __str__(self): + return 'slave address = {0}\nhardware version = {1}\nfirmware version = {2}\nbms version = {3}\nampere hours = {4}'.format( + self.slave_address, self.hardware_version, self.firmware_version, self.bms_version, str(self.ampere_hours)) + + +class BatteryStatus(object): + """ + record holding the current status of a battery + """ + def __init__(self, battery, modbus_data): + # type: (Battery, list[int]) -> None + + self.battery = battery + self.modbus_data = modbus_data diff --git a/NodeRed/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py b/NodeRed/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py new file mode 100755 index 000000000..99c01de4c --- /dev/null +++ b/NodeRed/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py @@ -0,0 +1,980 @@ +#!/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 + +class S3config: + def __init__(self): + self.bucket = "1-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e" + self.region = "sos-ch-dk-2" + self.provider = "exo.io" + self.key = "EXOcc0e47a4c4d492888ff5a7f2" + self.secret = "79QG4unMh7MeVacMnXr5xGxEyAlWZDIdM-dg_nXFFr4" + self.content_type = "text/plain; 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 create_csv_signals(firmware_version): + def read_power(status): + return int(read_current(status) * read_voltage(status)) + + 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 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_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 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)) + + def return_led_state_blue(status): + led_state = c.read_led_state(register=1004, led=LedColor.blue)(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_red(status): + led_state = c.read_led_state(register=1004, led=LedColor.red)(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_green(status): + led_state = c.read_led_state(register=1004, led=LedColor.green)(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_amber(status): + led_state = c.read_led_state(register=1004, led=LedColor.amber)(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" + + 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) + + 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) + #if hex_string_to_ascii(battery_status_string) == "EOC_": + #return True + #return False + return hex_string_to_ascii(battery_status_string) == "EOC_" + + 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 + + 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', c.read_bool(register=1013, bit=0)), + CsvSignal('/Battery/Devices/IoStatus/AlarmOutActive', c.read_bool(register=1013, bit=1)), + 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', c.read_bool(register=1013, bit=4)), + 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', c.read_float(register=1052)), + CsvSignal('/Battery/Devices/MaxChargePower', calc_max_charge_power), + CsvSignal('/Battery/Devices/MaxDischargePower', calc_max_discharge_power), + + # 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)), + + ] + + +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) + + def read_power(status): + return int(read_current(status) * read_voltage(status)) + + read_limb_bitmap = c.read_bitmap(1059) + 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_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 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', min, c.read_float(register=1052)), + 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', c.mean, 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.first, cfg.FIRMWARE_VERSION, firmware_version), + 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('/Diagnostics/WarningFlags', c.first, c.read_hex_string(register=1005, count=4)), + 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/TCMM', c.return_in_list, c.read_bool(register=1005, bit=39)), + 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('/Diagnostics/AlarmFlags', c.first, c.read_hex_string(register=1009, count=4)), + 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/TcBM', c.return_in_list, c.read_bool(register=1005, bit=36)), + #Signal('/AlarmFlags/BRNF', c.return_in_list, c.read_bool(register=1005, bit=37)), + 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, c.read_bool(register=1013, bit=0)), + Signal('/Diagnostics/IoStatus/AlarmOutActive', c.return_in_list, c.read_bool(register=1013, bit=1)), + 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, c.read_bool(register=1013, bit=4)), + 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) + + +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') + + statuses = [read_battery_status(modbus, battery) for battery in batteries] + node_numbers = [battery.slave_address for battery in batteries] + + publish_values(dbus, signals, statuses) + create_csv_files(csv_signals, statuses, node_numbers) + + 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 + + +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 + + alive = update(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)] + 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): + 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) + + #installation_name = get_installation_name(INSTALLATION_NAME_FILE) + 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): + for s in signals: + signal_name = insert_id(s.name, i+1) + #value = serialize_for_csv(s.get_value(statuses[i])) + 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) + + + # Create an S3config instance + s3_config = S3config() + response = s3_config.create_put_request(csv_filename, csv_data) + + 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/dbus-fzsonick-48tl/ext/velib_python/__pycache__/ve_utils.cpython-38.pyc b/NodeRed/dbus-fzsonick-48tl/ext/velib_python/__pycache__/ve_utils.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc9881466c4ad2fc120422fe0c5135a3509929db GIT binary patch literal 6666 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 literal 0 HcmV?d00001 diff --git a/NodeRed/dbus-fzsonick-48tl/ext/velib_python/ve_utils.py b/NodeRed/dbus-fzsonick-48tl/ext/velib_python/ve_utils.py new file mode 100644 index 000000000..f5a2f85a0 --- /dev/null +++ b/NodeRed/dbus-fzsonick-48tl/ext/velib_python/ve_utils.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys +from traceback import print_exc +from os import _exit as os_exit +from os import statvfs +from subprocess import check_output, CalledProcessError +import logging +import dbus +logger = logging.getLogger(__name__) + +VEDBUS_INVALID = dbus.Array([], signature=dbus.Signature('i'), variant_level=1) + +class NoVrmPortalIdError(Exception): + pass + +# Use this function to make sure the code quits on an unexpected exception. Make sure to use it +# when using GLib.idle_add and also GLib.timeout_add. +# Without this, the code will just keep running, since GLib does not stop the mainloop on an +# exception. +# Example: GLib.idle_add(exit_on_error, myfunc, arg1, arg2) +def exit_on_error(func, *args, **kwargs): + try: + return func(*args, **kwargs) + except: + try: + print ('exit_on_error: there was an exception. Printing stacktrace will be tried and then exit') + print_exc() + except: + pass + + # sys.exit() is not used, since that throws an exception, which does not lead to a program + # halt when used in a dbus callback, see connection.py in the Python/Dbus libraries, line 230. + os_exit(1) + + +__vrm_portal_id = None +def get_vrm_portal_id(): + # The original definition of the VRM Portal ID is that it is the mac + # address of the onboard- ethernet port (eth0), stripped from its colons + # (:) and lower case. This may however differ between platforms. On Venus + # the task is therefore deferred to /sbin/get-unique-id so that a + # platform specific method can be easily defined. + # + # If /sbin/get-unique-id does not exist, then use the ethernet address + # of eth0. This also handles the case where velib_python is used as a + # package install on a Raspberry Pi. + # + # On a Linux host where the network interface may not be eth0, you can set + # the VRM_IFACE environment variable to the correct name. + + global __vrm_portal_id + + if __vrm_portal_id: + return __vrm_portal_id + + portal_id = None + + # First try the method that works if we don't have a data partition. This + # will fail when the current user is not root. + try: + portal_id = check_output("/sbin/get-unique-id").decode("utf-8", "ignore").strip() + if not portal_id: + raise NoVrmPortalIdError("get-unique-id returned blank") + __vrm_portal_id = portal_id + return portal_id + except CalledProcessError: + # get-unique-id returned non-zero + raise NoVrmPortalIdError("get-unique-id returned non-zero") + except OSError: + # File doesn't exist, use fallback + pass + + # Fall back to getting our id using a syscall. Assume we are on linux. + # Allow the user to override what interface is used using an environment + # variable. + import fcntl, socket, struct, os + + iface = os.environ.get('VRM_IFACE', 'eth0').encode('ascii') + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', iface[:15])) + except IOError: + raise NoVrmPortalIdError("ioctl failed for eth0") + + __vrm_portal_id = info[18:24].hex() + return __vrm_portal_id + + +# See VE.Can registers - public.docx for definition of this conversion +def convert_vreg_version_to_readable(version): + def str_to_arr(x, length): + a = [] + for i in range(0, len(x), length): + a.append(x[i:i+length]) + return a + + x = "%x" % version + x = x.upper() + + if len(x) == 5 or len(x) == 3 or len(x) == 1: + x = '0' + x + + a = str_to_arr(x, 2); + + # remove the first 00 if there are three bytes and it is 00 + if len(a) == 3 and a[0] == '00': + a.remove(0); + + # if we have two or three bytes now, and the first character is a 0, remove it + if len(a) >= 2 and a[0][0:1] == '0': + a[0] = a[0][1]; + + result = '' + for item in a: + result += ('.' if result != '' else '') + item + + + result = 'v' + result + + return result + + +def get_free_space(path): + result = -1 + + try: + s = statvfs(path) + result = s.f_frsize * s.f_bavail # Number of free bytes that ordinary users + except Exception as ex: + logger.info("Error while retrieving free space for path %s: %s" % (path, ex)) + + return result + + +def _get_sysfs_machine_name(): + try: + with open('/sys/firmware/devicetree/base/model', 'r') as f: + return f.read().rstrip('\x00') + except IOError: + pass + + return None + +# Returns None if it cannot find a machine name. Otherwise returns the string +# containing the name +def get_machine_name(): + # First try calling the venus utility script + try: + return check_output("/usr/bin/product-name").strip().decode('UTF-8') + except (CalledProcessError, OSError): + pass + + # Fall back to sysfs + name = _get_sysfs_machine_name() + if name is not None: + return name + + # Fall back to venus build machine name + try: + with open('/etc/venus/machine', 'r', encoding='UTF-8') as f: + return f.read().strip() + except IOError: + pass + + return None + + +def get_product_id(): + """ Find the machine ID and return it. """ + + # First try calling the venus utility script + try: + return check_output("/usr/bin/product-id").strip().decode('UTF-8') + except (CalledProcessError, OSError): + pass + + # Fall back machine name mechanism + name = _get_sysfs_machine_name() + return { + 'Color Control GX': 'C001', + 'Venus GX': 'C002', + 'Octo GX': 'C006', + 'EasySolar-II': 'C007', + 'MultiPlus-II': 'C008', + 'Maxi GX': 'C009', + 'Cerbo GX': 'C00A' + }.get(name, 'C003') # C003 is Generic + + +# Returns False if it cannot open the file. Otherwise returns its rstripped contents +def read_file(path): + content = False + + try: + with open(path, 'r') as f: + content = f.read().rstrip() + except Exception as ex: + logger.debug("Error while reading %s: %s" % (path, ex)) + + return content + + +def wrap_dbus_value(value): + if value is None: + return VEDBUS_INVALID + if isinstance(value, float): + return dbus.Double(value, variant_level=1) + if isinstance(value, bool): + return dbus.Boolean(value, variant_level=1) + if isinstance(value, int): + try: + return dbus.Int32(value, variant_level=1) + except OverflowError: + return dbus.Int64(value, variant_level=1) + if isinstance(value, str): + return dbus.String(value, variant_level=1) + if isinstance(value, list): + if len(value) == 0: + # If the list is empty we cannot infer the type of the contents. So assume unsigned integer. + # A (signed) integer is dangerous, because an empty list of signed integers is used to encode + # an invalid value. + return dbus.Array([], signature=dbus.Signature('u'), variant_level=1) + return dbus.Array([wrap_dbus_value(x) for x in value], variant_level=1) + if isinstance(value, dict): + # Wrapping the keys of the dictionary causes D-Bus errors like: + # 'arguments to dbus_message_iter_open_container() were incorrect, + # assertion "(type == DBUS_TYPE_ARRAY && contained_signature && + # *contained_signature == DBUS_DICT_ENTRY_BEGIN_CHAR) || (contained_signature == NULL || + # _dbus_check_is_valid_signature (contained_signature))" failed in file ...' + return dbus.Dictionary({(k, wrap_dbus_value(v)) for k, v in value.items()}, variant_level=1) + return value + + +dbus_int_types = (dbus.Int32, dbus.UInt32, dbus.Byte, dbus.Int16, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64) + + +def unwrap_dbus_value(val): + """Converts D-Bus values back to the original type. For example if val is of type DBus.Double, + a float will be returned.""" + if isinstance(val, dbus_int_types): + return int(val) + if isinstance(val, dbus.Double): + return float(val) + if isinstance(val, dbus.Array): + v = [unwrap_dbus_value(x) for x in val] + return None if len(v) == 0 else v + if isinstance(val, (dbus.Signature, dbus.String)): + return str(val) + # Python has no byte type, so we convert to an integer. + if isinstance(val, dbus.Byte): + return int(val) + if isinstance(val, dbus.ByteArray): + return "".join([bytes(x) for x in val]) + if isinstance(val, (list, tuple)): + return [unwrap_dbus_value(x) for x in val] + if isinstance(val, (dbus.Dictionary, dict)): + # Do not unwrap the keys, see comment in wrap_dbus_value + return dict([(x, unwrap_dbus_value(y)) for x, y in val.items()]) + if isinstance(val, dbus.Boolean): + return bool(val) + return val + +# When supported, only name owner changes for the the given namespace are reported. This +# prevents spending cpu time at irrelevant changes, like scripts accessing the bus temporarily. +def add_name_owner_changed_receiver(dbus, name_owner_changed, namespace="com.victronenergy"): + # support for arg0namespace is submitted upstream, but not included at the time of + # writing, Venus OS does support it, so try if it works. + if namespace is None: + dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged') + else: + try: + dbus.add_signal_receiver(name_owner_changed, + signal_name='NameOwnerChanged', arg0namespace=namespace) + except TypeError: + dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged') diff --git a/NodeRed/dbus-fzsonick-48tl/ext/velib_python/vedbus.py b/NodeRed/dbus-fzsonick-48tl/ext/velib_python/vedbus.py new file mode 100644 index 000000000..6171a2101 --- /dev/null +++ b/NodeRed/dbus-fzsonick-48tl/ext/velib_python/vedbus.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import dbus.service +import logging +import traceback +import os +import weakref +from collections import defaultdict +from ve_utils import wrap_dbus_value, unwrap_dbus_value + +# vedbus contains three classes: +# VeDbusItemImport -> use this to read data from the dbus, ie import +# VeDbusItemExport -> use this to export data to the dbus (one value) +# VeDbusService -> use that to create a service and export several values to the dbus + +# Code for VeDbusItemImport is copied from busitem.py and thereafter modified. +# All projects that used busitem.py need to migrate to this package. And some +# projects used to define there own equivalent of VeDbusItemExport. Better to +# use VeDbusItemExport, or even better the VeDbusService class that does it all for you. + +# TODOS +# 1 check for datatypes, it works now, but not sure if all is compliant with +# com.victronenergy.BusItem interface definition. See also the files in +# tests_and_examples. And see 'if type(v) == dbus.Byte:' on line 102. Perhaps +# something similar should also be done in VeDbusBusItemExport? +# 2 Shouldn't VeDbusBusItemExport inherit dbus.service.Object? +# 7 Make hard rules for services exporting data to the D-Bus, in order to make tracking +# changes possible. Does everybody first invalidate its data before leaving the bus? +# And what about before taking one object away from the bus, instead of taking the +# whole service offline? +# They should! And after taking one value away, do we need to know that someone left +# the bus? Or we just keep that value in invalidated for ever? Result is that we can't +# see the difference anymore between an invalidated value and a value that was first on +# the bus and later not anymore. See comments above VeDbusItemImport as well. +# 9 there are probably more todos in the code below. + +# Some thoughts with regards to the data types: +# +# Text from: http://dbus.freedesktop.org/doc/dbus-python/doc/tutorial.html#data-types +# --- +# Variants are represented by setting the variant_level keyword argument in the +# constructor of any D-Bus data type to a value greater than 0 (variant_level 1 +# means a variant containing some other data type, variant_level 2 means a variant +# containing a variant containing some other data type, and so on). If a non-variant +# is passed as an argument but introspection indicates that a variant is expected, +# it'll automatically be wrapped in a variant. +# --- +# +# Also the different dbus datatypes, such as dbus.Int32, and dbus.UInt32 are a subclass +# of Python int. dbus.String is a subclass of Python standard class unicode, etcetera +# +# So all together that explains why we don't need to explicitly convert back and forth +# between the dbus datatypes and the standard python datatypes. Note that all datatypes +# in python are objects. Even an int is an object. + +# The signature of a variant is 'v'. + +# Export ourselves as a D-Bus service. +class VeDbusService(object): + def __init__(self, servicename, bus=None): + # dict containing the VeDbusItemExport objects, with their path as the key. + self._dbusobjects = {} + self._dbusnodes = {} + self._ratelimiters = [] + self._dbusname = None + + # dict containing the onchange callbacks, for each object. Object path is the key + self._onchangecallbacks = {} + + # Connect to session bus whenever present, else use the system bus + self._dbusconn = bus or (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus()) + + # make the dbus connection available to outside, could make this a true property instead, but ach.. + self.dbusconn = self._dbusconn + + # Register ourselves on the dbus, trigger an error if already in use (do_not_queue) + self._dbusname = dbus.service.BusName(servicename, self._dbusconn, do_not_queue=True) + + # Add the root item that will return all items as a tree + self._dbusnodes['/'] = VeDbusRootExport(self._dbusconn, '/', self) + + logging.info("registered ourselves on D-Bus as %s" % servicename) + + # To force immediate deregistering of this dbus service and all its object paths, explicitly + # call __del__(). + def __del__(self): + for node in list(self._dbusnodes.values()): + node.__del__() + self._dbusnodes.clear() + for item in list(self._dbusobjects.values()): + item.__del__() + self._dbusobjects.clear() + if self._dbusname: + self._dbusname.__del__() # Forces call to self._bus.release_name(self._name), see source code + self._dbusname = None + + def get_name(self): + return self._dbusname.get_name() + + # @param callbackonchange function that will be called when this value is changed. First parameter will + # be the path of the object, second the new value. This callback should return + # True to accept the change, False to reject it. + def add_path(self, path, value, description="", writeable=False, + onchangecallback=None, gettextcallback=None, valuetype=None, itemtype=None): + + if onchangecallback is not None: + self._onchangecallbacks[path] = onchangecallback + + itemtype = itemtype or VeDbusItemExport + item = itemtype(self._dbusconn, path, value, description, writeable, + self._value_changed, gettextcallback, deletecallback=self._item_deleted, valuetype=valuetype) + + spl = path.split('/') + for i in range(2, len(spl)): + subPath = '/'.join(spl[:i]) + if subPath not in self._dbusnodes and subPath not in self._dbusobjects: + self._dbusnodes[subPath] = VeDbusTreeExport(self._dbusconn, subPath, self) + self._dbusobjects[path] = item + logging.debug('added %s with start value %s. Writeable is %s' % (path, value, writeable)) + + # Add the mandatory paths, as per victron dbus api doc + def add_mandatory_paths(self, processname, processversion, connection, + deviceinstance, productid, productname, firmwareversion, hardwareversion, connected): + self.add_path('/Mgmt/ProcessName', processname) + self.add_path('/Mgmt/ProcessVersion', processversion) + self.add_path('/Mgmt/Connection', connection) + + # Create rest of the mandatory objects + self.add_path('/DeviceInstance', deviceinstance) + self.add_path('/ProductId', productid) + self.add_path('/ProductName', productname) + self.add_path('/FirmwareVersion', firmwareversion) + self.add_path('/HardwareVersion', hardwareversion) + self.add_path('/Connected', connected) + + # Callback function that is called from the VeDbusItemExport objects when a value changes. This function + # maps the change-request to the onchangecallback given to us for this specific path. + def _value_changed(self, path, newvalue): + if path not in self._onchangecallbacks: + return True + + return self._onchangecallbacks[path](path, newvalue) + + def _item_deleted(self, path): + self._dbusobjects.pop(path) + for np in list(self._dbusnodes.keys()): + if np != '/': + for ip in self._dbusobjects: + if ip.startswith(np + '/'): + break + else: + self._dbusnodes[np].__del__() + self._dbusnodes.pop(np) + + def __getitem__(self, path): + return self._dbusobjects[path].local_get_value() + + def __setitem__(self, path, newvalue): + self._dbusobjects[path].local_set_value(newvalue) + + def __delitem__(self, path): + self._dbusobjects[path].__del__() # Invalidates and then removes the object path + assert path not in self._dbusobjects + + def __contains__(self, path): + return path in self._dbusobjects + + def __enter__(self): + l = ServiceContext(self) + self._ratelimiters.append(l) + return l + + def __exit__(self, *exc): + # pop off the top one and flush it. If with statements are nested + # then each exit flushes its own part. + if self._ratelimiters: + self._ratelimiters.pop().flush() + +class ServiceContext(object): + def __init__(self, parent): + self.parent = parent + self.changes = {} + + def __getitem__(self, path): + return self.parent[path] + + def __setitem__(self, path, newvalue): + c = self.parent._dbusobjects[path]._local_set_value(newvalue) + if c is not None: + self.changes[path] = c + + def flush(self): + if self.changes: + self.parent._dbusnodes['/'].ItemsChanged(self.changes) + +class TrackerDict(defaultdict): + """ Same as defaultdict, but passes the key to default_factory. """ + def __missing__(self, key): + self[key] = x = self.default_factory(key) + return x + +class VeDbusRootTracker(object): + """ This tracks the root of a dbus path and listens for PropertiesChanged + signals. When a signal arrives, parse it and unpack the key/value changes + into traditional events, then pass it to the original eventCallback + method. """ + def __init__(self, bus, serviceName): + self.importers = defaultdict(weakref.WeakSet) + self.serviceName = serviceName + self._match = bus.get_object(serviceName, '/', introspect=False).connect_to_signal( + "ItemsChanged", weak_functor(self._items_changed_handler)) + + def __del__(self): + self._match.remove() + self._match = None + + def add(self, i): + self.importers[i.path].add(i) + + def _items_changed_handler(self, items): + if not isinstance(items, dict): + return + + for path, changes in items.items(): + try: + v = changes['Value'] + except KeyError: + continue + + try: + t = changes['Text'] + except KeyError: + t = str(unwrap_dbus_value(v)) + + for i in self.importers.get(path, ()): + i._properties_changed_handler({'Value': v, 'Text': t}) + +""" +Importing basics: + - If when we power up, the D-Bus service does not exist, or it does exist and the path does not + yet exist, still subscribe to a signal: as soon as it comes online it will send a signal with its + initial value, which VeDbusItemImport will receive and use to update local cache. And, when set, + call the eventCallback. + - If when we power up, save it + - When using get_value, know that there is no difference between services (or object paths) that don't + exist and paths that are invalid (= empty array, see above). Both will return None. In case you do + really want to know ifa path exists or not, use the exists property. + - When a D-Bus service leaves the D-Bus, it will first invalidate all its values, and send signals + with that update, and only then leave the D-Bus. (or do we need to subscribe to the NameOwnerChanged- + signal!?!) To be discussed and make sure. Not really urgent, since all existing code that uses this + class already subscribes to the NameOwnerChanged signal, and subsequently removes instances of this + class. + +Read when using this class: +Note that when a service leaves that D-Bus without invalidating all its exported objects first, for +example because it is killed, VeDbusItemImport doesn't have a clue. So when using VeDbusItemImport, +make sure to also subscribe to the NamerOwnerChanged signal on bus-level. Or just use dbusmonitor, +because that takes care of all of that for you. +""" +class VeDbusItemImport(object): + def __new__(cls, bus, serviceName, path, eventCallback=None, createsignal=True): + instance = object.__new__(cls) + + # If signal tracking should be done, also add to root tracker + if createsignal: + if "_roots" not in cls.__dict__: + cls._roots = TrackerDict(lambda k: VeDbusRootTracker(bus, k)) + + return instance + + ## Constructor + # @param bus the bus-object (SESSION or SYSTEM). + # @param serviceName the dbus-service-name (string), for example 'com.victronenergy.battery.ttyO1' + # @param path the object-path, for example '/Dc/V' + # @param eventCallback function that you want to be called on a value change + # @param createSignal only set this to False if you use this function to one time read a value. When + # leaving it to True, make sure to also subscribe to the NameOwnerChanged signal + # elsewhere. See also note some 15 lines up. + def __init__(self, bus, serviceName, path, eventCallback=None, createsignal=True): + # TODO: is it necessary to store _serviceName and _path? Isn't it + # stored in the bus_getobjectsomewhere? + self._serviceName = serviceName + self._path = path + self._match = None + # TODO: _proxy is being used in settingsdevice.py, make a getter for that + self._proxy = bus.get_object(serviceName, path, introspect=False) + self.eventCallback = eventCallback + + assert eventCallback is None or createsignal == True + if createsignal: + self._match = self._proxy.connect_to_signal( + "PropertiesChanged", weak_functor(self._properties_changed_handler)) + self._roots[serviceName].add(self) + + # store the current value in _cachedvalue. When it doesn't exists set _cachedvalue to + # None, same as when a value is invalid + self._cachedvalue = None + try: + v = self._proxy.GetValue() + except dbus.exceptions.DBusException: + pass + else: + self._cachedvalue = unwrap_dbus_value(v) + + def __del__(self): + if self._match is not None: + self._match.remove() + self._match = None + self._proxy = None + + def _refreshcachedvalue(self): + self._cachedvalue = unwrap_dbus_value(self._proxy.GetValue()) + + ## Returns the path as a string, for example '/AC/L1/V' + @property + def path(self): + return self._path + + ## Returns the dbus service name as a string, for example com.victronenergy.vebus.ttyO1 + @property + def serviceName(self): + return self._serviceName + + ## Returns the value of the dbus-item. + # the type will be a dbus variant, for example dbus.Int32(0, variant_level=1) + # this is not a property to keep the name consistant with the com.victronenergy.busitem interface + # returns None when the property is invalid + def get_value(self): + return self._cachedvalue + + ## Writes a new value to the dbus-item + def set_value(self, newvalue): + r = self._proxy.SetValue(wrap_dbus_value(newvalue)) + + # instead of just saving the value, go to the dbus and get it. So we have the right type etc. + if r == 0: + self._refreshcachedvalue() + + return r + + ## Resets the item to its default value + def set_default(self): + self._proxy.SetDefault() + self._refreshcachedvalue() + + ## Returns the text representation of the value. + # For example when the value is an enum/int GetText might return the string + # belonging to that enum value. Another example, for a voltage, GetValue + # would return a float, 12.0Volt, and GetText could return 12 VDC. + # + # Note that this depends on how the dbus-producer has implemented this. + def get_text(self): + return self._proxy.GetText() + + ## Returns true of object path exists, and false if it doesn't + @property + def exists(self): + # TODO: do some real check instead of this crazy thing. + r = False + try: + r = self._proxy.GetValue() + r = True + except dbus.exceptions.DBusException: + pass + + return r + + ## callback for the trigger-event. + # @param eventCallback the event-callback-function. + @property + def eventCallback(self): + return self._eventCallback + + @eventCallback.setter + def eventCallback(self, eventCallback): + self._eventCallback = eventCallback + + ## Is called when the value of the imported bus-item changes. + # Stores the new value in our local cache, and calls the eventCallback, if set. + def _properties_changed_handler(self, changes): + if "Value" in changes: + changes['Value'] = unwrap_dbus_value(changes['Value']) + self._cachedvalue = changes['Value'] + if self._eventCallback: + # The reason behind this try/except is to prevent errors silently ending up the an error + # handler in the dbus code. + try: + self._eventCallback(self._serviceName, self._path, changes) + except: + traceback.print_exc() + os._exit(1) # sys.exit() is not used, since that also throws an exception + + +class VeDbusTreeExport(dbus.service.Object): + def __init__(self, bus, objectPath, service): + dbus.service.Object.__init__(self, bus, objectPath) + self._service = service + logging.debug("VeDbusTreeExport %s has been created" % objectPath) + + def __del__(self): + # self._get_path() will raise an exception when retrieved after the call to .remove_from_connection, + # so we need a copy. + path = self._get_path() + if path is None: + return + self.remove_from_connection() + logging.debug("VeDbusTreeExport %s has been removed" % path) + + def _get_path(self): + if len(self._locations) == 0: + return None + return self._locations[0][1] + + def _get_value_handler(self, path, get_text=False): + logging.debug("_get_value_handler called for %s" % path) + r = {} + px = path + if not px.endswith('/'): + px += '/' + for p, item in self._service._dbusobjects.items(): + if p.startswith(px): + v = item.GetText() if get_text else wrap_dbus_value(item.local_get_value()) + r[p[len(px):]] = v + logging.debug(r) + return r + + @dbus.service.method('com.victronenergy.BusItem', out_signature='v') + def GetValue(self): + value = self._get_value_handler(self._get_path()) + return dbus.Dictionary(value, signature=dbus.Signature('sv'), variant_level=1) + + @dbus.service.method('com.victronenergy.BusItem', out_signature='v') + def GetText(self): + return self._get_value_handler(self._get_path(), True) + + def local_get_value(self): + return self._get_value_handler(self.path) + +class VeDbusRootExport(VeDbusTreeExport): + @dbus.service.signal('com.victronenergy.BusItem', signature='a{sa{sv}}') + def ItemsChanged(self, changes): + pass + + @dbus.service.method('com.victronenergy.BusItem', out_signature='a{sa{sv}}') + def GetItems(self): + return { + path: { + 'Value': wrap_dbus_value(item.local_get_value()), + 'Text': item.GetText() } + for path, item in self._service._dbusobjects.items() + } + + +class VeDbusItemExport(dbus.service.Object): + ## Constructor of VeDbusItemExport + # + # Use this object to export (publish), values on the dbus + # Creates the dbus-object under the given dbus-service-name. + # @param bus The dbus object. + # @param objectPath The dbus-object-path. + # @param value Value to initialize ourselves with, defaults to None which means Invalid + # @param description String containing a description. Can be called over the dbus with GetDescription() + # @param writeable what would this do!? :). + # @param callback Function that will be called when someone else changes the value of this VeBusItem + # over the dbus. First parameter passed to callback will be our path, second the new + # value. This callback should return True to accept the change, False to reject it. + def __init__(self, bus, objectPath, value=None, description=None, writeable=False, + onchangecallback=None, gettextcallback=None, deletecallback=None, + valuetype=None): + dbus.service.Object.__init__(self, bus, objectPath) + self._onchangecallback = onchangecallback + self._gettextcallback = gettextcallback + self._value = value + self._description = description + self._writeable = writeable + self._deletecallback = deletecallback + self._type = valuetype + + # To force immediate deregistering of this dbus object, explicitly call __del__(). + def __del__(self): + # self._get_path() will raise an exception when retrieved after the + # call to .remove_from_connection, so we need a copy. + path = self._get_path() + if path == None: + return + if self._deletecallback is not None: + self._deletecallback(path) + self.remove_from_connection() + logging.debug("VeDbusItemExport %s has been removed" % path) + + def _get_path(self): + if len(self._locations) == 0: + return None + return self._locations[0][1] + + ## Sets the value. And in case the value is different from what it was, a signal + # will be emitted to the dbus. This function is to be used in the python code that + # is using this class to export values to the dbus. + # set value to None to indicate that it is Invalid + def local_set_value(self, newvalue): + changes = self._local_set_value(newvalue) + if changes is not None: + self.PropertiesChanged(changes) + + def _local_set_value(self, newvalue): + if self._value == newvalue: + return None + + self._value = newvalue + return { + 'Value': wrap_dbus_value(newvalue), + 'Text': self.GetText() + } + + def local_get_value(self): + return self._value + + # ==== ALL FUNCTIONS BELOW THIS LINE WILL BE CALLED BY OTHER PROCESSES OVER THE DBUS ==== + + ## Dbus exported method SetValue + # Function is called over the D-Bus by other process. It will first check (via callback) if new + # value is accepted. And it is, stores it and emits a changed-signal. + # @param value The new value. + # @return completion-code When successful a 0 is return, and when not a -1 is returned. + @dbus.service.method('com.victronenergy.BusItem', in_signature='v', out_signature='i') + def SetValue(self, newvalue): + if not self._writeable: + return 1 # NOT OK + + newvalue = unwrap_dbus_value(newvalue) + + # If value type is enforced, cast it. If the type can be coerced + # python will do it for us. This allows ints to become floats, + # or bools to become ints. Additionally also allow None, so that + # a path may be invalidated. + if self._type is not None and newvalue is not None: + try: + newvalue = self._type(newvalue) + except (ValueError, TypeError): + return 1 # NOT OK + + if newvalue == self._value: + return 0 # OK + + # call the callback given to us, and check if new value is OK. + if (self._onchangecallback is None or + (self._onchangecallback is not None and self._onchangecallback(self.__dbus_object_path__, newvalue))): + + self.local_set_value(newvalue) + return 0 # OK + + return 2 # NOT OK + + ## Dbus exported method GetDescription + # + # Returns the a description. + # @param language A language code (e.g. ISO 639-1 en-US). + # @param length Lenght of the language string. + # @return description + @dbus.service.method('com.victronenergy.BusItem', in_signature='si', out_signature='s') + def GetDescription(self, language, length): + return self._description if self._description is not None else 'No description given' + + ## Dbus exported method GetValue + # Returns the value. + # @return the value when valid, and otherwise an empty array + @dbus.service.method('com.victronenergy.BusItem', out_signature='v') + def GetValue(self): + return wrap_dbus_value(self._value) + + ## Dbus exported method GetText + # Returns the value as string of the dbus-object-path. + # @return text A text-value. '---' when local value is invalid + @dbus.service.method('com.victronenergy.BusItem', out_signature='s') + def GetText(self): + if self._value is None: + return '---' + + # Default conversion from dbus.Byte will get you a character (so 'T' instead of '84'), so we + # have to convert to int first. Note that if a dbus.Byte turns up here, it must have come from + # the application itself, as all data from the D-Bus should have been unwrapped by now. + if self._gettextcallback is None and type(self._value) == dbus.Byte: + return str(int(self._value)) + + if self._gettextcallback is None and self.__dbus_object_path__ == '/ProductId': + return "0x%X" % self._value + + if self._gettextcallback is None: + return str(self._value) + + return self._gettextcallback(self.__dbus_object_path__, self._value) + + ## The signal that indicates that the value has changed. + # Other processes connected to this BusItem object will have subscribed to the + # event when they want to track our state. + @dbus.service.signal('com.victronenergy.BusItem', signature='a{sv}') + def PropertiesChanged(self, changes): + pass + +## This class behaves like a regular reference to a class method (eg. self.foo), but keeps a weak reference +## to the object which method is to be called. +## Use this object to break circular references. +class weak_functor: + def __init__(self, f): + self._r = weakref.ref(f.__self__) + self._f = weakref.ref(f.__func__) + + def __call__(self, *args, **kargs): + r = self._r() + f = self._f() + if r == None or f == None: + return + f(r, *args, **kargs) diff --git a/NodeRed/dbus-fzsonick-48tl/start.sh b/NodeRed/dbus-fzsonick-48tl/start.sh new file mode 100755 index 000000000..d818ffc57 --- /dev/null +++ b/NodeRed/dbus-fzsonick-48tl/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +. /opt/victronenergy/serial-starter/run-service.sh + +app=/opt/victronenergy/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py +args="$tty" +start $args diff --git a/NodeRed/dvcc.py b/NodeRed/dvcc.py new file mode 100644 index 000000000..3a6720892 --- /dev/null +++ b/NodeRed/dvcc.py @@ -0,0 +1,1287 @@ +from dbus.exceptions import DBusException +from gi.repository import GLib +import logging +from math import pi, floor, ceil +import traceback +from itertools import count, chain +from functools import partial, wraps + +# Victron packages +from sc_utils import safeadd, copy_dbus_value, reify, ExpiringValue +from ve_utils import exit_on_error + +from delegates.base import SystemCalcDelegate +from delegates.batteryservice import BatteryService +from delegates.multi import Multi as MultiService + +# Adjust things this often (in seconds) +# solar chargers has to switch to HEX mode each time we write a value to its +# D-Bus service. Writing too often may block text messages. In MPPT firmware +# v1.23 and later, all relevant values will be transmitted as asynchronous +# message, so the update rate could be increased. For now, keep this at 3 and +# above. +ADJUST = 3 + +VEBUS_FIRMWARE_REQUIRED = 0x422 +VEDIRECT_FIRMWARE_REQUIRED = 0x129 +VECAN_FIRMWARE_REQUIRED = 0x10200 # 1.02, 24-bit version + +# This is a place to account for some BMS quirks where we may have to ignore +# the BMS value and substitute our own. + +def _byd_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed): + """ Quirk for the BYD batteries. When the battery sends CCL=0, float it at + 55V. """ + if charge_current == 0: + return (55, 40, feedback_allowed, False) + return (charge_voltage, charge_current, feedback_allowed, False) + +def _lg_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed): + """ Quirk for LG batteries. The hard limit is 58V. Above that you risk + tripping on high voltage. The batteries publish a charge voltage of 57.7V + but we need to make room for an 0.4V overvoltage when feed-in is enabled. + """ + # Make room for a potential 0.4V at the top + return (min(charge_voltage, 57.3), charge_current, feedback_allowed, False) + +def _pylontech_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed): + """ Quirk for Pylontech. Make a bit of room at the top. Pylontech says that + at 51.8V the battery is 95% full, and that balancing starts at 90%. + 53.2V is normally considered 100% full, and 54V raises an alarm. By + running the battery at 52.4V it will be 99%-100% full, balancing should + be active, and we should avoid high voltage alarms. + + Identify 24-V batteries by the lower charge voltage, and do the same + thing with an 8-to-15 cell ratio, +-3.48V per cell. + """ + # Use 3.48V per cell plus a little, 52.4V for 15 cell 48V batteries. + # Use 3.46V per cell plus a little, 27.8V for 24V batteries testing shows that's 100% SOC. + # That leaves 1.6V margin for 48V batteries and 1.0V for 24V. + # See https://github.com/victronenergy/venus/issues/536 + if charge_voltage > 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 new file mode 100644 index 000000000..c4e81e053 --- /dev/null +++ b/NodeRed/flows.json @@ -0,0 +1,5660 @@ +[ + { + "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 new file mode 100755 index 000000000..c0c2248a8 --- /dev/null +++ b/NodeRed/rc.local @@ -0,0 +1,26 @@ +#!/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 new file mode 100644 index 000000000..d76cdd8ec --- /dev/null +++ b/NodeRed/settings-user.js @@ -0,0 +1,31 @@ +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' } + }, + }