""" LowLevelSelectorWidget (PySide2) -------------------------------- Виджет для: * Выбора XML файла с описанием переменных (как в примере пользователя) * Парсинга всех и их вложенных * Построения плоского списка путей (имя/подпуть) с расчётом абсолютного адреса (base_address + offset) * Определения структур с полями даты (year, month, day, hour, minute) * Выбора переменной и (опционально) переменной даты / ручного ввода даты * Выбора типов: ptr_type (pt_*), iq_type, return_type * Форматирования адреса в виде 0x000000 (6 HEX) * Генерации словаря/кадра для последующей LowLevel-команды (не отправляет сам) Интеграция: * Подключите сигнал variablePrepared(dict) к функции, формирующей и отправляющей пакет. * Содержимое dict: { 'address': int, 'address_hex': str, # '0x....' 'ptr_type': int, # значение enum pt_* 'iq_type': int, 'return_type': int, 'datetime': { 'year': int, 'month': int, 'day': int, 'hour': int, 'minute': int, }, 'path': str, # полный путь переменной 'type_string': str, # строка типа из XML } Зависимости: только PySide2 и стандартная библиотека. """ from __future__ import annotations import sys import xml.etree.ElementTree as ET from dataclasses import dataclass, field from typing import List, Dict, Optional, Tuple from PySide2 import QtCore, QtGui, QtWidgets from path_hints import PathHints # ------------------------------------------------------------ Enumerations -- # Сопоставление строк из XML типу ptr_type (адаптируйте под реальный проект) PTR_TYPE_MAP = { 'int8': 'pt_int8', 'signed char': 'pt_int8', 'char': 'pt_int8', 'int16': 'pt_int16', 'short': 'pt_int16', 'int': 'pt_int16', 'int32': 'pt_int32', 'long': 'pt_int32', 'int64': 'pt_int64', 'long long': 'pt_int64', 'uint8': 'pt_uint8', 'unsigned char': 'pt_uint8', 'uint16': 'pt_uint16', 'unsigned short': 'pt_uint16', 'unsigned int': 'pt_uint16', 'uint32': 'pt_uint32', 'unsigned long': 'pt_uint32', 'uint64': 'pt_uint64', 'unsigned long long': 'pt_uint64', 'float': 'pt_float', 'floatf': 'pt_float', 'struct': 'pt_struct', 'union': 'pt_union', } PT_ENUM_ORDER = [ 'pt_unknown','pt_int8','pt_int16','pt_int32','pt_int64', 'pt_uint8','pt_uint16','pt_uint32','pt_uint64','pt_float', 'pt_struct','pt_union' ] IQ_ENUM_ORDER = [ 't_iq_none','t_iq','t_iq1','t_iq2','t_iq3','t_iq4','t_iq5','t_iq6', 't_iq7','t_iq8','t_iq9','t_iq10','t_iq11','t_iq12','t_iq13','t_iq14', 't_iq15','t_iq16','t_iq17','t_iq18','t_iq19','t_iq20','t_iq21','t_iq22', 't_iq23','t_iq24','t_iq25','t_iq26','t_iq27','t_iq28','t_iq29','t_iq30' ] # Для примера: маппинг имени enum -> числовое значение (индекс по порядку) PT_ENUM_VALUE = {name: idx for idx, name in enumerate(PT_ENUM_ORDER)} IQ_ENUM_VALUE = {name: idx for idx, name in enumerate(IQ_ENUM_ORDER)} # -------------------------------------------------------------- Data types -- DATE_FIELD_SET = {'year','month','day','hour','minute'} @dataclass class MemberNode: name: str offset: int = 0 type_str: str = '' size: Optional[int] = None children: List['MemberNode'] = field(default_factory=list) # --- новые, но необязательные (совместимость) --- kind: Optional[str] = None # 'array', 'union', ... count: Optional[int] = None # size1 (число элементов в массиве) def is_date_struct(self) -> bool: if not self.children: return False child_names = {c.name for c in self.children} return DATE_FIELD_SET.issubset(child_names) @dataclass class VariableNode: name: str address: int type_str: str size: Optional[int] members: List[MemberNode] = field(default_factory=list) # --- новые, но необязательные --- kind: Optional[str] = None # 'array' count: Optional[int] = None # size1 def base_address_hex(self) -> str: return f"0x{self.address:06X}" # --------------------------- XML Parser ---------------------------- class VariablesXML: """ Читает твой XML и выдаёт плоский список путей: - Массивы -> name[i], многоуровневые -> name[i][j] - Указатель на структуру -> дети через '->' - Обычная структура -> дети через '.' """ # предположительные размеры примитивов (под STM/MCU: int=2) _PRIM_SIZE = { 'char':1, 'signed char':1, 'unsigned char':1, 'uint8_t':1, 'int8_t':1, 'short':2, 'short int':2, 'signed short':2, 'unsigned short':2, 'uint16_t':2, 'int16_t':2, 'int':2, 'signed int':2, 'unsigned int':2, 'long':4, 'unsigned long':4, 'int32_t':4, 'uint32_t':4, 'float':4, 'long long':8, 'unsigned long long':8, 'int64_t':8, 'uint64_t':8, 'double':8, } def __init__(self, path: str): self.path = path self.timestamp: str = '' self.variables: List[VariableNode] = [] self._parse() # ------------------ low helpers ------------------ @staticmethod def _parse_int_guess(txt: Optional[str]) -> Optional[int]: if not txt: return None txt = txt.strip() if txt.startswith(('0x','0X')): return int(txt, 16) # если в строке есть буквы A-F → возможно hex if any(c in 'abcdefABCDEF' for c in txt): try: return int(txt, 16) except ValueError: pass try: return int(txt, 10) except ValueError: return None @staticmethod def _is_pointer_to_struct(t: str) -> bool: if not t: return False low = t.replace('\t',' ').replace('\n',' ') return 'struct ' in low and '*' in low @staticmethod def _is_struct_or_union(t: str) -> bool: if not t: return False low = t.strip() return low.startswith('struct ') or low.startswith('union ') @staticmethod def _strip_array_suffix(t: str) -> str: return t[:-2].strip() if t.endswith('[]') else t def _guess_primitive_size(self, type_str: str) -> Optional[int]: if not type_str: return None base = type_str for tok in ('volatile','const'): base = base.replace(tok, '') base = base.replace('*',' ') base = base.replace('[',' ').replace(']',' ') base = ' '.join(base.split()).strip() return self._PRIM_SIZE.get(base) # ------------------ XML read ------------------ def _parse(self): tree = ET.parse(self.path) root = tree.getroot() ts = root.find('timestamp') self.timestamp = ts.text.strip() if ts is not None and ts.text else '' def parse_member(elem) -> MemberNode: name = elem.get('name','') offset = int(elem.get('offset','0'),16) if elem.get('offset') else 0 t = elem.get('type','') or '' size_attr = elem.get('size') size = int(size_attr,16) if size_attr else None kind = elem.get('kind') size1_attr = elem.get('size1') count = None if size1_attr: count = self._parse_int_guess(size1_attr) node = MemberNode(name=name, offset=offset, type_str=t, size=size, kind=kind, count=count) for ch in elem.findall('member'): node.children.append(parse_member(ch)) return node for var in root.findall('variable'): addr = int(var.get('address','0'),16) name = var.get('name','') t = var.get('type','') or '' size_attr = var.get('size') size = int(size_attr,16) if size_attr else None kind = var.get('kind') size1_attr = var.get('size1') count = None if size1_attr: count = self._parse_int_guess(size1_attr) members = [parse_member(m) for m in var.findall('member')] self.variables.append( VariableNode(name=name, address=addr, type_str=t, size=size, members=members, kind=kind, count=count) ) # ------------------ flatten (expanded) ------------------ def flattened(self, max_array_elems: Optional[int] = None ) -> List[Tuple[str,int,str]]: """ Вернёт [(path, addr, type_str), ...]. max_array_elems: ограничить разворачивание больших массивов (None = все). """ out: List[Tuple[str,int,str]] = [] def add(path: str, addr: int, t: str): out.append((path, addr, t)) def compute_stride(size_bytes: Optional[int], count: Optional[int], base_type: Optional[str], node_children: Optional[List[MemberNode]]) -> int: # 1) пробуем size/count if size_bytes and count and count > 0: stride = size_bytes // count if stride * count != size_bytes: # округлённо вверх stride = (size_bytes + count - 1) // count if stride <= 0: stride = 1 return stride # 2) размер примитива по типу if base_type: gs = self._guess_primitive_size(base_type) if gs: return gs # 3) попытка по детям (структура) if node_children: min_off = min(ch.offset for ch in node_children) max_end = min_off for ch in node_children: sz = ch.size if not sz: sz = self._guess_primitive_size(ch.type_str) or 1 end = ch.offset + sz if end > max_end: max_end = end stride = max_end - min_off if stride > 0: return stride return 1 def expand_members(prefix_name: str, base_addr: int, members: List[MemberNode], parent_is_ptr_struct: bool): """ Разворачиваем список members относительно базового адреса. parent_is_ptr_struct: если True, то соединение '->' иначе '.' """ join = '->' if parent_is_ptr_struct else '.' for m in members: path_m = f"{prefix_name}{join}{m.name}" if prefix_name else m.name addr_m = base_addr + m.offset add(path_m, addr_m, m.type_str) # массив? if (m.kind == 'array') or m.type_str.endswith('[]'): count = m.count if count is None: count = 0 # неизвестно → не разворачиваем if count <= 0: continue base_t = self._strip_array_suffix(m.type_str) stride = compute_stride(m.size, count, base_t, m.children if m.children else None) limit = count if max_array_elems is None else min(count, max_array_elems) for i in range(limit): path_i = f"{path_m}[{i}]" addr_i = addr_m + i*stride add(path_i, addr_i, base_t) # элемент массива: если структура / union → раскроем поля if m.children and self._is_struct_or_union(base_t): expand_members(path_i, addr_i, m.children, parent_is_ptr_struct=False) # элемент массива: если указатель на структуру elif self._is_pointer_to_struct(base_t): # у таких обычно нет children в XML, но если есть — используем expand_members(path_i, addr_i, m.children, parent_is_ptr_struct=True) continue # не массив if m.children: is_ptr_struct = self._is_pointer_to_struct(m.type_str) expand_members(path_m, addr_m, m.children, parent_is_ptr_struct=is_ptr_struct) # --- top-level --- for v in self.variables: add(v.name, v.address, v.type_str) # top-level массив? if (v.kind == 'array') or v.type_str.endswith('[]'): count = v.count if count is None: count = 0 if count > 0: base_t = self._strip_array_suffix(v.type_str) stride = compute_stride(v.size, count, base_t, v.members if v.members else None) limit = count if max_array_elems is None else min(count, max_array_elems) for i in range(limit): p = f"{v.name}[{i}]" a = v.address + i*stride add(p, a, base_t) # массив структур? if v.members and self._is_struct_or_union(base_t): expand_members(p, a, v.members, parent_is_ptr_struct=False) # массив указателей на структуры? elif self._is_pointer_to_struct(base_t): expand_members(p, a, v.members, parent_is_ptr_struct=True) continue # к след. переменной # top-level не массив if v.members: is_ptr_struct = self._is_pointer_to_struct(v.type_str) expand_members(v.name, v.address, v.members, parent_is_ptr_struct=is_ptr_struct) return out # -------------------- date candidates (как было) -------------------- def date_struct_candidates(self) -> List[Tuple[str,int]]: cands = [] for v in self.variables: # верхний уровень (если есть все поля даты) direct_names = {mm.name for mm in v.members} if DATE_FIELD_SET.issubset(direct_names): cands.append((v.name, v.address)) # проверка членов первого уровня for m in v.members: if m.is_date_struct(): cands.append((f"{v.name}.{m.name}", v.address + m.offset)) return cands # ------------------------------------------- Address / validation helpers -- HEX_ADDR_MASK = QtCore.QRegExp(r"0x[0-9A-Fa-f]{0,6}") class HexAddrValidator(QtGui.QRegExpValidator): def __init__(self, parent=None): super().__init__(HEX_ADDR_MASK, parent) @staticmethod def normalize(text: str) -> str: if not text: return '0x000000' try: val = int(text,16) except ValueError: return '0x000000' return f"0x{val & 0xFFFFFF:06X}" # --------------------------------------------------------- Main Widget ---- class LowLevelSelectorWidget(QtWidgets.QWidget): variablePrepared = QtCore.Signal(dict) xmlLoaded = QtCore.Signal(str) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle('LowLevel Variable Selector') self._xml: Optional[VariablesXML] = None self._paths = [] self._path_info = {} self._addr_index = {} self._hints = PathHints() self._build_ui() self._connect() def _build_ui(self): lay = QtWidgets.QVBoxLayout(self) # --- File chooser --- file_box = QtWidgets.QHBoxLayout() self.btn_load = QtWidgets.QPushButton('Load XML...') self.lbl_file = QtWidgets.QLabel('') self.lbl_file.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) file_box.addWidget(self.btn_load) file_box.addWidget(self.lbl_file, 1) lay.addLayout(file_box) self.lbl_timestamp = QtWidgets.QLabel('Timestamp: -') lay.addWidget(self.lbl_timestamp) form = QtWidgets.QFormLayout() # --- Search field for variable --- self.edit_var_search = QtWidgets.QLineEdit() self.edit_var_search.setPlaceholderText("Введите имя/путь или адрес 0x......") form.addRow('Variable:', self.edit_var_search) # Popup list self._popup = QtWidgets.QListView() self._popup.setWindowFlags(QtCore.Qt.ToolTip) self._popup.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self._popup.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self._popup.clicked.connect(self._on_popup_clicked) self._model_all = QtGui.QStandardItemModel(self) self._model_filtered = QtGui.QStandardItemModel(self) # Address self.edit_address = QtWidgets.QLineEdit('0x000000') self.edit_address.setValidator(HexAddrValidator(self)) self.edit_address.setMaximumWidth(120) form.addRow('Address:', self.edit_address) # Manual date spins dt_row = QtWidgets.QHBoxLayout() self.spin_year = QtWidgets.QSpinBox(); self.spin_year.setRange(2000, 2100); self.spin_year.setValue(2025) self.spin_month = QtWidgets.QSpinBox(); self.spin_month.setRange(1,12) self.spin_day = QtWidgets.QSpinBox(); self.spin_day.setRange(1,31) self.spin_hour = QtWidgets.QSpinBox(); self.spin_hour.setRange(0,23) self.spin_minute = QtWidgets.QSpinBox(); self.spin_minute.setRange(0,59) for w,label in [(self.spin_year,'Y'),(self.spin_month,'M'),(self.spin_day,'D'),(self.spin_hour,'h'),(self.spin_minute,'m')]: box = QtWidgets.QVBoxLayout() box.addWidget(QtWidgets.QLabel(label, alignment=QtCore.Qt.AlignHCenter)) box.addWidget(w) dt_row.addLayout(box) form.addRow('Manual Date:', dt_row) # Types self.cmb_ptr_type = QtWidgets.QComboBox(); self.cmb_ptr_type.addItems(PT_ENUM_ORDER) self.cmb_iq_type = QtWidgets.QComboBox(); self.cmb_iq_type.addItems(IQ_ENUM_ORDER) self.cmb_return_type = QtWidgets.QComboBox(); self.cmb_return_type.addItems(IQ_ENUM_ORDER) form.addRow('ptr_type:', self.cmb_ptr_type) form.addRow('iq_type:', self.cmb_iq_type) form.addRow('return_type:', self.cmb_return_type) lay.addLayout(form) self.btn_prepare = QtWidgets.QPushButton('Prepare Variable Dict') lay.addWidget(self.btn_prepare) lay.addStretch(1) self.txt_info = QtWidgets.QPlainTextEdit() self.txt_info.setReadOnly(True) self.txt_info.setMaximumHeight(140) lay.addWidget(QtWidgets.QLabel('Info:')) lay.addWidget(self.txt_info) # Event filter for keyboard on search field self.edit_var_search.installEventFilter(self) def _connect(self): self.btn_load.clicked.connect(self._on_load_xml) self.edit_address.editingFinished.connect(self._normalize_address) self.btn_prepare.clicked.connect(self._emit_variable) self.edit_var_search.textEdited.connect(self._on_var_search_edited) self.edit_var_search.returnPressed.connect(self._activate_current_popup_selection) # ---------------- XML Load ---------------- def _on_load_xml(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, 'Select variables XML', '', 'XML Files (*.xml);;All Files (*)') if not path: return try: self._xml = VariablesXML(path) except Exception as e: QtWidgets.QMessageBox.critical(self, 'Parse error', f'Ошибка парсинга:\n{e}') return self.lbl_file.setText(path) self.lbl_timestamp.setText(f'Timestamp: {self._xml.timestamp or "-"}') self._populate_variables() self._apply_timestamp_to_date() self.xmlLoaded.emit(path) self._log(f'Loaded {path}, variables={len(self._xml.variables)}') def _apply_timestamp_to_date(self): if not self._xml.timestamp: return import datetime try: # Пример: "Sat Jul 19 15:27:59 2025" dt = datetime.datetime.strptime(self._xml.timestamp, "%a %b %d %H:%M:%S %Y") self.spin_year.setValue(dt.year) self.spin_month.setValue(dt.month) self.spin_day.setValue(dt.day) self.spin_hour.setValue(dt.hour) self.spin_minute.setValue(dt.minute) except Exception as e: print(f"Ошибка разбора timestamp '{self._xml.timestamp}': {e}") def _populate_variables(self): if not self._xml: return flat = self._xml.flattened() # flat: [(path, addr, type_str), ...] self._paths = [] self._path_info = {} self._addr_index = {} self._model_all.clear() # держим «сырой» полный список (можно не показывать) self._model_filtered.clear() # текущие подсказки # индексирование for path, addr, t in flat: self._paths.append(path) self._path_info[path] = (addr, t) if addr in self._addr_index: self._addr_index[addr] = None else: self._addr_index[addr] = path # наполняем «all» модель (необязательная, но пусть остаётся — не используем напрямую) it = QtGui.QStandardItem(f"{path} [{addr:06X}]") it.setData(path, QtCore.Qt.UserRole+1) it.setData(addr, QtCore.Qt.UserRole+2) it.setData(t, QtCore.Qt.UserRole+3) self._model_all.appendRow(it) # построить подсказки self._hints.set_paths([(p, self._path_info[p][1]) for p in self._paths]) # начальное состояние попапа (пустой ввод → top-level) self._update_popup_model(self._hints.suggest('')) self._log(f"Variables loaded: {len(flat)}") # --------------- Search mechanics --------------- def _update_popup_model(self, paths: List[str]): """Обновляет модель попапа списком путей (full paths).""" self._model_filtered.clear() limit = 400 added = 0 for p in paths: info = self._path_info.get(p) if not info: continue addr, t = info it = QtGui.QStandardItem(f"{p} [{addr:06X}]") it.setData(p, QtCore.Qt.UserRole+1) it.setData(addr, QtCore.Qt.UserRole+2) it.setData(t, QtCore.Qt.UserRole+3) self._model_filtered.appendRow(it) added += 1 if added >= limit: break if added >= limit: extra = QtGui.QStandardItem("... (more results truncated)") extra.setEnabled(False) self._model_filtered.appendRow(extra) def _show_popup(self): if self._model_filtered.rowCount() == 0: self._popup.hide() return self._popup.setModel(self._model_filtered) self._popup.setMinimumWidth(self.edit_var_search.width()) pos = self.edit_var_search.mapToGlobal(QtCore.QPoint(0, self.edit_var_search.height())) self._popup.move(pos) self._popup.show() self._popup.raise_() self._popup.setFocus() self._popup.setCurrentIndex(self._model_filtered.index(0,0)) def _hide_popup(self): self._popup.hide() def _on_var_search_edited(self, text: str): t = text.strip() # адрес? if t.startswith("0x") and len(t) >= 3: try: addr = int(t, 16) path = self._addr_index.get(addr) if path: self._set_current_variable(path, from_address=True) self._hide_popup() return except ValueError: pass # подсказки по имени suggestions = self._hints.suggest(t) self._update_popup_model(suggestions) self._show_popup() def _on_popup_clicked(self, idx: QtCore.QModelIndex): if not idx.isValid(): return path = idx.data(QtCore.Qt.UserRole+1) if path: self._set_current_variable(path) self._hide_popup() def _activate_current_popup_selection(self): if self._popup.isVisible(): idx = self._popup.currentIndex() if idx.isValid(): self._on_popup_clicked(idx) return # Попытка прямого совпадения path = self.edit_var_search.text().strip() if path in self._path_info: self._set_current_variable(path) def eventFilter(self, obj, ev): if obj is self.edit_var_search and ev.type() == QtCore.QEvent.KeyPress: if ev.key() in (QtCore.Qt.Key_Down, QtCore.Qt.Key_Up): if not self._popup.isVisible(): self._show_popup() else: step = 1 if ev.key()==QtCore.Qt.Key_Down else -1 cur = self._popup.currentIndex() row = cur.row() + step if row < 0: row = 0 if row >= self._model_filtered.rowCount(): row = self._model_filtered.rowCount()-1 self._popup.setCurrentIndex(self._model_filtered.index(row,0)) return True elif ev.key() == QtCore.Qt.Key_Escape: self._hide_popup() return True return super().eventFilter(obj, ev) def _set_current_variable(self, path: str, from_address=False): if path not in self._path_info: return addr, type_str = self._path_info[path] self.edit_var_search.setText(path) self.edit_address.setText(f"0x{addr:06X}") ptr_enum_name = self._map_type_to_ptr_enum(type_str) self._select_combo_text(self.cmb_ptr_type, ptr_enum_name) source = "ADDR" if from_address else "SEARCH" self._log(f"[{source}] Selected {path} @0x{addr:06X} type={type_str} -> ptr={ptr_enum_name}") # --------------- Date struct / address / helpers --------------- def _normalize_address(self): self.edit_address.setText(HexAddrValidator.normalize(self.edit_address.text())) def _map_type_to_ptr_enum(self, type_str: str) -> str: if not type_str: return 'pt_unknown' low = type_str.lower() token = low.replace('*',' ').replace('[',' ').split()[0] return PTR_TYPE_MAP.get(token, 'pt_unknown') def _select_combo_text(self, combo: QtWidgets.QComboBox, text: str): ix = combo.findText(text) if ix >= 0: combo.setCurrentIndex(ix) def _collect_datetime(self) -> Dict[str,int]: return { 'year': self.spin_year.value(), 'month': self.spin_month.value(), 'day': self.spin_day.value(), 'hour': self.spin_hour.value(), 'minute': self.spin_minute.value(), } def _emit_variable(self): if not self._path_info: QtWidgets.QMessageBox.warning(self, 'No XML', 'Сначала загрузите XML файл.') return path = self.edit_var_search.text().strip() if path not in self._path_info: QtWidgets.QMessageBox.warning(self, 'Variable', 'Переменная не выбрана / не найдена.') return addr, type_str = self._path_info[path] ptr_type_name = self.cmb_ptr_type.currentText() iq_type_name = self.cmb_iq_type.currentText() ret_type_name = self.cmb_return_type.currentText() out = { 'address': addr, 'address_hex': f"0x{addr:06X}", 'ptr_type': PT_ENUM_VALUE.get(ptr_type_name, 0), 'iq_type': IQ_ENUM_VALUE.get(iq_type_name, 0), 'return_type': IQ_ENUM_VALUE.get(ret_type_name, 0), 'datetime': self._collect_datetime(), 'path': path, 'type_string': type_str, 'ptr_type_name': ptr_type_name, 'iq_type_name': iq_type_name, 'return_type_name': ret_type_name, } self._log(f"Prepared variable: {out}") self.variablePrepared.emit(out) def _log(self, msg: str): self.txt_info.appendPlainText(msg) # ----------------------------------------------------------- Demo window -- class _DemoWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('LowLevel Selector Demo') self.selector = LowLevelSelectorWidget(self) self.setCentralWidget(self.selector) self.selector.variablePrepared.connect(self.on_var) def on_var(self, data: dict): print('Variable prepared ->', data) def closeEvent(self, ev): self.setCentralWidget(None) super().closeEvent(ev) # ----------------------------------------------------------------- main --- if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) w = _DemoWindow() w.resize(640, 520) w.show() sys.exit(app.exec_())