Fixed path problems in front-end (configuration tab)

Push scripts for battery logging download and battery firmware update
This commit is contained in:
Noe 2024-02-26 16:56:19 +01:00
parent 5d358fa269
commit 39fc4ad331
8 changed files with 712 additions and 13 deletions

View File

@ -296,7 +296,7 @@ public static class ExoCmd
byte[] replyData = udpClient.Receive(ref remoteEndPoint); byte[] replyData = udpClient.Receive(ref remoteEndPoint);
string replyMessage = Encoding.UTF8.GetString(replyData); string replyMessage = Encoding.UTF8.GetString(replyData);
Console.WriteLine("Received " + replyMessage + " from installation " + installation.VpnIp); Console.WriteLine("Received " + replyMessage + " from installation " + installation.VpnIp);
break; return true;
} }
catch (SocketException ex) 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 s3Region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", S3Credentials!);
//var url = s3Region.Bucket(installation.BucketName()).Path("config.json"); //var url = s3Region.Bucket(installation.BucketName()).Path("config.json");

View File

@ -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__ + ' <slave id> <serial device>')
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.<serial device>" 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:])

View File

@ -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 <ip_address> <battery_ids>"
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

Binary file not shown.

View File

@ -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

View File

@ -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__ + ' <serial device> <battery id> <firmware>'))
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:])

View File

@ -78,7 +78,9 @@ function Installation(props: singleInstallationProps) {
const fetchDataOnlyOneTime = async () => { const fetchDataOnlyOneTime = async () => {
let success = false; let success = false;
while (true) { const max_retransmissions = 3;
for (let i = 0; i < max_retransmissions; i++) {
success = await fetchDataPeriodically(); success = await fetchDataPeriodically();
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
if (success) { if (success) {

View File

@ -67,6 +67,25 @@ function InstallationTabs() {
setCurrentTab(value); 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 const singleInstallationTabs = currentUser.hasWriteAccess
? [ ? [
{ {
@ -275,10 +294,7 @@ function InstallationTabs() {
to={ to={
tab.value === 'list' || tab.value === 'tree' tab.value === 'list' || tab.value === 'tree'
? routes[tab.value] ? routes[tab.value]
: location.pathname.substring( : navigateToTabPath(location.pathname, routes[tab.value])
0,
location.pathname.lastIndexOf('/') + 1
) + routes[tab.value]
} }
/> />
))} ))}
@ -334,12 +350,7 @@ function InstallationTabs() {
value={tab.value} value={tab.value}
component={Link} component={Link}
label={tab.label} label={tab.label}
to={ to={routes[tab.value]}
location.pathname.substring(
0,
location.pathname.lastIndexOf('/') + 1
) + routes[tab.value]
}
/> />
))} ))}
</Tabs> </Tabs>