diff --git a/csharp/App/SaliMax/Doc/SalimaxConfigReadme.txt b/csharp/App/SaliMax/Doc/SalimaxConfigReadme.txt new file mode 100644 index 000000000..559eb6ca8 --- /dev/null +++ b/csharp/App/SaliMax/Doc/SalimaxConfigReadme.txt @@ -0,0 +1,110 @@ +"MinSoc": Number, 0 - 100 this is the minimum State of Charge that the batteries must not go below, + "ForceCalibrationCharge": Boolean (true or false), A flag to force a calibration charge, + "DisplayIndividualBatteries": Boolean (true or false), To display the indvidual batteries + "PConstant": Number 0 - 1, P value of our controller. + "GridSetPoint": Number in Watts, The set point of our controller. + "BatterySelfDischargePower": Number, 200, this a physical measurement of the self discharging power. + "HoldSocZone": Number, 1, This is magic number for the soft landing factor. + "IslandMode": { // Dc Link Voltage in Island mode + "AcDc": { + "MaxDcLinkVoltage": Number, 810, Max Dc Link Voltage, + "MinDcLinkVoltage": Number, 690, Min Dc Link Voltage, + "ReferenceDcLinkVoltage": Number, 750, Reference Dc Link + }, + "DcDc": { + "LowerDcLinkVoltage": Number, 50, Lower Dc Link Window , + "ReferenceDcLinkVoltage": 750, reference Dc Link + "UpperDcLinkVoltage": Number, 50, Upper Dc Link Window , + } + }, + "GridTie": {// Dc Link Voltage in GrieTie mode + "AcDc": { + "MaxDcLinkVoltage":Number, 780, Max Dc Link Voltage, + "MinDcLinkVoltage": Number, 690, Min Dc Link Voltage, + "ReferenceDcLinkVoltage": Number, 750, Reference Dc Link + }, + "DcDc": { + "LowerDcLinkVoltage": Number, 20, Lower Dc Link Window , + "ReferenceDcLinkVoltage": 750, reference Dc Link + "UpperDcLinkVoltage": Number, 20, Upper Dc Link Window , + } + }, + "MaxBatteryChargingCurrent":Number, 0 - 210, Max Charging current by DcDc + "MaxBatteryDischargingCurrent":Number, 0 - 210, Max Discharging current by DcDc + "MaxDcPower": Number, 0 - 10000, Max Power exported/imported by DcDc (10000 is the maximum) + "MaxChargeBatteryVoltage": Number, 57, Max Charging battery Voltage + "MinDischargeBatteryVoltage": Number, 0, Min Charging Battery Voltage + "Devices": { This is All Salimax devices (including offline ones) + "RelaysIp": { + "DeviceState": 1, // 0: is not present, 1: Present and Can be mesured, 2: Present but must be computed/calculted + "Host": "10.0.1.1", // Ip @ of the device in the local network + "Port": 502 // port + }, + "GridMeterIp": { + "DeviceState": 1, + "Host": "10.0.4.1", + "Port": 502 + }, + "PvOnAcGrid": { + "DeviceState": 0, // If a device is not present + "Host": "false", // this is not important + "Port": 0 // this is not important + }, + "LoadOnAcGrid": { + "DeviceState": 2, // this is a computed device + "Host": "true", + "Port": 0 + }, + "PvOnAcIsland": { + "DeviceState": 0, + "Host": "false", + "Port": 0 + }, + "IslandBusLoadMeterIp": { + "DeviceState": 1, + "Host": "10.0.4.2", + "Port": 502 + }, + "TruConvertAcIp": { + "DeviceState": 1, + "Host": "10.0.2.1", + "Port": 502 + }, + "PvOnDc": { + "DeviceState": 1, + "Host": "10.0.5.1", + "Port": 502 + }, + "LoadOnDc": { + "DeviceState": 0, + "Host": "false", + "Port": 0 + }, + "TruConvertDcIp": { + "DeviceState": 1, + "Host": "10.0.3.1", + "Port": 502 + }, + "BatteryIp": { + "DeviceState": 1, + "Host": "localhost", + "Port": 6855 + }, + "BatteryNodes": [ // this is a list of nodes + 2, + 3, + 4, + 5, + 6 + ] + }, + "S3": { // this is parameters of S3 Buckets and co + "Bucket": "8-3e5b3069-214a-43ee-8d85-57d72000c19d", + "Region": "sos-ch-dk-2", + "Provider": "exo.io", + "Key": "EXO502627299197f83e8b090f63", + "Secret": "jUNYJL6B23WjndJnJlgJj4rc1i7uh981u5Aba5xdA5s", + "ContentType": "text/plain; charset=utf-8", + "Host": "8-3e5b3069-214a-43ee-8d85-57d72000c19d.sos-ch-dk-2.exo.io", + "Url": "https://8-3e5b3069-214a-43ee-8d85-57d72000c19d.sos-ch-dk-2.exo.io" + } diff --git a/csharp/App/SaliMax/deploy.sh b/csharp/App/SaliMax/deploy.sh index 057e274b5..9bd0d801b 100755 --- a/csharp/App/SaliMax/deploy.sh +++ b/csharp/App/SaliMax/deploy.sh @@ -21,6 +21,6 @@ rsync -v \ ./bin/Release/$dotnet_version/linux-x64/publish/* \ $username@"$salimax_ip":~/salimax -echo -e "\n============================ Execute ============================\n" +#echo -e "\n============================ Execute ============================\n" -sshpass -p "$root_password" ssh -o StrictHostKeyChecking=no -t "$username"@"$salimax_ip" "echo '$root_password' | sudo -S sh -c 'cd salimax && ./restart'" 2>/dev/null +#sshpass -p "$root_password" ssh -o StrictHostKeyChecking=no -t "$username"@"$salimax_ip" "echo '$root_password' | sudo -S sh -c 'cd salimax && ./restart'" 2>/dev/null diff --git a/csharp/App/SaliMax/tunnelstoSalimaxX.sh b/csharp/App/SaliMax/tunnelstoSalimaxX.sh index 5e3865181..03b256195 100755 --- a/csharp/App/SaliMax/tunnelstoSalimaxX.sh +++ b/csharp/App/SaliMax/tunnelstoSalimaxX.sh @@ -34,7 +34,7 @@ tunnel "Ext Emu Meter (modbus) " 10.0.4.1 502 5003 tunnel "Int Emu Meter " 10.0.4.2 502 5004 tunnel "AMPT (modbus) " 10.0.5.1 502 5005 tunnel "Adam " 10.0.1.1 502 5006 #for AMAX is 10.0.1.3 -tunnel "Batteries " 127.0.0.1 6855 5007 +tunnel "Batteries " 127.0.0.1 6855 5007 echo echo "press any key to close the tunnels ..." diff --git a/firmware/opt/innovenergy/scripts/ExtractS3README.txt b/firmware/opt/innovenergy/scripts/ExtractS3README.txt new file mode 100644 index 000000000..97854d4d7 --- /dev/null +++ b/firmware/opt/innovenergy/scripts/ExtractS3README.txt @@ -0,0 +1,127 @@ +This README file provides a comprehensive guide to utilizing a Python script for interacting with S3 storage, +specifically designed for downloading and processing data files based on a specified time range and key parameters. +The script requires Python3 installed on your system and makes use of the s3cmd tool for accessing data in cloud storage. +It also illustrates the process of configuring s3cmd by creating a .s3cfg file with your access credentials. + + +############ Create the .s3cfg file in home directory ################ + +nano .s3cfg + +Copy this lines inside the file. + +[default] +host_base = sos-ch-dk-2.exo.io +host_bucket = %(bucket)s.sos-ch-dk-2.exo.io +access_key = EXO4d838d1360ba9fb7d51648b0 +secret_key = _bmrp6ewWAvNwdAQoeJuC-9y02Lsx7NV6zD-WjljzCU +use_https = True + + +############ S3cmd instalation ################ + +Please install s3cmd for retrieving data from our Cloud storage. + +sudo apt install s3cmd + +############ Python3 instalation ################ + +To check if you have already have python3, run this command + + python3 --version + + +To install you can use this command: + +1) sudo apt update + +2) sudo apt install python3 + +3) python3 --version (to check if pyhton3 installed correctly) + + +############ Run extractRange.py ################ + +usage: extractRange.py [-h] --key KEY --bucket-number BUCKET_NUMBER start_timestamp end_timestamp + +KEY: the key can be a one word or a path + + for example: /DcDc/Devices/2/Status/Dc/Battery/voltage ==> this will provide us a Dc battery Voltage of the DcDc device 2. + example : Dc/Battery/voltage ==> This will provide all DcDc Device voltage (including the avg voltage of all DcDc device) + example : voltage ==> This will provide all voltage of all devices in the Salimax + +BUCKET_NUMBER: This a number of bucket name for the instalation + + List of bucket number/ instalation: + 1: Prototype + 2: Marti Technik (Bern) + 3: Schreinerei Schönthal (Thun) + 4: Wittmann Kottingbrunn + 5: Biohof Gubelmann (Walde) + 6: Steakhouse Mettmenstetten + 7: Andreas Ballif / Lerchenhof + 8: Weidmann Oberwil (ZG) + 9: Christian Huber (EBS Elektrotechnik) + + +start_timestamp end_timestamp: this must be a correct timestamp of 10 digits. +The start_timestamp must be smaller than the end_timestamp. + +PS: The data will be downloaded to a folder named S3cmdData_{Bucket_Number}. If this folder does not exist, it will be created. +If the folder exist, it will try to download data if there is no files in the folder. +If the folder exist and contains at least one file, it will only data extraction. + +Example command: + +python3 extractRange.py 1707087500 1707091260 --key ActivePowerImportT2 --bucket-number 1 + + +################################ EXTENDED FEATURES FOR MORE ADVANCED USAGE ################################ + +1) Multiple Keys Support: + +The script supports the extraction of data using multiple keys. Users can specify one or multiple keys separated by commas with the --keys parameter. +This feature allows for more granular data extraction, catering to diverse data analysis requirements. For example, users can extract data for different +metrics or parameters from the same or different CSV files within the specified range. + +2) Exact Match for Keys: + +With the --exact_match flag, the script offers an option to enforce exact matching of keys. This means that only the rows containing a key that exactly +matches the specified key(s) will be considered during the data extraction process. This option enhances the precision of the data extraction, making it +particularly useful when dealing with CSV files that contain similar but distinct keys. + +3) Dynamic Header Generation: + +The script dynamically generates headers for the output CSV file based on the keys provided. This ensures that the output file accurately reflects the +extracted data, providing a clear and understandable format for subsequent analysis. The headers correspond to the keys used for data extraction, making +it easy to identify and analyze the extracted data. + +4)Advanced Data Processing Capabilities: + +i) Booleans as Numbers: The --booleans_as_numbers flag allows users to convert boolean values (True/False) into numeric representations (1/0). This feature +is particularly useful for analytical tasks that require numerical data processing. + +ii) Sampling Stepsize: The --sampling_stepsize parameter enables users to define the granularity of the time range for data extraction. By specifying the number +of 2-second intervals, users can adjust the sampling interval, allowing for flexible data retrieval based on time. + +Example Command: + +python3 extractRange.py 1707087500 1707091260 --keys ActivePowerImportT2,Soc --bucket-number 1 --exact_match --booleans_as_numbers + + +This command extracts data for ActivePowerImportT2 and TotalEnergy keys from bucket number 1, between the specified timestamps, with exact +matching of keys and boolean values converted to numbers. + +Visualization and Data Analysis: + +After data extraction, the script facilitates data analysis by: + +i) Providing a visualization function to plot the extracted data. Users can modify this function to suit their specific analysis needs, adjusting +plot labels, titles, and other matplotlib parameters. + +ii) Saving the extracted data in a CSV file, with dynamically generated headers based on the specified keys. This file can be used for further +analysis or imported into data analysis tools. + +This Python script streamlines the process of data retrieval from S3 storage, offering flexible and powerful options for data extraction, visualization, +and analysis. Its support for multiple keys, exact match filtering, and advanced processing capabilities make it a valuable tool for data analysts and +researchers working with time-series data or any dataset stored in S3 buckets. diff --git a/firmware/opt/innovenergy/scripts/TunnelToSalimax.sh b/firmware/opt/innovenergy/scripts/TunnelToSalimax.sh new file mode 100644 index 000000000..f7c65bea5 --- /dev/null +++ b/firmware/opt/innovenergy/scripts/TunnelToSalimax.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +host="ie-entwicklung@$1" + +tunnel() { + name=$1 + ip=$2 + rPort=$3 + lPort=$4 + + echo -n "$name @ $ip mapped to localhost:$lPort " + ssh -nNTL "$lPort:$ip:$rPort" "$host" 2> /dev/null & + + until nc -vz 127.0.0.1 $lPort 2> /dev/null + do + echo -n . + sleep 0.3 + done + + echo "ok" +} + +echo "" + +tunnel "Trumpf Inverter (http) " 10.0.2.1 80 8001 +tunnel "Trumpf DCDC (http) " 10.0.3.1 80 8002 +tunnel "Ext Emu Meter (http) " 10.0.4.1 80 8003 +tunnel "Int Emu Meter (http) " 10.0.4.2 80 8004 +tunnel "AMPT (http) " 10.0.5.1 8080 8005 + +tunnel "Trumpf Inverter (modbus)" 10.0.2.1 502 5001 +tunnel "Trumpf DCDC (modbus) " 10.0.3.1 502 5002 +tunnel "Ext Emu Meter (modbus) " 10.0.4.1 502 5003 +tunnel "Int Emu Meter " 10.0.4.2 502 5004 +tunnel "AMPT (modbus) " 10.0.5.1 502 5005 +tunnel "Adam " 10.0.1.1 502 5006 +tunnel "Batteries " 127.0.0.1 6855 5007 + +echo +echo "press any key to close the tunnels ..." +read -r -n 1 -s +kill $(jobs -p) +echo "done" + diff --git a/firmware/opt/innovenergy/scripts/deployAllInstalllationsSalimax.ch b/firmware/opt/innovenergy/scripts/deployAllInstalllationsSalimax.ch new file mode 100644 index 000000000..6b1c68296 --- /dev/null +++ b/firmware/opt/innovenergy/scripts/deployAllInstalllationsSalimax.ch @@ -0,0 +1,31 @@ +#!/bin/bash + +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' +root_password='Salimax4x25' + +set -e + +echo -e "\n============================ Build ============================\n" + +dotnet publish \ + ./SaliMax.csproj \ + -p:PublishTrimmed=false \ + -c Release \ + -r linux-x64 + +echo -e "\n============================ Deploy ============================\n" +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") + +for ip_address in "${ip_addresses[@]}"; do + rsync -v \ + --exclude '*.pdb' \ + ./bin/Release/$dotnet_version/linux-x64/publish/* \ + $username@"$ip_address":~/salimax + + ssh "$username"@"$ip_address" "cd salimax && echo '$root_password' | sudo -S ./restart" + + +echo "Deployed and ran commands on $ip_address" +done diff --git a/firmware/opt/innovenergy/scripts/deploySalimax.sh b/firmware/opt/innovenergy/scripts/deploySalimax.sh new file mode 100644 index 000000000..364c30a55 --- /dev/null +++ b/firmware/opt/innovenergy/scripts/deploySalimax.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +dotnet_version='net6.0' +salimax_ip="$1" +username='ie-entwicklung' + +set -e + +echo -e "\n============================ Build ============================\n" + +dotnet publish \ + ./SaliMax.csproj \ + -p:PublishTrimmed=false \ + -c Release \ + -r linux-x64 + +echo -e "\n============================ Deploy ============================\n" + +rsync -v \ + --exclude '*.pdb' \ + ./bin/Release/$dotnet_version/linux-x64/publish/* \ + $username@"$salimax_ip":~/salimax + diff --git a/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax b/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax new file mode 100755 index 000000000..458fe44ed --- /dev/null +++ b/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax @@ -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/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax.py b/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax.py new file mode 100755 index 000000000..458fe44ed --- /dev/null +++ b/firmware/opt/innovenergy/scripts/upload-bms-firmware-Salimax.py @@ -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:])