Files
iarican b09ce6eaad feat: add dynamic COM port scanning to CLI setup
Added automatic COM port discovery and selection during startup to remove hardcoded COM9 dependency. Updated GUI status and title bar to display the active port.
2026-07-21 15:33:55 +02:00

427 lines
19 KiB
Python

import serial
import serial.tools.list_ports # 🌟 Added for automatic port scanning
import time
import threading
import queue
import sys
import math
from PyQt5 import QtWidgets, QtCore, QtGui
import pyqtgraph as pg
# --- IMPORT OFFICIAL INFINEON MODULES ---
try:
from tle9018dqk.control import TLE9018DQK
from tle9018dqk.registers import REG
except ImportError:
print("\n[!] ERROR: 'tle9018dqk' folder was not found in the same directory!")
sys.exit(1)
# --- GLOBAL INITIALIZATION CACHE ---
RUNTIME_PROFILE = {
"ch_1_3_byte": 0xCC,
"ch_0_1_byte": 0xC9,
"ch_4_byte": 0x0C,
"ntc_1_4_r25": 10000.0,
"ntc_1_4_beta": 3435.0
}
# --- CLI HARDWARE INTERACTIVE SETUP (BEFORE STARTUP) ---
print("=" * 60)
print(" TLE9018 MULTI-NODE BMS HARDWARE FOOTPRINT SETUP")
print("=" * 60)
# 🌟 STEP 1: COM PORT SELECTION
ports = [p.device for p in serial.tools.list_ports.comports()]
if not ports:
print("[!] WARNING: No active COM Ports detected on the system!")
selected_port = input("[?] Enter the target port manually (e.g. COM9): ").strip().upper()
if not selected_port:
selected_port = "COM9"
else:
print("\nActive COM Ports detected on the system:")
for idx, p in enumerate(ports, start=1):
print(f" [{idx}] {p}")
port_choice = input(f"[?] Select a port option (1-{len(ports)}) or enter manually: ").strip()
if port_choice.isdigit() and 1 <= int(port_choice) <= len(ports):
selected_port = ports[int(port_choice) - 1]
else:
selected_port = port_choice.upper() if port_choice else ports[0]
print(f"-> Selected Port: {selected_port}")
# 🌟 STEP 2: NODE COUNT SELECTION
try:
TOTAL_NODES_INPUT = int(input("\n[?] Enter total number of TLE9018 nodes in the daisy chain (1-8): "))
if not (1 <= TOTAL_NODES_INPUT <= 8):
raise ValueError
except ValueError:
print("[!] Invalid input. Defaulting to 1 node.")
TOTAL_NODES_INPUT = 1
# 🌟 STEP 3: NTC PROFILE SELECTION
print("\nSelect the sensor type physically populated on channels 1 to 4:")
print(" [1] 10 kOhm NTCs (Standard Baseline Array)")
print(" [2] 100 kOhm NTCs (High Impedance Footprint)")
ntc_choice = input("[?] Choice (1 or 2): ").strip()
if ntc_choice == "2":
print("-> Configuring Channels 1-4 for 100k NTC profiles (10uA Bias Mode).")
RUNTIME_PROFILE["ntc_1_4_r25"] = 100000.0
RUNTIME_PROFILE["ntc_1_4_beta"] = 4250.0
RUNTIME_PROFILE["ch_1_3_byte"] = 0x99
RUNTIME_PROFILE["ch_0_1_byte"] = 0x99
RUNTIME_PROFILE["ch_4_byte"] = 0x09
else:
print("-> Configuring Channels 1-4 for 10k NTC profiles (40uA Bias Mode).")
RUNTIME_PROFILE["ntc_1_4_r25"] = 10000.0
RUNTIME_PROFILE["ntc_1_4_beta"] = 3435.0
RUNTIME_PROFILE["ch_1_3_byte"] = 0xCC
RUNTIME_PROFILE["ch_0_1_byte"] = 0xC9
RUNTIME_PROFILE["ch_4_byte"] = 0x0C
print("=" * 60)
print("DECLARATION: Hardware initialization parameters verified.")
print(f" -> Active Communication Port : {selected_port}")
print(f" -> Deploying total chain nodes: {TOTAL_NODES_INPUT}")
print(f" -> Channel 0 Target Profile : 100k Ohm NTC")
print(f" -> Channels 1-4 Target Profile: {int(RUNTIME_PROFILE['ntc_1_4_r25']/1000)}k Ohm NTC")
print("PROCEEDING TO MONITORING INTERFACE...")
print("=" * 60)
# --- GLOBAL HARDWARE CONFIGURATION CONSTANTS ---
CONFIG = {
"PORT": selected_port, # Dynamically assigned based on CLI selection
"BAUDRATE": 2000000,
"SAMPLE_INTERVAL_S": 0.1,
"MAX_PLOT_POINTS": 50,
}
R_TMP_OHM = 100.0
FSR_TMP_V = 2.0
NTC_T25_K = 298.15
NTC_R25_MAP = {0: 100000.0, 1: RUNTIME_PROFILE["ntc_1_4_r25"], 2: RUNTIME_PROFILE["ntc_1_4_r25"], 3: RUNTIME_PROFILE["ntc_1_4_r25"], 4: RUNTIME_PROFILE["ntc_1_4_r25"]}
NTC_BETA_MAP = {0: 4250.0, 1: RUNTIME_PROFILE["ntc_1_4_beta"], 2: RUNTIME_PROFILE["ntc_1_4_beta"], 3: RUNTIME_PROFILE["ntc_1_4_beta"], 4: RUNTIME_PROFILE["ntc_1_4_beta"]}
_PLOT_QUEUE = queue.Queue(maxsize=100)
_COMMAND_QUEUE = queue.Queue()
_STOP_REQUESTED = False
_ACTIVE_NODES = TOTAL_NODES_INPUT
class MultiCMBDashboard(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"TLE9018 Master BMS - [{CONFIG['PORT']}] Dual Scope Control")
self.current_view_node = 1
self.data_history = {}
self.node_states = {i: {"e6": 0, "e7": 0} for i in range(1, _ACTIVE_NODES + 1)}
self.setup_ui()
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.update_ui)
self.timer.start(40)
def setup_ui(self):
self.setStyleSheet("""
QMainWindow { background-color: #0A0A0B; }
QLabel { color: #FFFFFF; font-family: 'Segoe UI'; }
QComboBox, QSpinBox { background-color: #2A2A2A; color: #FFFFFF; border: 1px solid #444; padding: 8px; border-radius: 6px; font-size: 18px; }
QComboBox QAbstractItemView { background-color: #2A2A2A; color: #FFFFFF; selection-background-color: #4A4A4A; selection-color: #FFFFFF; }
QTabBar::tab { background: #222; color: #FFFFFF; padding: 15px; min-width: 180px; font-size: 14px; }
QTabBar::tab:selected { background: #333; border-bottom: 4px solid #90CAF9; }
QScrollArea { border: none; background: transparent; }
""")
central = QtWidgets.QWidget(); self.setCentralWidget(central)
main_layout = QtWidgets.QVBoxLayout(central)
ctrl_panel = QtWidgets.QFrame()
ctrl_panel.setMinimumHeight(100)
ctrl_panel.setStyleSheet("background-color: #1A1A1A; border-radius: 12px; border: 1px solid #333;")
ctrl_layout = QtWidgets.QHBoxLayout(ctrl_panel)
ctrl_layout.setContentsMargins(25, 15, 25, 15)
ctrl_layout.setSpacing(20)
lbl_nodes = QtWidgets.QLabel("Nodes In Chain:"); lbl_nodes.setStyleSheet("font-weight: bold; font-size: 18px; color: #90CAF9;")
self.spin_nodes = QtWidgets.QSpinBox(); self.spin_nodes.setRange(1, 8); self.spin_nodes.setValue(_ACTIVE_NODES); self.spin_nodes.setMinimumHeight(45)
self.btn_init = QtWidgets.QPushButton("RE-ENUMERATE CHAIN")
self.btn_init.setStyleSheet("background-color: #FFAB91; color: #1A1A1A; font-weight: bold; font-size: 16px; height: 50px; padding: 0 20px; border-radius: 8px;")
self.btn_init.clicked.connect(self.run_enumeration)
self.status_lbl = QtWidgets.QLabel(f"PORT: {CONFIG['PORT']} | LIVE STREAMING"); self.status_lbl.setStyleSheet("font-weight: bold; font-size: 16px; color: #A5D6A7;")
lbl_view = QtWidgets.QLabel("Viewing Board:"); lbl_view.setStyleSheet("font-weight: bold; font-size: 18px; color: #FFCC80;")
self.node_selector = QtWidgets.QComboBox(); self.node_selector.setMinimumHeight(45); self.node_selector.setMinimumWidth(200)
for i in range(_ACTIVE_NODES):
self.node_selector.addItem(f"Board ID {i+1}")
self.node_selector.currentIndexChanged.connect(self.change_node_view)
ctrl_layout.addWidget(lbl_nodes); ctrl_layout.addWidget(self.spin_nodes)
ctrl_layout.addWidget(self.btn_init); ctrl_layout.addWidget(self.status_lbl); ctrl_layout.addStretch()
ctrl_layout.addWidget(lbl_view); ctrl_layout.addWidget(self.node_selector)
main_layout.addWidget(ctrl_panel)
self.tabs = QtWidgets.QTabWidget(); main_layout.addWidget(self.tabs)
self.v_cards = []
v_tab = QtWidgets.QWidget(); v_lay = QtWidgets.QVBoxLayout(v_tab); v_scroll = QtWidgets.QScrollArea(); v_scroll.setWidgetResizable(True)
v_cont = QtWidgets.QWidget(); self.v_grid = QtWidgets.QVBoxLayout(v_cont)
self.v_grid.setSpacing(10)
for i in range(18):
card = self.create_card(f"CELL {i+1:02d}", "#90CAF9", is_voltage=True)
self.v_cards.append(card); self.v_grid.addWidget(card['frame'])
v_scroll.setWidget(v_cont); v_lay.addWidget(v_scroll); self.tabs.addTab(v_tab, "VOLTAGES")
self.t_cards = []
t_tab = QtWidgets.QWidget(); t_lay = QtWidgets.QVBoxLayout(t_tab); t_scroll = QtWidgets.QScrollArea(); t_scroll.setWidgetResizable(True)
t_cont = QtWidgets.QWidget(); self.t_grid = QtWidgets.QVBoxLayout(t_cont)
self.t_grid.setSpacing(10)
for i in range(5):
title = f"NTC TEMPERATURE {i+1:02d} (100k)" if i == 0 else f"NTC TEMPERATURE {i+1:02d} ({int(RUNTIME_PROFILE['ntc_1_4_r25']/1000)}k)"
card = self.create_card(title, "#80CBC4", is_voltage=False)
self.t_cards.append(card); self.t_grid.addWidget(card['frame'])
t_scroll.setWidget(t_cont); t_lay.addWidget(t_scroll); self.tabs.addTab(t_tab, "TEMPERATURES (5 CH ACTIVE)")
def create_card(self, title, color, is_voltage):
frame = QtWidgets.QFrame(); frame.setMinimumHeight(160); frame.setStyleSheet("background-color: #161616; border: 1px solid #333; border-radius: 12px;")
layout = QtWidgets.QHBoxLayout(frame); info = QtWidgets.QVBoxLayout()
info.setContentsMargins(15, 15, 15, 15)
lbl = QtWidgets.QLabel(title); lbl.setStyleSheet(f"color: {color}; font-size: 20px; font-weight: bold; border: none;")
val = QtWidgets.QLabel("--- V" if is_voltage else "--- °C")
val.setStyleSheet("color: #FFFFFF; font-size: 32px; font-family: 'Consolas'; font-weight: bold; border: none;")
info.addWidget(lbl); info.addWidget(val); info.addStretch()
if is_voltage:
btn = QtWidgets.QPushButton("BALANCE")
btn.setCheckable(True)
btn.clicked.connect(self.update_hardware_configs)
btn.setStyleSheet("QPushButton { background-color: #2A2A2A; color: #A0AAB5; height: 38px; border-radius: 6px; font-size: 13px; font-weight: bold; min-width: 100px; } QPushButton:checked { background-color: #FF3D00; color: #fff; font-weight: bold; }")
info.addWidget(btn)
card_data = {'frame': frame, 'val_lbl': val, 'btn': btn}
else:
card_data = {'frame': frame, 'val_lbl': val}
plot = pg.PlotWidget()
plot.setBackground('#0E0F12')
plot.setMouseEnabled(x=False, y=False)
plot.hideAxis('bottom')
plot.hideButtons()
plot.getAxis('left').setWidth(40)
plot.showGrid(y=True, alpha=0.1)
curve = plot.plot(pen=pg.mkPen(color, width=2.5))
plot.getViewBox().enableAutoRange(axis='y', enable=True)
layout.addLayout(info, 1); layout.addWidget(plot, 5)
card_data['curve'] = curve
card_data['plot'] = plot
return card_data
def run_enumeration(self):
count = self.spin_nodes.value()
_COMMAND_QUEUE.put({"type": "ENUM", "count": count})
self.node_selector.clear()
for i in range(count):
self.node_selector.addItem(f"Board ID {i+1}")
if (i+1) not in self.node_states: self.node_states[i+1] = {"e6": 0, "e7": 0}
self.data_history.clear()
def change_node_view(self, index):
if index < 0: return
self.current_view_node = index + 1
state = self.node_states.get(self.current_view_node, {"e6": 0, "e7": 0})
for i, card in enumerate(self.v_cards):
card['btn'].blockSignals(True)
mask = state['e6'] if i < 12 else state['e7']
card['btn'].setChecked(bool(mask & (1 << (i % 12))))
card['btn'].blockSignals(False); card['plot'].enableAutoRange(axis='y', enable=True)
def update_hardware_configs(self):
node = self.current_view_node
if node not in self.node_states: self.node_states[node] = {"e6": 0, "e7": 0}
e6, e7 = 0, 0
for i, card in enumerate(self.v_cards):
if card['btn'].isChecked():
if i < 12: e6 |= (1 << i)
else: e7 |= (1 << (i - 12))
self.node_states[node]['e6'], self.node_states[node]['e7'] = e6, e7
_COMMAND_QUEUE.put({"type": "BAL", "node": node, "e6": e6, "e7": e7})
def update_ui(self):
latest_data = {}
while not _PLOT_QUEUE.empty():
try:
data = _PLOT_QUEUE.get_nowait()
latest_data[data['node']] = data
except queue.Empty:
break
for nid, data in latest_data.items():
if nid not in self.data_history:
self.data_history[nid] = {
'v': [[0.0] * CONFIG["MAX_PLOT_POINTS"] for _ in range(18)],
't': [[25.0] * CONFIG["MAX_PLOT_POINTS"] for _ in range(5)]
}
for i in range(18):
self.data_history[nid]['v'][i].append(data['v'][i])
if len(self.data_history[nid]['v'][i]) > CONFIG["MAX_PLOT_POINTS"]:
self.data_history[nid]['v'][i].pop(0)
for i in range(5):
self.data_history[nid]['t'][i].append(data['t'][i])
if len(self.data_history[nid]['t'][i]) > CONFIG["MAX_PLOT_POINTS"]:
self.data_history[nid]['t'][i].pop(0)
if nid == self.current_view_node:
for i in range(18):
self.v_cards[i]['val_lbl'].setText(f"{data['v'][i]:.4f} V")
self.v_cards[i]['curve'].setData(self.data_history[nid]['v'][i])
for i in range(5):
if math.isnan(data['t'][i]):
self.t_cards[i]['val_lbl'].setText("Fault Wire")
else:
self.t_cards[i]['val_lbl'].setText(f"{data['t'][i]:.2f} °C")
self.t_cards[i]['curve'].setData(self.data_history[nid]['t'][i])
# --- CONFIGURE HARDWARE PARSING ENGINE ---
def configure_node_registers(isoUART, node_id):
"""Initializes register maps identically across deployment loops without fault trapping limits."""
isoUART.writeRegister(node_id, REG['OL_OV_CFG'], 0x03, 0xFF)
isoUART.writeRegister(node_id, REG['OL_UV_CFG'], 0x00, 0x00)
isoUART.writeRegister(node_id, REG['PART_CFG_0'], 0xFF, 0xFF)
isoUART.writeRegister(node_id, REG['PART_CFG_1'], 0xC0, 0x00)
isoUART.writeRegister(node_id, REG['EXT_TEMP_CFG_2'], 0x50, 0x00)
isoUART.writeRegister(node_id, 0x3C, RUNTIME_PROFILE["ch_1_3_byte"], RUNTIME_PROFILE["ch_0_1_byte"])
isoUART.writeRegister(node_id, 0x3D, 0x00, RUNTIME_PROFILE["ch_4_byte"])
isoUART.writeRegister(node_id, REG['MISC_CTRL'], 0x00, 0x01)
isoUART.writeRegister(node_id, REG['GEN_DIAG'], 0xFF, 0xFF)
isoUART.writeRegister(node_id, REG['CH_DIAG'], 0xFF, 0xFF)
isoUART.writeRegister(node_id, REG['RR_CFG_1'], 0x80, 0x01)
isoUART.writeRegister(node_id, REG['MEAS_CTRL'], 0xE0, 0xA1)
def hardware_loop():
global _ACTIVE_NODES
print("\n" + "="*50)
print(f"STATUS VERIFICATION: Launching baseline hardware backend on {CONFIG['PORT']}...")
try:
raw_ser = serial.Serial(CONFIG["PORT"], CONFIG["BAUDRATE"], timeout=0.2)
isoUART = TLE9018DQK(raw_ser)
except Exception as e:
print(f"❌ CRITICAL SERIAL ERROR on {CONFIG['PORT']}: {e}")
return
print("[1/3] Waking up daisy chain transceiver stack...")
isoUART.wake()
time.sleep(1.2)
print(f"[2/3] Beginning auto-enumeration routine for {_ACTIVE_NODES} node(s)...")
for i in range(1, _ACTIVE_NODES + 1):
hi_byte = 0x80 if i == _ACTIVE_NODES else 0x00
ok, err = isoUART.writeRegister(0x00, REG['IF_CFG'], hi_byte, i)
if ok:
print(f" -> Node {i} successfully enumerated and addressed.")
configure_node_registers(isoUART, i)
time.sleep(0.01)
else:
print(f" ⚠️ Error configuring hardware address on tracking node index {i}")
print("[3/3] System locked. Background telemetry scanning loop running.")
print("="*50 + "\n")
while not _STOP_REQUESTED:
while not _COMMAND_QUEUE.empty():
t = _COMMAND_QUEUE.get()
if t['type'] == "ENUM":
_ACTIVE_NODES = t['count']
print(f"Re-enumeration requested. Configuring {_ACTIVE_NODES} hardware blocks...")
isoUART.wake()
time.sleep(1.2)
for i in range(1, _ACTIVE_NODES + 1):
hi_byte = 0x80 if i == _ACTIVE_NODES else 0x00
ok, err = isoUART.writeRegister(0x00, REG['IF_CFG'], hi_byte, i)
if ok:
configure_node_registers(isoUART, i)
time.sleep(0.01)
elif t['type'] == "BAL":
d1_e6, d0_e6 = (t['e6'] >> 8) & 0xFF, t['e6'] & 0xFF
d1_e7, d0_e7 = (t['e7'] >> 8) & 0xFF, t['e7'] & 0xFF
isoUART.writeRegister(t['node'], REG['BAL_CTRL_0'], d1_e6, d0_e6)
isoUART.writeRegister(t['node'], REG['BAL_CTRL_1'], d1_e7, d0_e7)
if _ACTIVE_NODES > 0:
for n in range(1, _ACTIVE_NODES + 1):
raw_ser.reset_input_buffer()
isoUART.resetWDT(n, 0x7F)
isoUART.writeRegister(n, REG['MEAS_CTRL'], 0xE0, 0xA1)
time.sleep(0.006)
# 1. Read 18 Cell Voltages
v_list = []
for i in range(18):
ok, data = isoUART.readRegister(n, REG[f'PCVM_{i}'])
if ok and len(data) >= 2:
raw_val = (data[1] << 8) | data[0]
v_list.append((raw_val / 65535.0) * 5.0)
else:
v_list.append(0.0)
# 2. Read 5 Active NTC Temperature Registers
t_list = []
for i in range(5):
ok, data = raw_data = isoUART.readRegister(n, REG[f'EXT_TEMP_{i}'])
if ok and len(data) >= 2:
raw_reg = (data[1] << 8) | data[0]
valid = (raw_reg >> 15) & 0x01
intc = (raw_reg >> 10) & 0x07
code = raw_reg & 0x03FF
if valid and code > 0:
numerator = code * FSR_TMP_V * (1 << (7 - intc))
denominator = 1024 * 640e-6
r_ntc = (numerator / denominator) - R_TMP_OHM
if r_ntc > 0:
ntc_r25 = NTC_R25_MAP[i]
ntc_beta = NTC_BETA_MAP[i]
inv_t = (1.0 / NTC_T25_K) + (1.0 / ntc_beta) * math.log(r_ntc / ntc_r25)
temp_celsius = (1.0 / inv_t) - 273.15
t_list.append(temp_celsius)
else:
t_list.append(float('nan'))
else:
t_list.append(float('nan'))
else:
t_list.append(float('nan'))
try:
_PLOT_QUEUE.put_nowait({'node': n, 'v': v_list, 't': t_list})
except queue.Full:
pass
time.sleep(CONFIG["SAMPLE_INTERVAL_S"])
if __name__ == "__main__":
threading.Thread(target=hardware_loop, daemon=True).start()
app = QtWidgets.QApplication(sys.argv)
win = MultiCMBDashboard()
win.showMaximized()
sys.exit(app.exec())