cid / app.py
enpaiva's picture
Update app.py
7cd2a08 verified
import gradio as gr
import json
from lxml import etree
# ==================== FUNCIONES DE PARSING ====================
def get_ied_ip(root, ns, ied_name):
"""Extrae la IP del IED"""
for cap in root.findall('.//scl:ConnectedAP', ns):
if cap.get('iedName') == ied_name:
address = cap.find('.//scl:Address', ns)
if address is not None:
for p in address.findall('.//scl:P', ns):
if p.get('type') == 'IP':
return p.text
return None
def process_da_type(children_dict, da_type_id, datype_cache, ns):
"""Procesa recursivamente DAType para extraer todos DA anidados"""
da_type_elem = datype_cache.get(da_type_id)
if da_type_elem is None:
return
# Procesar DA anidados (antes BDA)
for bda in da_type_elem.findall('scl:BDA', ns):
bda_name = bda.get('name')
bda_type = bda.get('type', '')
bda_btype = bda.get('bType', '')
bda_data = {
'_type': 'Data Attribute', # Cambio: era 'Basic Data Attribute'
'_type_short': 'DA', # Cambio: era 'BDA'
'_display': bda_name,
'_children': {}
}
# Si el DA tiene un tipo complejo, procesarlo recursivamente
if bda_type and bda_type in datype_cache:
process_da_type(bda_data['_children'], bda_type, datype_cache, ns)
children_dict[bda_name] = bda_data
def process_do_type(children_dict, do_type_id, dotype_cache, datype_cache, ns):
"""Procesa recursivamente DOType para extraer todos DA"""
do_type_elem = dotype_cache.get(do_type_id)
if do_type_elem is None:
return
# Procesar Data Attributes directos
for da in do_type_elem.findall('scl:DA', ns):
da_name = da.get('name')
da_fc = da.get('fc', '')
da_type = da.get('type', '')
da_btype = da.get('bType', '')
display = f"{da_name} [{da_fc}]" if da_fc else da_name
da_data = {
'_type': 'Data Attribute',
'_type_short': 'DA',
'_display': display,
'_children': {}
}
# Si el DA tiene un tipo complejo (DAType), procesarlo recursivamente
if da_type and da_type in datype_cache:
process_da_type(da_data['_children'], da_type, datype_cache, ns)
children_dict[da_name] = da_data
# Procesar Sub Data Objects como DA (Cambio: SDO ahora es DA)
for sdo in do_type_elem.findall('scl:SDO', ns):
sdo_name = sdo.get('name')
sdo_type = sdo.get('type')
sdo_data = {
'_type': 'Data Attribute', # Cambio: era 'Sub Data Object'
'_type_short': 'DA', # Cambio: era 'SDO'
'_display': sdo_name,
'_children': {}
}
# Recursión para procesar el tipo del SDO
process_do_type(sdo_data['_children'], sdo_type, dotype_cache, datype_cache, ns)
children_dict[sdo_name] = sdo_data
def get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln_type):
"""Extrae Data Objects del LNodeType usando cache"""
data_objects = {}
lntype = lntype_cache.get(ln_type)
if lntype is not None:
for do in lntype.findall('scl:DO', ns):
do_name = do.get('name')
do_type = do.get('type')
do_data = {
'_type': 'Data Object',
'_type_short': 'DO',
'_display': do_name,
'_children': {}
}
# Procesar DOType y sus DA/SDO
process_do_type(do_data['_children'], do_type, dotype_cache, datype_cache, ns)
data_objects[do_name] = do_data
return data_objects
def get_data_objects_clean(root, ns, ln_type):
"""Extrae Data Objects del LNodeType"""
data_objects = {}
lntype = root.find(f".//scl:LNodeType[@id='{ln_type}']", ns)
if lntype is not None:
for do in lntype.findall('.//scl:DO', ns):
do_name = do.get('name')
do_type = do.get('type')
do_data = {
'_type': 'Data Object',
'_type_short': 'DO',
'_display': do_name,
'_children': {}
}
# Data Attributes
do_type_elem = root.find(f".//scl:DOType[@id='{do_type}']", ns)
if do_type_elem is not None:
for da in do_type_elem.findall('.//scl:DA', ns):
da_name = da.get('name')
da_fc = da.get('fc', '')
display = f"{da_name} [{da_fc}]" if da_fc else da_name
do_data['_children'][da_name] = {
'_type': 'Data Attribute',
'_type_short': 'DA',
'_display': display
}
data_objects[do_name] = do_data
return data_objects
def resolve_da_structure(root, ns, lntype_id, do_name, da_name, dotype_cache, datype_cache):
"""Resuelve la estructura completa de un DA específico incluyendo sus BDA"""
# Buscar el LNodeType
lntype = root.find(f".//scl:LNodeType[@id='{lntype_id}']", ns)
if lntype is None:
return {}
# Buscar el DO dentro del LNodeType
do_elem = lntype.find(f".//scl:DO[@name='{do_name}']", ns)
if do_elem is None:
return {}
do_type = do_elem.get('type')
if not do_type:
return {}
# Buscar el DOType
do_type_elem = dotype_cache.get(do_type)
if do_type_elem is None:
return {}
# Buscar el DA dentro del DOType
da_elem = do_type_elem.find(f".//scl:DA[@name='{da_name}']", ns)
if da_elem is None:
return {}
da_type = da_elem.get('type', '')
if not da_type or da_type not in datype_cache:
return {}
# Procesar la estructura del DA
da_structure = {}
process_da_type(da_structure, da_type, datype_cache, ns)
return da_structure
def add_fcda_to_hierarchy(hierarchy, fcda, root, ns, lntype_cache, dotype_cache, datype_cache):
"""Desglosa un FCDA en la jerarquía: LD -> LN -> DO -> DA -> BDA"""
ld_inst = fcda.get('ldInst', '')
ln_prefix = fcda.get('prefix', '')
ln_class = fcda.get('lnClass', '')
ln_inst = fcda.get('lnInst', '')
do_name = fcda.get('doName', '')
da_name = fcda.get('daName', '')
fc = fcda.get('fc', '')
# 1. Logical Device
if ld_inst and ld_inst not in hierarchy:
hierarchy[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
current = hierarchy.get(ld_inst, hierarchy) if ld_inst else hierarchy
if '_children' not in current:
current = hierarchy
else:
current = current['_children']
# 2. Logical Node
ln_key = f"{ln_prefix}{ln_class}{ln_inst}"
if ln_key not in current:
current[ln_key] = {
'_type': 'Logical Node',
'_type_short': 'LN',
'_display': ln_key,
'_children': {}
}
current = current[ln_key]['_children']
# 3. Data Object
if do_name and do_name not in current:
current[do_name] = {
'_type': 'Data Object',
'_type_short': 'DO',
'_display': do_name,
'_children': {}
}
if do_name:
current = current[do_name]['_children']
# 4. Data Attribute con estructura completa
if da_name:
da_display = f"{da_name} [{fc}]" if fc else da_name
if da_name not in current:
current[da_name] = {
'_type': 'Data Attribute',
'_type_short': 'DA',
'_display': da_display,
'_children': {}
}
# ✅ NUEVO: Resolver la estructura completa del DA (incluyendo BDA)
# Necesitamos encontrar el LNodeType para este LN
lntype_id = None
for ld in root.findall('.//scl:LDevice', ns):
if ld.get('inst') == ld_inst:
if ln_class == 'LLN0':
ln_elem = ld.find('.//scl:LN0', ns)
else:
ln_elem = ld.find(f".//scl:LN[@lnClass='{ln_class}'][@inst='{ln_inst}']", ns)
if ln_elem is None and ln_prefix:
ln_elem = ld.find(f".//scl:LN[@prefix='{ln_prefix}'][@lnClass='{ln_class}'][@inst='{ln_inst}']", ns)
if ln_elem is not None:
lntype_id = ln_elem.get('lnType')
break
if lntype_id:
da_structure = resolve_da_structure(root, ns, lntype_id, do_name, da_name, dotype_cache, datype_cache)
if da_structure:
current[da_name]['_children'] = da_structure
else:
# Si no hay DA pero hay FC, agregar al DO
if fc and ld_inst in hierarchy:
do_obj = hierarchy[ld_inst]['_children'][ln_key]['_children'][do_name]
do_obj['_display'] = f"{do_name} [{fc}]"
def resolve_dataset_structure(root, ns, ld_inst, dataset_name, lntype_cache, dotype_cache, datype_cache):
"""Resuelve y retorna la estructura completa de un Dataset"""
structure = {}
# Buscar el dataset en el LDevice correspondiente
for ld in root.findall('.//scl:LDevice', ns):
if ld.get('inst') == ld_inst:
ln0 = ld.find('.//scl:LN0', ns)
if ln0 is not None:
for ds in ln0.findall('.//scl:DataSet', ns):
if ds.get('name') == dataset_name:
# Construir estructura desde FCDAs
for fcda in ds.findall('.//scl:FCDA', ns):
add_fcda_to_hierarchy(structure, fcda, root, ns, lntype_cache, dotype_cache, datype_cache)
return structure
return structure
def parse_ied_clean(cid_file):
"""Parsea el archivo CID con optimizaciones"""
tree = etree.parse(cid_file)
root = tree.getroot()
ns = {'scl': 'http://www.iec.ch/61850/2003/SCL'}
# OPTIMIZACIÓN 1: Pre-cachear LNodeTypes, DOTypes y DATypes
lntype_cache = {lnt.get('id'): lnt for lnt in root.findall('.//scl:LNodeType', ns)}
dotype_cache = {dot.get('id'): dot for dot in root.findall('.//scl:DOType', ns)}
datype_cache = {dat.get('id'): dat for dat in root.findall('.//scl:DAType', ns)}
ieds_structure = {}
for ied in root.findall('.//scl:IED', ns):
ied_name = ied.get('name')
ip_address = get_ied_ip(root, ns, ied_name)
display_name = f"{ied_name} ({ip_address})" if ip_address else ied_name
ieds_structure[ied_name] = {
'_type': 'Physical Device',
'_type_short': 'PD',
'_display': display_name,
'_children': {
'GOOSE': {'_type': 'Section', '_type_short': 'Section', '_display': 'GOOSE', '_children': {}},
'Reports': {'_type': 'Section', '_type_short': 'Section', '_display': 'Reports', '_children': {}},
'Dataset': {'_type': 'Section', '_type_short': 'Section', '_display': 'Dataset', '_children': {}},
'Data Model': {'_type': 'Section', '_type_short': 'Section', '_display': 'Data Model', '_children': {}}
}
}
goose_section = ieds_structure[ied_name]['_children']['GOOSE']['_children']
reports_section = ieds_structure[ied_name]['_children']['Reports']['_children']
dataset_section = ieds_structure[ied_name]['_children']['Dataset']['_children']
data_model_section = ieds_structure[ied_name]['_children']['Data Model']['_children']
for ld in ied.findall('.//scl:LDevice', ns):
ld_inst = ld.get('inst')
data_model_section[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
ld_node = data_model_section[ld_inst]
ln0 = ld.find('.//scl:LN0', ns)
if ln0 is not None:
ln0_data = {'_type': 'Logical Node', '_type_short': 'LN', '_display': 'LLN0', '_children': {}}
# Datasets - NUEVA NOMENCLATURA: {LD} → {LN}.{Dataset} → {DO} → {DA}
for ds in ln0.findall('.//scl:DataSet', ns):
ds_name = ds.get('name')
# Key única: LD_inst.LLN0.DatasetName
ds_key = f"{ld_inst}.LLN0.{ds_name}"
# Estructura del dataset
ds_structure = {
'_type': 'Dataset',
'_type_short': 'Dataset',
'_display': f"LLN0.{ds_name}", # Mostrar LN.Dataset
'_children': {}
}
# Agregar jerarquía LD como padre
if ld_inst not in dataset_section:
dataset_section[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
# Construir estructura desde FCDAs (sin el LD padre, solo contenido)
ds_content = {}
for fcda in ds.findall('.//scl:FCDA', ns):
add_fcda_to_hierarchy(ds_content, fcda, root, ns, lntype_cache, dotype_cache, datype_cache)
ds_structure['_children'] = ds_content
# Agregar al LD correspondiente
dataset_section[ld_inst]['_children'][ds_key] = ds_structure
# ReportControls - NUEVA NOMENCLATURA: {LD} → {LN}.{RptName} [ref: LN.Dataset]
for rpt in ln0.findall('.//scl:ReportControl', ns):
rpt_name = rpt.get('name')
rpt_datset = rpt.get('datSet', '')
rpt_key = f"{ld_inst}.LLN0.{rpt_name}"
# ✅ Display limpio SIN [ref:]
rpt_display = f"LLN0.{rpt_name}"
rpt_node = {
'_type': 'ReportControl',
'_type_short': 'ReportControl',
'_display': rpt_display,
'_datSet': f"LLN0.{rpt_datset}" if rpt_datset else '', # ✅ Con prefijo LLN0
'_buffered': rpt.get('buffered', 'false'),
'_children': {}
}
# Agregar jerarquía LD como padre
if ld_inst not in reports_section:
reports_section[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
# Resolver estructura del dataset
if rpt_datset:
dataset_structure = resolve_dataset_structure(root, ns, ld_inst, rpt_datset, lntype_cache, dotype_cache, datype_cache)
rpt_node['_children'] = dataset_structure
# Agregar al LD correspondiente
reports_section[ld_inst]['_children'][rpt_key] = rpt_node
# GOOSEControls - NUEVA NOMENCLATURA: {LD} → {LN}.{GSEName} [ref: LN.Dataset]
for gse in ln0.findall('.//scl:GSEControl', ns):
gse_name = gse.get('name')
gse_datset = gse.get('datSet', '')
gse_key = f"{ld_inst}.LLN0.{gse_name}"
# ✅ Display limpio SIN [ref:]
gse_display = f"LLN0.{gse_name}"
gse_node = {
'_type': 'GOOSEControl',
'_type_short': 'GOOSEControl',
'_display': gse_display,
'_datSet': f"LLN0.{gse_datset}" if gse_datset else '', # ✅ Con prefijo LLN0
'_children': {}
}
# Agregar jerarquía LD como padre
if ld_inst not in goose_section:
goose_section[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
# Resolver estructura del dataset
if gse_datset:
dataset_structure = resolve_dataset_structure(root, ns, ld_inst, gse_datset, lntype_cache, dotype_cache, datype_cache)
gse_node['_children'] = dataset_structure
# Agregar al LD correspondiente
goose_section[ld_inst]['_children'][gse_key] = gse_node
# SVControls - NUEVA NOMENCLATURA: {LD} → {LN}.{SVName} [ref: LN.Dataset]
for smv in ln0.findall('.//scl:SampledValueControl', ns):
smv_name = smv.get('name')
smv_datset = smv.get('datSet', '')
smv_key = f"{ld_inst}.LLN0.{smv_name}"
# ✅ Display limpio SIN [ref:]
smv_display = f"LLN0.{smv_name}"
smv_node = {
'_type': 'SVControl',
'_type_short': 'SVControl',
'_display': smv_display,
'_datSet': f"LLN0.{smv_datset}" if smv_datset else '', # ✅ Con prefijo LLN0
'_children': {}
}
# Agregar jerarquía LD como padre
if ld_inst not in goose_section:
goose_section[ld_inst] = {
'_type': 'Logical Device',
'_type_short': 'LD',
'_display': ld_inst,
'_children': {}
}
# Resolver estructura del dataset
if smv_datset:
dataset_structure = resolve_dataset_structure(root, ns, ld_inst, smv_datset, lntype_cache, dotype_cache, datype_cache)
smv_node['_children'] = dataset_structure
# Agregar al LD correspondiente
goose_section[ld_inst]['_children'][smv_key] = smv_node
# OPTIMIZACIÓN 2: Usar cache
ln0_type = ln0.get('lnType')
if ln0_type:
data_objects = get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln0_type)
ln0_data['_children'].update(data_objects)
ld_node['_children']['LLN0'] = ln0_data
# Otros Logical Nodes
for ln in ld.findall('.//scl:LN', ns):
ln_class = ln.get('lnClass')
ln_inst = ln.get('inst', '')
ln_prefix = ln.get('prefix', '')
ln_key = f"{ln_prefix}{ln_class}{ln_inst}"
ln_data = {'_type': 'Logical Node', '_type_short': 'LN', '_display': ln_key, '_children': {}}
ln_type = ln.get('lnType')
if ln_type:
data_objects = get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln_type)
ln_data['_children'] = data_objects
ld_node['_children'][ln_key] = ln_data
return ieds_structure
# ==================== FIN FUNCIONES DE PARSING ====================
def generate_collapsible_tree_js(data, path="root", parent_section=None, level=0, max_initial_depth=0):
"""Genera árbol HTML colapsable - SIEMPRE renderiza hijos pero los oculta"""
html = ""
# Determinar si este nivel debe estar expandido inicialmente
should_expand_initially = level < max_initial_depth
if isinstance(data, dict):
for idx, (key, value) in enumerate(data.items()):
if key.startswith('_'):
continue
if isinstance(value, dict):
obj_type_short = value.get('_type_short', value.get('_type', ''))
display = value.get('_display', key)
is_section = obj_type_short == 'Section'
skip_type = parent_section in ['GOOSE', 'Reports', 'Dataset'] and obj_type_short in ['GOOSEControl', 'SVControl', 'ReportControl', 'Dataset']
node_id = f"node_{path}_{idx}".replace(" ", "_").replace("[", "").replace("]", "").replace(".", "_").replace("/", "_").replace("(", "").replace(")", "")
has_children = '_children' in value and value['_children'] and any(not k.startswith('_') for k in value['_children'].keys())
extra_info = ""
if obj_type_short == 'ReportControl':
datset = value.get('_datSet', '')
buffered = value.get('_buffered', '')
if datset:
extra_info = f"<span class='extra-info'>datSet: {datset}</span>"
if buffered:
extra_info += f" <span class='extra-info'>| buffered: {buffered}</span>"
elif buffered:
extra_info = f"<span class='extra-info'>buffered: {buffered}</span>"
if obj_type_short in ['GOOSEControl', 'SVControl']:
datset = value.get('_datSet', '')
if datset:
extra_info = f"<span class='extra-info'>datSet: {datset}</span>"
if obj_type_short == 'Dataset':
# Los datasets no muestran extra_info
pass
indent = level * 8
if has_children:
# Determinar estado inicial
initial_display = 'block' if should_expand_initially else 'none'
icon_class = 'expanded' if should_expand_initially else ''
html += f"""
<div class="tree-node" style="margin-left: {indent}px;">
<div class="tree-item tree-toggle" data-target="{node_id}">
<span class="toggle-icon {icon_class}">›</span>
"""
else:
html += f"""
<div class="tree-node" style="margin-left: {indent}px;">
<div class="tree-item">
<span class="no-icon">·</span>
"""
if is_section:
html += f"<strong class='section-title'>{display}</strong>"
current_section = display
elif skip_type:
html += f"<span class='node-text'>{display}</span>{extra_info}"
current_section = parent_section
else:
if '[' in display and ']' in display and obj_type_short == 'DA':
parts = display.rsplit('[', 1)
name_part = parts[0].strip()
fc_part = '[' + parts[1]
html += f"<span class='node-type node-type-{obj_type_short.lower()}'>{obj_type_short}</span> <span class='node-text'>{name_part}</span> <span class='fc-text'>{fc_part}</span>"
elif obj_type_short in ['GOOSEControl', 'SVControl', 'ReportControl', 'Dataset']:
# ✅ NUEVO: Mostrar en negrita sin el tipo
html += f"<strong class='control-name'>{display}</strong>{extra_info}"
else:
html += f"<span class='node-type node-type-{obj_type_short.lower()}'>{obj_type_short}</span> <span class='node-text'>{display}</span>{extra_info}"
current_section = parent_section
html += "</div>"
if has_children:
# CRÍTICO: SIEMPRE renderizar hijos, solo cambiar display
html += f"<div id='{node_id}' class='tree-children' style='display: {initial_display};'>"
# SIEMPRE generar el HTML de los hijos
html += generate_collapsible_tree_js(
value['_children'],
f"{path}_{idx}",
current_section,
level + 1,
max_initial_depth
)
html += "</div>"
html += "</div>"
return html
def process_cid_file(file):
"""Procesa el archivo CID y genera la visualización"""
if file is None:
return """
<div class='empty-state'>
<div style='font-size: 48px; margin-bottom: 12px;'>📄</div>
<div style='font-size: 13px; font-weight: 600; margin-bottom: 4px;'>No file loaded</div>
<div style='font-size: 11px;'>Select a CID file from the sidebar</div>
</div>
""", ""
try:
ieds = parse_ied_clean(file.name)
stats = count_elements(ieds)
ied_name = list(ieds.keys())[0] if ieds else "Unknown"
ied_info = ieds[ied_name] if ied_name in ieds else {}
ied_display = ied_info.get('_display', ied_name)
stats_html = f"""
<div class='stats-card'>
<div class='stats-title'>Estadísticas</div>
<table class='stats-table'>
<tr><td> Physical Devices:</td><td>{stats['ieds']}</td></tr>
<tr><td> Logical Devices:</td><td>{stats['lds']}</td></tr>
<tr><td> Logical Nodes:</td><td>{stats['lns']}</td></tr>
<tr><td> Data Objects:</td><td>{stats['dos']}</td></tr>
<tr><td> Data Attributes:</td><td>{stats['das']}</td></tr>
<tr class='divider'><td> Datasets:</td><td>{stats['datasets']}</td></tr>
<tr><td> Reports:</td><td>{stats['reports']}</td></tr>
<tr><td> GOOSE/SV:</td><td>{stats['goose']}</td></tr>
</table>
</div>
"""
tree_content = generate_collapsible_tree_js(ieds)
import time
tree_id = f"tree_{int(time.time() * 1000)}"
html_tree = f"""
<div id="{tree_id}" class="cid-tree-root">
<style>
#{tree_id} {{
--tree-bg: #FFFFFF;
--tree-border: #EDEBE9;
--tree-hover: #F3F2F1;
--tree-text: #323130;
--tree-secondary: #605E5C;
--tree-tertiary: #8A8886;
--tree-heading: #201F1E;
--tree-icon: #605E5C;
--tree-code-bg: #F3F2F1;
--header-bg: #F3F2F1;
--color-pd: #0078D4;
--color-ld: #107C10;
--color-ln: #FF8C00;
--color-do: #E81123;
--color-da: #881798;
}}
#{tree_id} .tree-container {{
font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif;
font-size: 11px;
line-height: 1.4;
background: var(--tree-bg);
padding: 12px;
border: 1px solid var(--tree-border);
border-radius: 2px;
max-height: calc(100vh - 143px);
overflow-y: auto;
overflow-x: auto;
color: var(--tree-text);
}}
#{tree_id} .tree-node {{
user-select: none;
}}
#{tree_id} .tree-item {{
padding: 3px 6px;
border-radius: 2px;
transition: background-color 0.1s ease;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
min-height: 20px; /* <-- AGREGA ESTO para altura mínima consistente */
}}
#{tree_id} .tree-item.tree-toggle {{
cursor: pointer;
}}
#{tree_id} .tree-item.tree-toggle:hover {{
background-color: var(--tree-hover);
}}
#{tree_id} .toggle-icon {{
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
font-size: 12px; /* <-- Cambia de 14px a 12px */
color: var(--tree-icon);
transition: transform 0.1s ease;
flex-shrink: 0;
line-height: 1; /* <-- AGREGA ESTO */
}}
#{tree_id} .toggle-icon.expanded {{
transform: rotate(90deg);
}}
#{tree_id} .no-icon {{
display: inline-flex; /* <-- Cambia de inline-block a inline-flex */
align-items: center; /* <-- AGREGA ESTO */
justify-content: center; /* <-- AGREGA ESTO */
width: 12px;
height: 12px;
font-size: 12px;
color: var(--tree-tertiary);
flex-shrink: 0;
}}
#{tree_id} .node-type {{
font-weight: 600;
font-family: 'Consolas', 'Courier New', monospace;
padding: 1px 4px;
background: var(--tree-code-bg);
border-radius: 2px;
font-size: 10px;
line-height: 1; /* <-- AGREGA ESTO */
display: inline-flex; /* <-- AGREGA ESTO */
align-items: center; /* <-- AGREGA ESTO */
}}
#{tree_id} .node-type-pd {{ color: var(--color-pd); }}
#{tree_id} .node-type-ld {{ color: var(--color-ld); }}
#{tree_id} .node-type-ln {{ color: var(--color-ln); }}
#{tree_id} .node-type-do {{ color: var(--color-do); }}
#{tree_id} .node-type-da {{ color: var(--color-da); }}
#{tree_id} .node-text {{
color: var(--tree-text);
font-size: 11px;
line-height: 1; /* <-- AGREGA ESTO */
display: inline-flex; /* <-- AGREGA ESTO */
align-items: center; /* <-- AGREGA ESTO */
}}
#{tree_id} .section-title {{
color: var(--tree-heading);
font-size: 12px;
font-weight: 600;
line-height: 1; /* <-- AGREGA ESTO */
display: inline-flex; /* <-- AGREGA ESTO */
align-items: center; /* <-- AGREGA ESTO */
}}
#{tree_id} .fc-text {{
color: var(--tree-tertiary);
font-size: 10px;
line-height: 1; /* <-- AGREGA ESTO */
display: inline-flex; /* <-- AGREGA ESTO */
align-items: center; /* <-- AGREGA ESTO */
}}
#{tree_id} .control-name {{
color: var(--tree-text);
font-size: 11px;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
}}
#{tree_id} .extra-info {{
color: #A19F9D; /* Gris claro */
font-size: 10px;
margin-left: 8px;
font-weight: 400;
}}
#{tree_id} .tree-children {{
overflow: hidden;
}}
#{tree_id} .header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 8px 12px;
background: var(--header-bg);
border: 1px solid var(--tree-border);
border-radius: 2px;
}}
#{tree_id} .ied-title {{
font-size: 12px;
font-weight: 600;
color: var(--tree-heading);
display: flex;
align-items: center;
gap: 8px;
}}
#{tree_id} .control-buttons {{
display: flex;
gap: 4px;
}}
#{tree_id} .btn {{
padding: 4px 12px;
border: 1px solid var(--tree-border);
background: var(--tree-bg);
border-radius: 2px;
font-weight: 400;
cursor: pointer;
transition: all 0.1s ease;
font-size: 11px;
color: var(--tree-text);
font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif;
}}
#{tree_id} .btn:hover {{
background: var(--tree-hover);
}}
#{tree_id} .btn-primary {{
background: #0078D4;
color: white;
border-color: #0078D4;
}}
#{tree_id} .btn-primary:hover {{
background: #106EBE;
border-color: #106EBE;
}}
#{tree_id} .tree-container::-webkit-scrollbar {{
width: 12px;
height: 12px;
}}
#{tree_id} .tree-container::-webkit-scrollbar-track {{
background: var(--tree-hover);
}}
#{tree_id} .tree-container::-webkit-scrollbar-thumb {{
background: var(--tree-secondary);
border-radius: 2px;
}}
#{tree_id} .tree-container::-webkit-scrollbar-thumb:hover {{
background: var(--tree-icon);
}}
</style>
<div class="header">
<div class="ied-title">
<span>🔌</span>
<span>IEDs</span>
</div>
<div class="control-buttons">
<button class="btn btn-primary btn-expand-all">Expandir Todo</button>
<button class="btn btn-collapse-all">Colapsar Todo</button>
</div>
</div>
<div class="tree-container">
{tree_content}
</div>
</div>
"""
return html_tree, stats_html
except Exception as e:
import traceback
error_detail = traceback.format_exc()
print(f"Error processing CID: {error_detail}")
error_msg = f"""
<div class='error-state'>
<div style='font-size: 48px; margin-bottom: 12px;'>⚠️</div>
<div style='font-size: 13px; font-weight: 600; margin-bottom: 4px;'>Error processing file</div>
<div style='font-size: 11px;'>{str(e)}</div>
</div>
"""
return error_msg, ""
def count_elements(data):
"""Cuenta elementos en la estructura"""
stats = {
'ieds': 0,
'lds': 0,
'lns': 0,
'dos': 0,
'das': 0,
'datasets': 0,
'reports': 0,
'goose': 0
}
def count_recursive(d):
if isinstance(d, dict):
for key, value in d.items():
if key.startswith('_'):
continue
if isinstance(value, dict):
obj_type = value.get('_type_short', '')
if obj_type == 'PD':
stats['ieds'] += 1
elif obj_type == 'LD':
stats['lds'] += 1
elif obj_type == 'LN':
stats['lns'] += 1
elif obj_type == 'DO':
stats['dos'] += 1
elif obj_type == 'DA':
stats['das'] += 1
elif obj_type == 'Dataset':
stats['datasets'] += 1
elif obj_type == 'ReportControl':
stats['reports'] += 1
elif obj_type in ['GOOSEControl', 'SVControl']:
stats['goose'] += 1
if '_children' in value:
count_recursive(value['_children'])
count_recursive(data)
return stats
GLOBAL_JS = """
function() {
// Solo verificar tema UNA VEZ al cargar la página
if (!window.themeChecked) {
window.themeChecked = true;
const url = new URL(window.location);
if (url.searchParams.get('__theme') !== 'light') {
url.searchParams.set('__theme', 'light');
window.location.href = url.href;
return; // Detener ejecución
}
}
// Evitar múltiples inicializaciones
if (window.treeHandlersInitialized) return;
window.treeHandlersInitialized = true;
// Delegación de eventos ÚNICA
document.addEventListener('click', function(e) {
// Manejo de toggles
const toggle = e.target.closest('.tree-toggle');
if (toggle) {
const root = toggle.closest('.cid-tree-root');
if (!root) return;
const targetId = toggle.getAttribute('data-target');
if (!targetId) return;
const children = root.querySelector('#' + targetId);
const icon = toggle.querySelector('.toggle-icon');
if (children && icon) {
const isHidden = children.style.display === 'none' || children.style.display === '';
children.style.display = isHidden ? 'block' : 'none';
icon.classList.toggle('expanded', isHidden);
}
e.stopPropagation();
return;
}
// Expand all
if (e.target.closest('.btn-expand-all')) {
const root = e.target.closest('.cid-tree-root');
if (root) {
root.querySelectorAll('.tree-children').forEach(el => el.style.display = 'block');
root.querySelectorAll('.toggle-icon').forEach(el => el.classList.add('expanded'));
}
e.stopPropagation();
return;
}
// Collapse all
if (e.target.closest('.btn-collapse-all')) {
const root = e.target.closest('.cid-tree-root');
if (root) {
root.querySelectorAll('.tree-children').forEach(el => el.style.display = 'none');
root.querySelectorAll('.toggle-icon').forEach(el => el.classList.remove('expanded'));
}
e.stopPropagation();
return;
}
}, { passive: true }); // Mejorar rendimiento
}
"""
custom_css = """
/* Windows style theme - Solo tema claro */
:root {
--primary-color: #0078D4;
--primary-hover: #106EBE;
--background-color: #FAF9F8;
--sidebar-bg: #FFFFFF;
--border-color: #EDEBE9;
--text-primary: #201F1E;
--text-secondary: #323130;
--text-tertiary: #605E5C;
--text-quaternary: #8A8886;
--hover-bg: #F3F2F1;
--surface-bg: #FFFFFF;
}
body {
font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif !important;
background: var(--background-color) !important;
}
.gradio-container {
max-width: 100% !important;
padding: 0 !important;
background: var(--background-color) !important;
}
#component-0 {
background: var(--sidebar-bg) !important;
border-right: 1px solid var(--border-color) !important;
padding: 12px !important;
display: block !important;
}
/* Reducir espaciado entre componentes del sidebar */
#component-0 .gr-block {
margin-bottom: 0.3rem !important;
gap: 0.2rem !important;
}
#component-0 .gr-form {
gap: 0.3rem !important;
}
#component-0 .gr-box {
gap: 0.3rem !important;
}
/* Markdown compacto en sidebar */
#component-0 .prose {
margin-top: 0 !important;
margin-bottom: 0.3rem !important;
}
#component-0 .prose h3 {
margin-top: 0.3rem !important;
margin-bottom: 0.2rem !important;
}
#component-0 .prose p {
margin-top: 0 !important;
margin-bottom: 0.2rem !important;
}
/* File input y botón compactos */
#component-0 .gr-file,
#component-0 .gr-button {
margin-bottom: 0.3rem !important;
}
.gr-button {
border-radius: 2px !important;
font-size: 11px !important;
padding: 6px 16px !important;
font-weight: 400 !important;
border: 1px solid var(--border-color) !important;
transition: all 0.1s ease !important;
background: var(--surface-bg) !important;
color: var(--text-secondary) !important;
}
.gr-button:hover {
background: var(--hover-bg) !important;
}
.gr-button-primary {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: white !important;
}
.gr-button-primary:hover {
background: var(--primary-hover) !important;
}
.gr-file {
border: 1px solid var(--border-color) !important;
border-radius: 2px !important;
background: var(--surface-bg) !important;
}
h1, h2, h3 {
color: var(--text-primary) !important;
font-weight: 600 !important;
}
h1 {
font-size: 18px !important;
margin-bottom: 4px !important;
margin-top: 0 !important;
}
h2 {
font-size: 14px !important;
margin-top: 8px !important;
margin-bottom: 4px !important;
}
h3 {
font-size: 12px !important;
margin-top: 8px !important;
margin-bottom: 4px !important;
}
p, li {
font-size: 11px !important;
line-height: 1.4 !important;
color: var(--text-secondary) !important;
margin-top: 2px !important;
margin-bottom: 2px !important;
}
strong {
color: var(--text-primary) !important;
font-weight: 600 !important;
}
.prose {
font-size: 11px !important;
color: var(--text-secondary) !important;
}
.prose p {
margin-top: 4px !important;
margin-bottom: 4px !important;
}
.prose ul, .prose ol {
margin-top: 4px !important;
margin-bottom: 4px !important;
padding-left: 16px !important;
}
.prose li {
margin-top: 2px !important;
margin-bottom: 2px !important;
}
.prose hr {
margin-top: 8px !important;
margin-bottom: 8px !important;
border-color: var(--border-color) !important;
}
.gr-box, .gr-form, .gr-panel {
gap: 6px !important;
}
.gr-padded {
padding: 8px !important;
}
.gr-label {
margin-bottom: 4px !important;
font-size: 11px !important;
color: var(--text-secondary) !important;
font-weight: 600 !important;
}
.gr-input-container {
margin-bottom: 6px !important;
}
/* Estilos personalizados para componentes HTML */
.stats-card {
background: var(--surface-bg);
border: 1px solid var(--border-color);
padding: 12px;
font-size: 11px;
line-height: 1.6;
border-radius: 2px;
}
.stats-title {
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
font-size: 11px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
}
.stats-table td {
padding: 2px 0;
color: var(--text-tertiary);
vertical-align: middle; /* <-- AGREGA ESTO */
}
.stats-table td:last-child {
text-align: center;
font-weight: 600;
color: var(--text-primary);
vertical-align: middle; /* <-- AGREGA ESTO */
}
.stats-table tr.divider {
border-top: 1px solid var(--border-color);
}
.stats-table tr.divider td {
padding-top: 4px;
}
.empty-state, .error-state {
text-align: center;
padding: 30px;
background: var(--surface-bg);
border: 1px solid var(--border-color);
border-radius: 2px;
}
.empty-state {
color: var(--text-quaternary);
}
.error-state {
color: var(--text-tertiary);
}
#panel-lateral {
gap: 0 !important;
}
/* Full height layout estable sin vh */
.full-height-column {
height: 100%;
display: flex;
flex-direction: column;
}
.full-height-html {
flex: 1; /* Ocupa todo el espacio disponible */
height: 100%; /* Hereda del padre */
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--surface-bg);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 20px;
box-sizing: border-box;
}
.empty-content {
text-align: center;
max-width: 500px;
}
/* Agregar después de .extra-info */
.ref-text {
color: var(--text-tertiary);
font-size: 10px;
font-style: italic;
margin-left: 4px;
}
"""
# Crear interfaz Gradio
with gr.Blocks(title="IEC 61850 CID Viewer", css=custom_css, js=GLOBAL_JS, fill_height=True) as app:
with gr.Row(equal_height=False):
with gr.Column(scale=1, min_width=280, elem_id="panel-lateral"):
gr.Markdown("""
# IEC 61850 Viewer
Herramienta profesional para analizar archivos SCL de subestaciones eléctricas IEC 61850.
""")
file_input = gr.File(
label="Subir el Archivo IID, ICD, CID o SCD",
file_types=[".cid", ".xml", ".scd"],
type="filepath",
height=150
)
process_btn = gr.Button(
"Procesar Archivo",
variant="primary",
size="sm"
)
gr.Markdown("---")
stats_output = gr.HTML(label="Statistics", show_label=False)
gr.Markdown("---")
gr.Markdown("""
### Leyendas
- **PD** Physical Device
- **LD** Logical Device
- **LN** Logical Node
- **DO** Data Object
- **DA** Data Attribute
- **FC** Functional Constraint
""")
gr.Markdown("---")
gr.Markdown("""
### Secciones
- **GOOSE** — GOOSE y SV Messages
- **Reports** — Report Control Blocks
- **Dataset** — Datasets
- **Data Model** — IED Data Model
""")
with gr.Column(scale=3, elem_classes="full-height-column"):
html_output = gr.HTML(
value="""
<div class="empty-state">
<div class="empty-content">
<div style="font-size: 72px; margin-bottom: 16px;">📄</div>
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">IEC 61850 Viewer</div>
<div style="font-size: 12px; color: var(--text-tertiary);">Cargue un archivo IID, ICD, CID o SCD desde la barra lateral para comenzar el análisis.</div>
</div>
</div>
""",
show_label=False,
elem_classes="full-height-html"
)
process_btn.click(
fn=process_cid_file,
inputs=[file_input],
outputs=[html_output, stats_output]
).then(
fn=lambda: None,
inputs=None,
outputs=[file_input]
)
if __name__ == "__main__":
app.launch(share=True, server_port=7860)