fbce1ecb42
3 editores nuevos integrados como tabs en MainWindow. vmssailor/studio/editors/symbols.py - 8 simbolos navales: motor, pump, valve, tank, sensor, indicator, line, label - _BaseSymbol QGraphicsItemGroup arrastrable + seleccionable - TankSymbol con fill animable por porcentaje - SymbolSpec dataclass serializable a JSON vmssailor/studio/editors/mimic_editor.py - Paleta lateral con tipos de simbolos - Doble-click crea simbolo en canvas - Selector de sistema (de los habilitados en el proyecto) - Boton 'Cargar demo' inserta P&ID demo (tanque-valvula-bomba-motor-sensor) - serialize_to_project() para guardar en .vmsproj vmssailor/studio/editors/tag_editor.py - Tabla CRUD con 13 columnas - Edicion inline de description, unit_si, range, control_mode - Validacion via Pydantic en cada edit - Filtro por texto en vivo - Contador de tags vmssailor/studio/editors/alarm_editor.py - Tabla aplanada de todas las alarmas configuradas - Counts por prioridad coloreados - Dialog modal para agregar AlarmConfig nueva - Edicion inline de threshold, operator, priority, hysteresis, delay, message - Eliminacion individual vmssailor/studio/main_window.py - Tabs 'Equipos', 'Mimicos', 'Tags', 'Alarmas' en panel derecho - Todos los editores reciben set_project y emiten projectMutated Tests (tests/studio/test_editors.py, 6 nuevos, total 126/126): - Symbol factory para los 8 tipos - MimicEditor con demo - TagEditor render - AlarmEditor priority counts + empty state - MainWindow tabs presentes 126/126 verde, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""Editor de configuración de alarmas (Sprint 3).
|
|
|
|
Cada tag puede tener múltiples AlarmConfig. Este editor permite:
|
|
- Listar todas las alarmas configuradas (todos los tags)
|
|
- Editar threshold, operator, priority, hysteresis, delay, message
|
|
- Agregar / eliminar alarmas
|
|
|
|
La lógica del alarm engine (evaluación en tiempo real) llega en Sprint 4
|
|
con el Runtime servidor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtGui import QColor
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QComboBox,
|
|
QDialog,
|
|
QDialogButtonBox,
|
|
QDoubleSpinBox,
|
|
QFormLayout,
|
|
QHBoxLayout,
|
|
QHeaderView,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from vmssailor.core.enums import AlarmPriority
|
|
from vmssailor.core.project import Project
|
|
from vmssailor.core.tag import AlarmConfig
|
|
from vmssailor.shared.ids import make_alarm_config_id
|
|
from vmssailor.studio.theme import (
|
|
C_CYAN,
|
|
C_EMERGENCY,
|
|
C_FOG,
|
|
C_HIGH,
|
|
C_INFO,
|
|
C_WARN,
|
|
mono_font,
|
|
ui_font,
|
|
)
|
|
|
|
_PRIORITY_COLORS = {
|
|
AlarmPriority.EMERGENCY: C_EMERGENCY,
|
|
AlarmPriority.HIGH: C_HIGH,
|
|
AlarmPriority.LOW: C_WARN,
|
|
AlarmPriority.INFO: C_INFO,
|
|
}
|
|
|
|
_COLS = [
|
|
"Tag",
|
|
"Alarm ID",
|
|
"Operador",
|
|
"Threshold",
|
|
"Prioridad",
|
|
"Histéresis",
|
|
"Delay (s)",
|
|
"Mensaje",
|
|
]
|
|
|
|
|
|
class AlarmEditor(QWidget):
|
|
projectMutated = Signal(Project)
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._project: Project | None = None
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(12, 12, 12, 12)
|
|
layout.setSpacing(10)
|
|
|
|
header = QLabel("Editor de alarmas")
|
|
header.setObjectName("title")
|
|
header.setFont(ui_font(14))
|
|
layout.addWidget(header)
|
|
|
|
intro = QLabel(
|
|
"Configuración declarativa de alarmas. Cuatro prioridades: "
|
|
"EMERGENCY (rojo, accion inmediata), HIGH (naranja), LOW (amber, watch), "
|
|
"INFO (cyan, registro)."
|
|
)
|
|
intro.setStyleSheet(f"color: {C_FOG};")
|
|
intro.setFont(ui_font(10))
|
|
intro.setWordWrap(True)
|
|
layout.addWidget(intro)
|
|
|
|
# Buttons row
|
|
btn_row = QHBoxLayout()
|
|
self._btn_add = QPushButton("+ Agregar alarma")
|
|
self._btn_add.setObjectName("primary")
|
|
self._btn_remove = QPushButton("Eliminar seleccionada")
|
|
self._counts_by_priority = QLabel("")
|
|
self._counts_by_priority.setFont(mono_font(10))
|
|
btn_row.addWidget(self._btn_add)
|
|
btn_row.addWidget(self._btn_remove)
|
|
btn_row.addStretch(1)
|
|
btn_row.addWidget(self._counts_by_priority)
|
|
layout.addLayout(btn_row)
|
|
|
|
# Table
|
|
self._table = QTableWidget(0, len(_COLS))
|
|
self._table.setHorizontalHeaderLabels(_COLS)
|
|
self._table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
|
|
self._table.verticalHeader().setVisible(False)
|
|
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
self._table.setEditTriggers(
|
|
QAbstractItemView.EditTrigger.DoubleClicked
|
|
| QAbstractItemView.EditTrigger.EditKeyPressed
|
|
)
|
|
self._table.itemChanged.connect(self._on_item_changed)
|
|
layout.addWidget(self._table, 1)
|
|
|
|
self._btn_add.clicked.connect(self._on_add)
|
|
self._btn_remove.clicked.connect(self._on_remove)
|
|
|
|
# ----- Public API -------------------------------------------------
|
|
|
|
def set_project(self, project: Project | None) -> None:
|
|
self._project = project
|
|
self._refresh_table()
|
|
|
|
# ----- Internals --------------------------------------------------
|
|
|
|
def _refresh_table(self) -> None:
|
|
self._table.blockSignals(True)
|
|
self._table.setRowCount(0)
|
|
if self._project is None:
|
|
self._counts_by_priority.setText("")
|
|
self._table.blockSignals(False)
|
|
return
|
|
# Aplanar todas las alarmas
|
|
flat: list[tuple[int, str, AlarmConfig]] = []
|
|
for tag_idx, t in enumerate(self._project.tags):
|
|
for a in t.alarms:
|
|
flat.append((tag_idx, t.id, a))
|
|
self._flat = flat
|
|
for _tag_idx, tag_id, a in flat:
|
|
self._append_row(tag_id, a)
|
|
self._update_priority_counts()
|
|
self._table.blockSignals(False)
|
|
|
|
def _append_row(self, tag_id: str, a: AlarmConfig) -> None:
|
|
row = self._table.rowCount()
|
|
self._table.insertRow(row)
|
|
tag_item = QTableWidgetItem(tag_id)
|
|
tag_item.setFlags(tag_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
tag_item.setForeground(QColor(C_CYAN))
|
|
self._table.setItem(row, 0, tag_item)
|
|
id_item = QTableWidgetItem(a.id)
|
|
id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
self._table.setItem(row, 1, id_item)
|
|
self._table.setItem(row, 2, QTableWidgetItem(a.operator))
|
|
self._table.setItem(row, 3, QTableWidgetItem(f"{a.threshold:.3f}"))
|
|
prio = QTableWidgetItem(a.priority.value)
|
|
prio.setForeground(QColor(_PRIORITY_COLORS[a.priority]))
|
|
self._table.setItem(row, 4, prio)
|
|
self._table.setItem(row, 5, QTableWidgetItem(f"{a.hysteresis:.3f}"))
|
|
self._table.setItem(row, 6, QTableWidgetItem(f"{a.delay_seconds:.1f}"))
|
|
self._table.setItem(row, 7, QTableWidgetItem(a.message))
|
|
|
|
def _update_priority_counts(self) -> None:
|
|
if not self._project:
|
|
return
|
|
counts: dict[AlarmPriority, int] = dict.fromkeys(AlarmPriority, 0)
|
|
for t in self._project.tags:
|
|
for a in t.alarms:
|
|
counts[a.priority] += 1
|
|
parts = []
|
|
for p in (AlarmPriority.EMERGENCY, AlarmPriority.HIGH, AlarmPriority.LOW, AlarmPriority.INFO):
|
|
color = _PRIORITY_COLORS[p]
|
|
parts.append(f'<span style="color:{color};">{p.value}:{counts[p]}</span>')
|
|
self._counts_by_priority.setText(" ".join(parts))
|
|
|
|
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
|
if self._project is None:
|
|
return
|
|
row = item.row()
|
|
if row >= len(self._flat):
|
|
return
|
|
tag_idx, _tag_id, a = self._flat[row]
|
|
col = item.column()
|
|
new_alarm: AlarmConfig | None = None
|
|
try:
|
|
if col == 2:
|
|
new_alarm = a.model_copy(update={"operator": item.text()})
|
|
elif col == 3:
|
|
new_alarm = a.model_copy(update={"threshold": float(item.text())})
|
|
elif col == 4:
|
|
new_alarm = a.model_copy(update={"priority": AlarmPriority(item.text())})
|
|
elif col == 5:
|
|
new_alarm = a.model_copy(update={"hysteresis": float(item.text())})
|
|
elif col == 6:
|
|
new_alarm = a.model_copy(update={"delay_seconds": float(item.text())})
|
|
elif col == 7:
|
|
new_alarm = a.model_copy(update={"message": item.text()})
|
|
except Exception as exc:
|
|
QMessageBox.warning(self, "Edición inválida", str(exc))
|
|
self._refresh_table()
|
|
return
|
|
if new_alarm is None:
|
|
return
|
|
|
|
tag = self._project.tags[tag_idx]
|
|
# Reemplazo del alarmConfig dentro de la lista
|
|
new_alarms = [new_alarm if x.id == a.id else x for x in tag.alarms]
|
|
new_tag = tag.model_copy(update={"alarms": new_alarms})
|
|
new_tags = list(self._project.tags)
|
|
new_tags[tag_idx] = new_tag
|
|
try:
|
|
self._project = self._project.model_copy(update={"tags": new_tags})
|
|
self._project = self._project.__class__.model_validate(self._project.model_dump())
|
|
except Exception as exc:
|
|
QMessageBox.critical(self, "Validación falló", str(exc))
|
|
self._refresh_table()
|
|
return
|
|
self._project.touch()
|
|
self.projectMutated.emit(self._project)
|
|
self._update_priority_counts()
|
|
|
|
def _on_add(self) -> None:
|
|
if self._project is None or not self._project.tags:
|
|
QMessageBox.information(self, "Agregar alarma", "No hay tags en el proyecto.")
|
|
return
|
|
dlg = _AddAlarmDialog(self._project, self)
|
|
if dlg.exec():
|
|
tag_idx, alarm = dlg.build()
|
|
if alarm is None:
|
|
return
|
|
tag = self._project.tags[tag_idx]
|
|
new_tag = tag.model_copy(update={"alarms": [*tag.alarms, alarm]})
|
|
new_tags = list(self._project.tags)
|
|
new_tags[tag_idx] = new_tag
|
|
try:
|
|
self._project = self._project.model_copy(update={"tags": new_tags})
|
|
self._project = self._project.__class__.model_validate(
|
|
self._project.model_dump()
|
|
)
|
|
except Exception as exc:
|
|
QMessageBox.critical(self, "Validación falló", str(exc))
|
|
return
|
|
self._project.touch()
|
|
self.projectMutated.emit(self._project)
|
|
self._refresh_table()
|
|
|
|
def _on_remove(self) -> None:
|
|
if self._project is None:
|
|
return
|
|
row = self._table.currentRow()
|
|
if row < 0 or row >= len(self._flat):
|
|
return
|
|
tag_idx, _tag_id, a = self._flat[row]
|
|
tag = self._project.tags[tag_idx]
|
|
new_tag = tag.model_copy(update={"alarms": [x for x in tag.alarms if x.id != a.id]})
|
|
new_tags = list(self._project.tags)
|
|
new_tags[tag_idx] = new_tag
|
|
try:
|
|
self._project = self._project.model_copy(update={"tags": new_tags})
|
|
self._project = self._project.__class__.model_validate(self._project.model_dump())
|
|
except Exception as exc:
|
|
QMessageBox.critical(self, "Validación falló", str(exc))
|
|
return
|
|
self._project.touch()
|
|
self.projectMutated.emit(self._project)
|
|
self._refresh_table()
|
|
|
|
|
|
class _AddAlarmDialog(QDialog):
|
|
def __init__(self, project: Project, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._project = project
|
|
self.setWindowTitle("Agregar configuración de alarma")
|
|
self.setMinimumWidth(420)
|
|
layout = QVBoxLayout(self)
|
|
form = QFormLayout()
|
|
|
|
self._tag_combo = QComboBox()
|
|
for idx, t in enumerate(project.tags):
|
|
self._tag_combo.addItem(t.id, idx)
|
|
form.addRow("Tag:", self._tag_combo)
|
|
|
|
self._level = QLineEdit("low")
|
|
form.addRow("Nivel (low/high/hihi/etc):", self._level)
|
|
|
|
self._op = QComboBox()
|
|
for op in (">", ">=", "<", "<=", "==", "!="):
|
|
self._op.addItem(op, op)
|
|
form.addRow("Operador:", self._op)
|
|
|
|
self._threshold = QDoubleSpinBox()
|
|
self._threshold.setRange(-1e9, 1e9)
|
|
self._threshold.setDecimals(3)
|
|
self._threshold.setValue(0.0)
|
|
form.addRow("Threshold:", self._threshold)
|
|
|
|
self._priority = QComboBox()
|
|
for p in AlarmPriority:
|
|
self._priority.addItem(p.value, p.value)
|
|
form.addRow("Prioridad:", self._priority)
|
|
|
|
self._hyst = QDoubleSpinBox()
|
|
self._hyst.setRange(0.0, 1e6)
|
|
self._hyst.setDecimals(3)
|
|
form.addRow("Histéresis:", self._hyst)
|
|
|
|
self._delay = QDoubleSpinBox()
|
|
self._delay.setRange(0.0, 300.0)
|
|
self._delay.setDecimals(1)
|
|
form.addRow("Delay (s):", self._delay)
|
|
|
|
self._message = QLineEdit()
|
|
form.addRow("Mensaje:", self._message)
|
|
|
|
layout.addLayout(form)
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
)
|
|
buttons.accepted.connect(self.accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
def build(self) -> tuple[int, AlarmConfig | None]:
|
|
tag_idx = self._tag_combo.currentData()
|
|
if tag_idx is None:
|
|
return -1, None
|
|
tag = self._project.tags[tag_idx]
|
|
try:
|
|
alarm = AlarmConfig(
|
|
id=make_alarm_config_id(tag.id, self._level.text() or "low"),
|
|
threshold=self._threshold.value(),
|
|
operator=self._op.currentData(),
|
|
priority=AlarmPriority(self._priority.currentData()),
|
|
hysteresis=self._hyst.value(),
|
|
delay_seconds=self._delay.value(),
|
|
message=self._message.text(),
|
|
)
|
|
return tag_idx, alarm
|
|
except Exception:
|
|
return -1, None
|