diff --git a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs index d6fe2b8a4..eadfaf265 100644 --- a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs +++ b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs @@ -296,7 +296,7 @@ public static class ExoCmd byte[] replyData = udpClient.Receive(ref remoteEndPoint); string replyMessage = Encoding.UTF8.GetString(replyData); Console.WriteLine("Received " + replyMessage + " from installation " + installation.VpnIp); - break; + return true; } catch (SocketException ex) { @@ -310,7 +310,7 @@ public static class ExoCmd } - return true; + return false; //var s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!); //var url = s3Region.Bucket(installation.BucketName()).Path("config.json"); diff --git a/csharp/App/SaliMax/downloadBatteryLogs/download-bms-log b/csharp/App/SaliMax/downloadBatteryLogs/download-bms-log new file mode 100644 index 000000000..b9c2e8f23 --- /dev/null +++ b/csharp/App/SaliMax/downloadBatteryLogs/download-bms-log @@ -0,0 +1,284 @@ +#!/usr/bin/python2 -u +# coding=utf-8 +import os +import re +import struct +import serial +import logging +from sys import argv, exit +from datetime import datetime +from pymodbus.pdu import ModbusRequest, ModbusResponse, ExceptionResponse +from pymodbus.other_message import ReportSlaveIdRequest +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +from pymodbus.factory import ClientDecoder +from pymodbus.client import ModbusSerialClient as Modbus +logging.basicConfig(level=logging.INFO) + + + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import List, Optional, NoReturn + +RESET_REGISTER = 0x2087 +FIRMWARE_VERSION_REGISTER = 1054 +SERIAL_STARTER_DIR = '/opt/victronenergy/serial-starter/' +INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name' +OUTPUT_DIR = '/data/innovenergy' + + +class ReadLogRequest(ModbusRequest): + + function_code = 0x42 + _rtu_frame_size = 5 # not used + + def __init__(self, address = None, **kwargs): + + ModbusRequest.__init__(self, **kwargs) + self.sub_function = 0 if address is None else 1 + self.address = address + + # FUGLY as hell, but necessary bcs PyModbus cannot deal + # with responses that have lengths depending on the sub_function. + # it goes without saying that this isn't thread-safe + ReadLogResponse._rtu_frame_size = 9 if self.sub_function == 0 else 9+128 + + def encode(self): + + if self.sub_function == 0: + return struct.pack('>B', self.sub_function) + else: + return struct.pack('>BI', self.sub_function, self.address) + + def decode(self, data): + self.sub_function = struct.unpack('>B', data) + + def execute(self, context): + print("EXECUTE1") + + def get_response_pdu_size(self): + return ReadLogResponse._rtu_frame_size - 3 + + def __str__(self): + return "ReadLogAddressRequest" + + +class ReadLogResponse(ModbusResponse): + + function_code = 0x42 + _rtu_frame_size = 9 # the WHOLE frame incl crc + + def __init__(self, sub_function=0, address=b'\x00', data=None, **kwargs): + ModbusResponse.__init__(self, **kwargs) + self.sub_function = sub_function + self.address = address + self.data = data + + def encode(self): + pass + + def decode(self, data): + self.address, self.address = struct.unpack_from(">BI", data) + self.data = data[5:] + + def __str__(self): + arguments = (self.function_code, self.address) + return "ReadLogAddressResponse(%s, %s)" % arguments + +# unfortunately we have to monkey-patch this global table because +# the current (victron) version of PyModbus does not have a +# way to "register" new function-codes yet +ClientDecoder.function_table.append(ReadLogResponse) + + +class LockTTY(object): + + def __init__(self, tty): + # type: (str) -> None + self.tty = tty + + def __enter__(self): + os.system(SERIAL_STARTER_DIR + 'stop-tty.sh ' + self.tty) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + os.system(SERIAL_STARTER_DIR + 'start-tty.sh ' + self.tty) + + +def wrap_try_except(error_msg): + def decorate(f): + def applicator(*args, **kwargs): + try: + return f(*args, **kwargs) + except: + print(error_msg) + exit(1) + return applicator + return decorate + + +def init_modbus(tty): + # type: (str) -> Modbus + + return Modbus( + port='/dev/' + tty, + method='rtu', + baudrate=115200, + stopbits=1, + bytesize=8, + timeout=0.5, # seconds + parity=serial.PARITY_ODD) + + +@wrap_try_except("Failed to download BMS log!") +def download_log(modbus, node_id, battery_id): + # type: (Modbus, int, str) -> NoReturn + + # Get address of latest log entry + # request = ReadLogRequest(unit=slave_id) + + print ('downloading BMS log from node ' + str(node_id) + ' ...') + + progress = -1 + log_file = battery_id + "-node" + str(node_id) + "-" + datetime.now().strftime('%d-%m-%Y') + ".bin" + print(log_file) + + with open(log_file, 'w') as f: + + eof = 0x200000 + record = 0x40 + for address in range(0, eof, 2*record): + + percent = int(100*address/eof) + + if percent != progress: + progress = percent + print('\r{}% '.format(progress),end='') + + request = ReadLogRequest(address, slave=node_id) + result = modbus.execute(request) # type: ReadLogResponse + + address1 = "{:06X}".format(address) + address2 = "{:06X}".format(address+record) + + data1 = result.data[:record] + data2 = result.data[record:] + + line1 = address1 + ":" + ''.join('{:02X}'.format(byte) for byte in data1) + line2 = address2 + ":" + ''.join('{:02X}'.format(byte) for byte in data2) + + lines = line1 + "\n" + line2 + "\n" + f.write(lines) + + print("\r100%") + print("done") + print("wrote log to " + log_file) + + return True + + +@wrap_try_except("Failed to contact battery!") +def identify_battery(modbus, node_id): + # type: (Modbus, int) -> str + + target = 'battery #' + str(node_id) + print('contacting ' + target + ' ...') + + request = ReportSlaveIdRequest(slave=node_id) + response = modbus.execute(request) + + index_of_ff = response.identifier.find(b'\xff') + sid_response = response.identifier[index_of_ff + 1:].decode('utf-8').split(' ') + + response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, slave=node_id) + + fw = '{0:0>4X}'.format(response.registers[0]) + print("log string is",sid_response[0]+"-"+sid_response[1]+"-"+fw) + + #return re.sub(" +", "-", sid + " " + fw) + return sid_response[0]+"-"+sid_response[1]+"-"+fw + + +def is_int(value): + # type: (str) -> bool + try: + _ = int(value) + return True + except ValueError: + return False + + +def print_usage(): + print ('Usage: ' + __file__ + ' ') + print ('Example: ' + __file__ + ' 2 ttyUSB0') + print ('') + print ('You can omit the "ttyUSB" prefix of the serial device:') + print (' ' + __file__ + ' 2 0') + print ('') + print ('You can omit the serial device entirely when the "com.victronenergy.battery." service is running:') + print (' ' + __file__ + ' 2') + print ('') + + +def get_tty_from_battery_service_name(): + # type: () -> Optional[str] + + import dbus + bus = dbus.SystemBus() + + tty = ( + name.split('.')[-1] + for name in bus.list_names() + if name.startswith('com.victronenergy.battery.') + ) + + return next(tty, None) + + +def parse_tty(tty): + # type: (Optional[str]) -> str + + if tty is None: + return get_tty_from_battery_service_name() + + if is_int(tty): + return 'ttyUSB' + argv[1] + else: + return tty + + +def parse_cmdline_args(argv): + # type: (List[str]) -> (str, int) + + slave_id = element_at_or_none(argv, 0) + tty = parse_tty(element_at_or_none(argv, 1)) + + if slave_id is None or tty is None: + print_usage() + exit(2) + + print("tty=",tty) + print("slave id= ",slave_id) + + return tty, int(slave_id) + + +def element_at_or_none(lst, index): + return next(iter(lst[index:]), None) + + +def main(argv): + # type: (List[str]) -> () + + tty, node_id = parse_cmdline_args(argv) + + with init_modbus(tty) as modbus: + battery_id = identify_battery(modbus, node_id) + download_log(modbus, node_id, battery_id) + + exit(0) + + +main(argv[1:]) diff --git a/csharp/App/SaliMax/downloadBatteryLogs/download_battery_logs.sh b/csharp/App/SaliMax/downloadBatteryLogs/download_battery_logs.sh new file mode 100755 index 000000000..20c3f05b4 --- /dev/null +++ b/csharp/App/SaliMax/downloadBatteryLogs/download_battery_logs.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +#Prototype 10.2.3.115 Prototype +#Salimax0001 10.2.3.104 Marti Technik (Bern) +#Salimax0002 10.2.4.29 Weidmann d (ZG) +#Salimax0003 10.2.4.33 Elektrotechnik Stefan GmbH +#Salimax0004 10.2.4.32 Biohof Gubelmann (Walde) +#Salimax0005 10.2.4.36 Schreinerei Schönthal (Thun) +#Salimax0006 10.2.4.35 Steakhouse Mettmenstetten +#Salimax0007 10.2.4.154 LerchenhofHerr Twannberg +#Salimax0008 10.2.4.113 Wittmann Kottingbrunn + +dotnet_version='net6.0' +ip_address="$1" +battery_ids="$2" +username='ie-entwicklung' +root_password='Salimax4x25' + +if [ "$#" -lt 2 ]; then + echo "Error: Insufficient arguments. Usage: $0 " + exit 1 +fi + +# Function to expand battery ids from a range +expand_battery_ids() { + local range="$1" + local expanded_ids=() + + IFS='-' read -r start end <<< "$range" + for ((i = start; i <= end; i++)); do + expanded_ids+=("$i") + done + + echo "${expanded_ids[@]}" +} + +# Check if battery_ids_arg contains a hyphen indicating a range +if [[ "$battery_ids" == *-* ]]; then + # Expand battery ids from the range + battery_ids=$(expand_battery_ids "$battery_ids") +else + # Use the provided battery ids + battery_ids=("$battery_ids") +fi + +echo "ip_address: $ip_address" +echo "Battery_ids: ${battery_ids[@]}" + +#ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" "10.2.4.113" "10.2.4.29") +#battery_ids=("2" "3" "4" "5" "6" "7" "8" "9" "10" "11") + +set -e + +scp download-bms-log "$username"@"$ip_address":/home/"$username" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl stop battery.service" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S apt install python3-pip -y" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S pip3 install pymodbus" + +for battery in "${battery_ids[@]}"; do + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S python3 download-bms-log " "$battery" " ttyUSB0" +done +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl start battery.service" +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S rm download-bms-log" +scp "$username"@"$ip_address":/home/"$username/*.bin" . +ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S rm *.bin" + +echo "Deployed and ran commands on $ip_address" +done + + diff --git a/csharp/App/SaliMax/uploadBatteryFw/AF0A.bin b/csharp/App/SaliMax/uploadBatteryFw/AF0A.bin new file mode 100644 index 000000000..e5b7b9aba Binary files /dev/null and b/csharp/App/SaliMax/uploadBatteryFw/AF0A.bin differ diff --git a/csharp/App/SaliMax/uploadBatteryFw/update_firmware.sh b/csharp/App/SaliMax/uploadBatteryFw/update_firmware.sh new file mode 100755 index 000000000..fab8d1174 --- /dev/null +++ b/csharp/App/SaliMax/uploadBatteryFw/update_firmware.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' +root_password='Salimax4x25' + +set -e + +ip_addresses=("10.2.3.115" "10.2.3.104" "10.2.4.33" "10.2.4.32" "10.2.4.36" "10.2.4.35" "10.2.4.154" "10.2.4.113" "10.2.4.29") +battery_ids=("2" "3" "4" "5" "6" "7" "8" "9" "10" "11") + + +for ip_address in "${ip_addresses[@]}"; do + scp upload-bms-firmware AF0A.bin "$username"@"$ip_address":/home/"$username" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl stop battery.service" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S apt install python3-pip -y" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S pip3 install pymodbus" + + for battery in "${battery_ids[@]}"; do + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S python3 upload-bms-firmware ttyUSB0 " "$battery" " AF0A.bin" + done + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl start battery.service" + ssh "$username"@"$ip_address" "echo '$root_password' | sudo -S systemctl rm upload-bms-firmware AF0A.bin" + +echo "Deployed and ran commands on $ip_address" +done + + diff --git a/csharp/App/SaliMax/uploadBatteryFw/upload-bms-firmware b/csharp/App/SaliMax/uploadBatteryFw/upload-bms-firmware new file mode 100755 index 000000000..458fe44ed --- /dev/null +++ b/csharp/App/SaliMax/uploadBatteryFw/upload-bms-firmware @@ -0,0 +1,303 @@ +#!/usr/bin/python2 -u +# coding=utf-8 + +import os +import struct +from time import sleep + +import serial +from os import system +import logging + +from pymodbus.client import ModbusSerialClient as Modbus +from pymodbus.exceptions import ModbusIOException +from pymodbus.pdu import ModbusResponse +from os.path import dirname, abspath +from sys import path, argv, exit + +path.append(dirname(dirname(abspath(__file__)))) + +PAGE_SIZE = 0x100 +HALF_PAGE =int( PAGE_SIZE / 2) +WRITE_ENABLE = [1] +FIRMWARE_VERSION_REGISTER = 1054 + +ERASE_FLASH_REGISTER = 0x2084 +RESET_REGISTER = 0x2087 +logging.basicConfig(level=logging.INFO) + + +# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime +# noinspection PyUnreachableCode +if False: + from typing import List, NoReturn, Iterable, Optional + + +class LockTTY(object): + + def __init__(self, tty): + # type: (str) -> None + self.tty = tty + + def __enter__(self): + system(SERIAL_STARTER_DIR + 'stop-tty.sh ' + self.tty) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + system(SERIAL_STARTER_DIR + 'start-tty.sh ' + self.tty) + + +def calc_stm32_crc_round(crc, data): + # type: (int, int) -> int + crc = crc ^ data + for _ in range(32): + xor = (crc & 0x80000000) != 0 + crc = (crc & 0x7FFFFFFF) << 1 # clear bit 31 because python ints have "infinite" bits + if xor: + crc = crc ^ 0x04C11DB7 + + return crc + + +def calc_stm32_crc(data): + # type: (Iterable[int]) -> int + crc = 0xFFFFFFFF + + for dw in data: + crc = calc_stm32_crc_round(crc, dw) + + return crc + + +def init_modbus(tty): + # type: (str) -> Modbus + + return Modbus( + port='/dev/' + tty, + method='rtu', + baudrate=115200, + stopbits=1, + bytesize=8, + timeout=0.5, # seconds + parity=serial.PARITY_ODD) + + +def failed(response): + # type: (ModbusResponse) -> bool + + # Todo 'ModbusIOException' object has no attribute 'function_code' + return response.function_code > 0x80 + + +def clear_flash(modbus, slave_address): + # type: (Modbus, int) -> bool + + print ('erasing flash...') + + write_response = modbus.write_registers(address=0x2084, values=[1], slave=slave_address) + + if failed(write_response): + print('erasing flash FAILED') + return False + + flash_countdown = 17 + while flash_countdown > 0: + read_response = modbus.read_holding_registers(address=0x2085, count=1, slave=slave_address) + + if failed(read_response): + print('erasing flash FAILED') + return False + + if read_response.registers[0] != flash_countdown: + flash_countdown = read_response.registers[0] + + msg = str(100 * (16 - flash_countdown) / 16) + '%' + print('\r{0} '.format(msg), end=' ') + + print('done!') + + return True + + +# noinspection PyShadowingBuiltins +def bytes_to_words(bytes): + # type: (str) -> List[int] + return list(struct.unpack('>' + int(len(bytes)/2) * 'H', bytes)) + + +def send_half_page_1(modbus, slave_address, data, page): + # type: (Modbus, int, str, int) -> NoReturn + + first_half = [page] + bytes_to_words(data[:HALF_PAGE]) + write_first_half = modbus.write_registers(0x2000, first_half, slave=slave_address) + + if failed(write_first_half): + raise Exception("Failed to write page " + str(page)) + + +def send_half_page_2(modbus, slave_address, data, page): + # type: (Modbus, int, str, int) -> NoReturn + + registers = bytes_to_words(data[HALF_PAGE:]) + calc_crc(page, data) + WRITE_ENABLE + result = modbus.write_registers(0x2041, registers, slave=slave_address) + + if failed(result): + raise Exception("Failed to write page " + str(page)) + + +def get_fw_name(fw_path): + # type: (str) -> str + return fw_path.split('/')[-1].split('.')[0] + + +def upload_fw(modbus, slave_id, fw_path, fw_name): + # type: (Modbus, int, str, str) -> NoReturn + + with open(fw_path, "rb") as f: + + size = os.fstat(f.fileno()).st_size + n_pages = int(size / PAGE_SIZE) + + print('uploading firmware ' + fw_name + ' to BMS ...') + + for page in range(0, n_pages): + page_data = f.read(PAGE_SIZE) + + msg = "page " + str(page + 1) + '/' + str(n_pages) + ' ' + str(100 * page / n_pages + 1) + '%' + print('\r{0} '.format(msg), end=' ') + + if is_page_empty(page_data): + continue + sleep(0.01) + send_half_page_1(modbus, slave_id, page_data, page) + sleep(0.01) + send_half_page_2(modbus, slave_id, page_data, page) + + +def is_page_empty(page): + # type: (str) -> bool + return page.count(b'\xff') == len(page) + + +def reset_bms(modbus, slave_id): + # type: (Modbus, int) -> bool + + print ('resetting BMS...') + + result = modbus.write_registers(RESET_REGISTER, [1], slave=slave_id) + + # expecting a ModbusIOException (timeout) + # BMS can no longer reply because it is already reset + success = isinstance(result, ModbusIOException) + + if success: + print('done') + else: + print('FAILED to reset battery!') + + return success + + +def calc_crc(page, data): + # type: (int, str) -> List[int] + + crc = calc_stm32_crc([page] + bytes_to_words(data)) + crc_bytes = struct.pack('>L', crc) + + return bytes_to_words(crc_bytes) + + +def identify_battery(modbus, slave_id): + # type: (Modbus, int) -> Optional[str] + print("slave id=",slave_id) + target = 'battery ' + str(slave_id) + ' at ' + '502' + + try: + + print(('contacting ...')) + + response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, slave=slave_id) + fw = '{0:0>4X}'.format(response.registers[0]) + + print(('found battery with firmware ' + fw)) + + return fw + + except: + print(('failed to communicate with ')) + return None + + +def print_usage(): + print(('Usage: ' + __file__ + ' ')) + print(('Example: ' + __file__ + ' ttyUSB0 2 A08C.bin')) + + +def parse_cmdline_args(argv): + # type: (List[str]) -> (str, str, str, str) + + def fail_with(msg): + print(msg) + print_usage() + exit(1) + + if len(argv) < 1: + fail_with('missing argument for tty device') + + if len(argv) < 2: + fail_with('missing argument for battery ID') + + if len(argv) < 3: + fail_with('missing argument for firmware') + + return argv[0], int(argv[1]), argv[2], get_fw_name(argv[2]) + + +def verify_firmware(modbus, battery_id, fw_name): + # type: (Modbus, int, str) -> NoReturn + + fw_verify = identify_battery(modbus, battery_id) + + if fw_verify == fw_name: + print('SUCCESS') + else: + print('FAILED to verify uploaded firmware!') + if fw_verify is not None: + print('expected firmware version ' + fw_name + ' but got ' + fw_verify) + + +def wait_for_bms_reboot(): + # type: () -> NoReturn + + # wait 20s for the battery to reboot + + print('waiting for BMS to reboot...') + + for t in range(20, 0, -1): + print('\r{0} '.format(t), end=' ') + sleep(1) + + print('0') + + +def main(argv): + # type: (List[str]) -> NoReturn + + tty, battery_id, fw_path, fw_name = parse_cmdline_args(argv) + with init_modbus(tty) as modbus: + + if identify_battery(modbus, battery_id) is None: + return + + clear_flash(modbus, battery_id) + upload_fw(modbus, battery_id, fw_path, fw_name) + + if not reset_bms(modbus, battery_id): + return + + wait_for_bms_reboot() + + verify_firmware(modbus, battery_id, fw_name) + + +main(argv[1:]) diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 8c0295fdc..3f83af635 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -78,7 +78,9 @@ function Installation(props: singleInstallationProps) { const fetchDataOnlyOneTime = async () => { let success = false; - while (true) { + const max_retransmissions = 3; + + for (let i = 0; i < max_retransmissions; i++) { success = await fetchDataPeriodically(); await new Promise((resolve) => setTimeout(resolve, 1000)); if (success) { diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx index 94bd65089..deb2b99ec 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx @@ -67,6 +67,25 @@ function InstallationTabs() { setCurrentTab(value); }; + const navigateToTabPath = (pathname: string, tab_value: string): string => { + let pathlist = pathname.split('/'); + let ret_path = ''; + for (let i = 1; i < pathlist.length; i++) { + if (Number.isNaN(Number(pathlist[i]))) { + ret_path += '/'; + ret_path += pathlist[i]; + } else { + ret_path += '/'; + ret_path += pathlist[i]; + ret_path += '/'; + break; + } + } + + ret_path += tab_value; + return ret_path; + }; + const singleInstallationTabs = currentUser.hasWriteAccess ? [ { @@ -275,10 +294,7 @@ function InstallationTabs() { to={ tab.value === 'list' || tab.value === 'tree' ? routes[tab.value] - : location.pathname.substring( - 0, - location.pathname.lastIndexOf('/') + 1 - ) + routes[tab.value] + : navigateToTabPath(location.pathname, routes[tab.value]) } /> ))} @@ -334,12 +350,7 @@ function InstallationTabs() { value={tab.value} component={Link} label={tab.label} - to={ - location.pathname.substring( - 0, - location.pathname.lastIndexOf('/') + 1 - ) + routes[tab.value] - } + to={routes[tab.value]} /> ))}