esp/paginas/programacion.php

1218 lines
39 KiB
PHP

<?php
$title = 'Programacion - ESP Admin';
$cssVersion = '20251003-2307'; // Versión de estilos
ob_start();
?>
<style>
.panel-container {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 1rem;
height: calc(100vh - 200px);
min-height: 600px;
}
@media (max-width: 1024px) {
.panel-container {
grid-template-columns: 1fr;
height: auto;
}
}
.panel {
background: white;
border-radius: 12px;
border: 2px solid #e2e8f0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
padding: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.pins-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.pin-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
transition: all 0.3s;
cursor: grab;
position: relative;
overflow: hidden;
user-select: none;
}
.pin-card:active {
cursor: grabbing;
}
.pin-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #10b981;
}
.pin-card.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.pin-card.on {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%) !important;
border-color: #10b981 !important;
}
.pin-card.off {
background: #f8fafc !important;
border-color: #cbd5e1 !important;
}
.pin-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #10b981, #059669);
opacity: 0;
transition: opacity 0.3s;
}
.pin-card.on::before {
opacity: 1;
}
.pin-status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
position: absolute;
top: 0.75rem;
right: 0.75rem;
transition: all 0.3s;
}
.pin-status-indicator.on {
background: #10b981;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
animation: pulse-dot 2s infinite;
}
.pin-status-indicator.off {
background: #94a3b8;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pin-label {
font-size: 0.875rem;
font-weight: 600;
color: #0f172a;
margin-bottom: 0.25rem;
}
.pin-name {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pin-value {
font-size: 1.25rem;
font-weight: 700;
text-align: center;
padding: 0.5rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.5);
}
.pin-value.on {
color: #059669;
}
.pin-value.off {
color: #64748b;
}
.drop-zone {
min-height: 200px;
border: 3px dashed #cbd5e1;
border-radius: 12px;
padding: 2rem;
text-align: center;
background: #f8fafc;
transition: all 0.3s;
margin-bottom: 1rem;
}
.drop-zone.drag-over {
border-color: #10b981;
background: #d1fae5;
transform: scale(1.02);
}
.drop-zone-empty {
color: #94a3b8;
font-size: 0.875rem;
}
.rule-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s;
}
.rule-card:hover {
border-color: #10b981;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.rule-card.disabled {
opacity: 0.6;
background: #f1f5f9;
}
.rule-builder {
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.rule-builder-section {
margin-bottom: 1.5rem;
}
.rule-builder-title {
font-size: 0.875rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dropped-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f1f5f9;
border: 2px solid #cbd5e1;
border-radius: 8px;
margin: 0.25rem;
font-size: 0.875rem;
transition: all 0.2s;
}
.dropped-item:hover {
border-color: #10b981;
background: #d1fae5;
}
.dropped-item .remove-btn {
cursor: pointer;
color: #ef4444;
font-weight: bold;
margin-left: 0.5rem;
}
.flow-canvas {
background:
linear-gradient(90deg, #f1f5f9 1px, transparent 1px),
linear-gradient(#f1f5f9 1px, transparent 1px);
background-size: 20px 20px;
min-height: 400px;
position: relative;
border-radius: 8px;
overflow: auto;
}
.flow-node {
position: absolute;
background: white;
border: 2px solid #10b981;
border-radius: 8px;
padding: 0.75rem;
min-width: 120px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: move;
user-select: none;
}
.flow-node.input {
border-color: #3b82f6;
}
.flow-node.output {
border-color: #f59e0b;
}
.flow-node.logic {
border-color: #8b5cf6;
}
</style>
<!-- Header con selector de dispositivo -->
<div class="mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900">Programación Visual</h1>
<p class="text-slate-600 mt-1">Crea reglas de automatización para tus dispositivos</p>
</div>
<div class="flex items-center gap-3">
<select id="deviceSelector" class="px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all">
<option value="">Selecciona un dispositivo...</option>
</select>
<button id="btnSaveRules" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm transition-all" disabled>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Guardar Reglas
</button>
<button id="btnNewRule" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-slate-700 hover:bg-slate-50 border border-slate-200 shadow-sm transition-all" disabled>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Nueva Regla
</button>
</div>
</div>
</div>
<!-- Contenedor de 3 paneles -->
<div class="panel-container">
<!-- Panel Izquierdo: Entradas -->
<div class="panel">
<div class="panel-header">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Entradas</span>
<span id="inputCount" class="ml-auto bg-white/20 px-2 py-0.5 rounded-full text-xs">0</span>
</div>
<div class="panel-body" id="inputsPanel">
<div class="text-center text-slate-500 py-8">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-sm">Selecciona un dispositivo</p>
</div>
</div>
</div>
<!-- Panel Central: Reglas/Lógica -->
<div class="panel">
<div class="panel-header">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span>Reglas de Automatización</span>
<span id="ruleCount" class="ml-auto bg-white/20 px-2 py-0.5 rounded-full text-xs">0</span>
</div>
<div class="panel-body" id="rulesPanel">
<!-- Constructor visual de reglas -->
<div id="ruleBuilderContainer" class="hidden">
<div class="rule-builder">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-slate-900">Constructor Visual de Regla</h3>
<button id="btnCancelBuilder" class="text-sm text-slate-600 hover:text-slate-900">✕ Cancelar</button>
</div>
<!-- Trigger -->
<div class="rule-builder-section">
<div class="rule-builder-title">
🔔 Disparador (Trigger)
</div>
<div id="triggerDropZone" class="drop-zone" data-type="trigger">
<div class="drop-zone-empty">
Arrastra una ENTRADA aquí para usarla como disparador
</div>
</div>
</div>
<!-- Condiciones -->
<div class="rule-builder-section">
<div class="rule-builder-title">
⚙️ Condiciones (opcional)
</div>
<div id="conditionsDropZone" class="drop-zone" data-type="conditions">
<div class="drop-zone-empty">
Arrastra ENTRADAS aquí para agregar condiciones
</div>
</div>
</div>
<!-- Acciones -->
<div class="rule-builder-section">
<div class="rule-builder-title">
⚡ Acciones
</div>
<div id="actionsDropZone" class="drop-zone" data-type="actions">
<div class="drop-zone-empty">
Arrastra SALIDAS aquí para definir las acciones
</div>
</div>
</div>
<!-- Botones -->
<div class="flex items-center gap-3">
<input type="text" id="builderRuleName" placeholder="Nombre de la regla..." class="flex-1 px-4 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500">
<button id="btnSaveBuiltRule" class="px-6 py-2 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 transition-all">
Guardar Regla
</button>
</div>
</div>
</div>
<!-- Lista de reglas guardadas -->
<div id="savedRulesList">
<div class="text-center text-slate-500 py-8">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm">No hay reglas creadas</p>
<p class="text-xs mt-1">Arrastra pines o haz clic en "Nueva Regla"</p>
</div>
</div>
</div>
</div>
<!-- Panel Derecho: Salidas -->
<div class="panel">
<div class="panel-header">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Salidas</span>
<span id="outputCount" class="ml-auto bg-white/20 px-2 py-0.5 rounded-full text-xs">0</span>
</div>
<div class="panel-body" id="outputsPanel">
<div class="text-center text-slate-500 py-8">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-sm">Selecciona un dispositivo</p>
</div>
</div>
</div>
</div>
<!-- Modal: Editor de Reglas -->
<div id="modalRuleEditor" class="fixed inset-0 z-50 hidden">
<div data-modal-overlay class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="relative mx-auto mt-10 max-w-4xl rounded-xl bg-white shadow-2xl" style="max-height: 90vh; overflow-y: auto;">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-emerald-50 to-emerald-100 sticky top-0 z-10">
<h5 class="m-0 font-bold text-slate-900 text-lg">Editor de Reglas</h5>
<button type="button" class="text-slate-400 hover:text-slate-600 transition-colors" data-modal-close>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6">
<form id="formRule">
<input type="hidden" id="ruleId" name="id">
<!-- Nombre de la regla -->
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Nombre de la Regla</label>
<input type="text" id="ruleName" name="name" placeholder="Ej: Riego automático nocturno" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required>
</div>
<!-- Estado activo/inactivo -->
<div class="mb-6 flex items-center gap-3">
<input type="checkbox" id="ruleEnabled" name="enabled" checked class="w-5 h-5 text-emerald-600 border-slate-300 rounded focus:ring-emerald-500">
<label for="ruleEnabled" class="text-sm font-medium text-slate-700">Regla activa</label>
</div>
<!-- Condiciones (Trigger) -->
<div class="mb-6">
<h6 class="text-md font-bold text-slate-900 mb-3">🔔 Disparador (Trigger)</h6>
<div class="bg-blue-50 border-2 border-blue-200 rounded-lg p-4">
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Tipo</label>
<select id="triggerType" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="input_change">Cambio en entrada</option>
<option value="timer">Temporizador</option>
<option value="manual">Manual</option>
</select>
</div>
<div id="triggerPinContainer">
<label class="block text-xs font-medium text-slate-600 mb-1">Pin</label>
<select id="triggerPin" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="">Selecciona...</option>
</select>
</div>
<div id="triggerValueContainer">
<label class="block text-xs font-medium text-slate-600 mb-1">Valor</label>
<select id="triggerValue" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm">
<option value="ON">ON</option>
<option value="OFF">OFF</option>
</select>
</div>
</div>
</div>
</div>
<!-- Condiciones adicionales -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h6 class="text-md font-bold text-slate-900">⚙️ Condiciones (opcional)</h6>
<button type="button" id="btnAddCondition" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium">+ Agregar condición</button>
</div>
<div id="conditionsContainer" class="space-y-2">
<!-- Las condiciones se agregan dinámicamente aquí -->
</div>
</div>
<!-- Acciones -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h6 class="text-md font-bold text-slate-900">⚡ Acciones</h6>
<button type="button" id="btnAddAction" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium">+ Agregar acción</button>
</div>
<div id="actionsContainer" class="space-y-2">
<!-- Las acciones se agregan dinámicamente aquí -->
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
<button type="button" data-modal-close class="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-all">Cancelar</button>
<button type="submit" class="px-6 py-2 text-sm font-semibold bg-emerald-600 text-white hover:bg-emerald-700 rounded-lg transition-all">Guardar Regla</button>
</div>
</form>
</div>
</div>
</div>
<script>
let currentChipId = null;
let currentPins = {};
let currentRules = [];
let pinStates = {}; // Estados en tiempo real desde MQTT WebSocket
// Cargar dispositivos en el selector
function loadDevices() {
$.get('/api/index.php?r=get_dispositivos', function(data) {
const $select = $('#deviceSelector');
$select.empty().append('<option value="">Selecciona un dispositivo...</option>');
for (const chipid in data) {
const d = data[chipid];
$select.append(`<option value="${chipid}">${d.tag} (${chipid})</option>`);
}
});
}
// Cargar puertos del dispositivo seleccionado
function loadDevicePorts(chipid) {
currentChipId = chipid;
$.get(`/api/index.php?r=get_puertos&chipid=${chipid}`, function(data) {
currentPins = data;
renderPins();
loadRules(chipid);
// Habilitar botones
$('#btnSaveRules, #btnNewRule').prop('disabled', false);
}).fail(function() {
mostrarToast('Error al cargar puertos del dispositivo', { type: 'danger' });
});
}
// Renderizar pines en los paneles
function renderPins() {
const $inputsPanel = $('#inputsPanel');
const $outputsPanel = $('#outputsPanel');
$inputsPanel.empty();
$outputsPanel.empty();
let inputCount = 0;
let outputCount = 0;
// Crear contenedores de grilla
const $inputsGrid = $('<div class="pins-grid"></div>');
const $outputsGrid = $('<div class="pins-grid"></div>');
for (const pin in currentPins) {
if (pin === 'update') continue;
const p = currentPins[pin];
const state = pinStates[pin] || 'OFF';
const stateClass = (state === 'ON') ? 'on' : 'off';
if (p.modo === 'INPUT' || p.modo === 'INPUT_PULLUP') {
inputCount++;
$inputsGrid.append(`
<div class="pin-card ${stateClass}" draggable="true" data-pin="${pin}" data-type="input" data-name="${p.disp || 'Sin nombre'}">
<span class="pin-status-indicator ${stateClass}"></span>
<div class="pin-label">${pin}</div>
<div class="pin-name" title="${p.disp || 'Sin nombre'}">${p.disp || 'Sin nombre'}</div>
<div class="pin-value ${stateClass}">${state}</div>
</div>
`);
} else if (p.modo === 'OUTPUT') {
outputCount++;
$outputsGrid.append(`
<div class="pin-card ${stateClass}" draggable="true" data-pin="${pin}" data-type="output" data-name="${p.disp || 'Sin nombre'}">
<span class="pin-status-indicator ${stateClass}"></span>
<div class="pin-label">${pin}</div>
<div class="pin-name" title="${p.disp || 'Sin nombre'}">${p.disp || 'Sin nombre'}</div>
<button class="toggle-output w-full px-3 py-2 text-sm font-bold rounded-lg transition-all ${state === 'ON' ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}" data-pin="${pin}" data-state="${state}">
${state}
</button>
</div>
`);
}
}
$('#inputCount').text(inputCount);
$('#outputCount').text(outputCount);
if (inputCount === 0) {
$inputsPanel.html('<div class="text-center text-slate-500 py-4 text-sm">No hay entradas configuradas</div>');
} else {
$inputsPanel.append($inputsGrid);
initDragAndDrop();
}
if (outputCount === 0) {
$outputsPanel.html('<div class="text-center text-slate-500 py-4 text-sm">No hay salidas configuradas</div>');
} else {
$outputsPanel.append($outputsGrid);
initDragAndDrop();
}
}
// Sistema de Drag & Drop
let draggedElement = null;
let builderState = {
trigger: null,
conditions: [],
actions: []
};
function initDragAndDrop() {
// Drag start
$(document).on('dragstart', '.pin-card[draggable="true"]', function(e) {
draggedElement = {
pin: $(this).data('pin'),
type: $(this).data('type'),
name: $(this).data('name')
};
$(this).addClass('dragging');
e.originalEvent.dataTransfer.effectAllowed = 'copy';
});
// Drag end
$(document).on('dragend', '.pin-card[draggable="true"]', function() {
$(this).removeClass('dragging');
});
// Drag over drop zones
$(document).on('dragover', '.drop-zone', function(e) {
e.preventDefault();
e.originalEvent.dataTransfer.dropEffect = 'copy';
$(this).addClass('drag-over');
});
// Drag leave
$(document).on('dragleave', '.drop-zone', function() {
$(this).removeClass('drag-over');
});
// Drop
$(document).on('drop', '.drop-zone', function(e) {
e.preventDefault();
$(this).removeClass('drag-over');
if (!draggedElement) return;
const dropType = $(this).data('type');
// Validar tipo de pin según zona
if (dropType === 'trigger' || dropType === 'conditions') {
if (draggedElement.type !== 'input') {
mostrarToast('Solo puedes arrastrar ENTRADAS aquí', { type: 'warning' });
return;
}
} else if (dropType === 'actions') {
if (draggedElement.type !== 'output') {
mostrarToast('Solo puedes arrastrar SALIDAS aquí', { type: 'warning' });
return;
}
}
addToBuilder(dropType, draggedElement);
draggedElement = null;
});
}
function addToBuilder(zone, item) {
const $zone = $(`[data-type="${zone}"]`);
if (zone === 'trigger') {
// Solo un trigger
builderState.trigger = item;
$zone.html(`
<div class="dropped-item">
<strong>${item.pin}</strong> - ${item.name}
<span class="text-xs text-slate-500">cuando cambie</span>
<span class="remove-btn" onclick="removeFromBuilder('trigger')">✕</span>
</div>
`);
} else if (zone === 'conditions') {
// Múltiples condiciones
builderState.conditions.push(item);
const idx = builderState.conditions.length - 1;
$zone.find('.drop-zone-empty').remove();
$zone.append(`
<div class="dropped-item" data-idx="${idx}">
<strong>${item.pin}</strong> - ${item.name}
<select class="ml-2 px-2 py-1 border rounded text-xs condition-operator">
<option value="==">es igual a</option>
<option value="!=">es diferente de</option>
</select>
<select class="ml-2 px-2 py-1 border rounded text-xs condition-value">
<option value="ON">ON</option>
<option value="OFF">OFF</option>
</select>
<span class="remove-btn" onclick="removeFromBuilder('conditions', ${idx})">✕</span>
</div>
`);
} else if (zone === 'actions') {
// Múltiples acciones
builderState.actions.push(item);
const idx = builderState.actions.length - 1;
$zone.find('.drop-zone-empty').remove();
$zone.append(`
<div class="dropped-item" data-idx="${idx}">
<strong>${item.pin}</strong> - ${item.name}
<span class="text-xs text-slate-500">establecer a</span>
<select class="ml-2 px-2 py-1 border rounded text-xs action-value">
<option value="ON">ON</option>
<option value="OFF">OFF</option>
</select>
<span class="remove-btn" onclick="removeFromBuilder('actions', ${idx})">✕</span>
</div>
`);
}
// Mostrar constructor si estaba oculto
$('#ruleBuilderContainer').removeClass('hidden');
$('#savedRulesList').addClass('hidden');
}
function removeFromBuilder(zone, idx) {
if (zone === 'trigger') {
builderState.trigger = null;
$('#triggerDropZone').html('<div class="drop-zone-empty">Arrastra una ENTRADA aquí para usarla como disparador</div>');
} else if (zone === 'conditions') {
builderState.conditions.splice(idx, 1);
$(`#conditionsDropZone .dropped-item[data-idx="${idx}"]`).remove();
if (builderState.conditions.length === 0) {
$('#conditionsDropZone').html('<div class="drop-zone-empty">Arrastra ENTRADAS aquí para agregar condiciones</div>');
}
} else if (zone === 'actions') {
builderState.actions.splice(idx, 1);
$(`#actionsDropZone .dropped-item[data-idx="${idx}"]`).remove();
if (builderState.actions.length === 0) {
$('#actionsDropZone').html('<div class="drop-zone-empty">Arrastra SALIDAS aquí para definir las acciones</div>');
}
}
}
// Botón Nueva Regla - mostrar constructor
$('#btnNewRule').on('click', function() {
$('#ruleBuilderContainer').removeClass('hidden');
$('#savedRulesList').addClass('hidden');
resetBuilder();
});
// Cancelar constructor
$('#btnCancelBuilder').on('click', function() {
$('#ruleBuilderContainer').addClass('hidden');
$('#savedRulesList').removeClass('hidden');
resetBuilder();
});
function resetBuilder() {
builderState = { trigger: null, conditions: [], actions: [] };
$('#triggerDropZone').html('<div class="drop-zone-empty">Arrastra una ENTRADA aquí para usarla como disparador</div>');
$('#conditionsDropZone').html('<div class="drop-zone-empty">Arrastra ENTRADAS aquí para agregar condiciones</div>');
$('#actionsDropZone').html('<div class="drop-zone-empty">Arrastra SALIDAS aquí para definir las acciones</div>');
$('#builderRuleName').val('');
}
// Guardar regla construida visualmente
$('#btnSaveBuiltRule').on('click', function() {
const ruleName = $('#builderRuleName').val().trim();
if (!ruleName) {
mostrarToast('Ingresa un nombre para la regla', { type: 'warning' });
return;
}
if (!builderState.trigger) {
mostrarToast('Debes agregar un disparador (trigger)', { type: 'warning' });
return;
}
if (builderState.actions.length === 0) {
mostrarToast('Debes agregar al menos una acción', { type: 'warning' });
return;
}
// Construir regla
const rule = {
id: 'r' + Date.now(),
name: ruleName,
enabled: true,
trigger: {
type: 'input_change',
pin: builderState.trigger.pin,
value: 'ON'
},
conditions: builderState.conditions.map((c, idx) => ({
pin: c.pin,
operator: $(`#conditionsDropZone .dropped-item[data-idx="${idx}"] .condition-operator`).val(),
value: $(`#conditionsDropZone .dropped-item[data-idx="${idx}"] .condition-value`).val()
})),
actions: builderState.actions.map((a, idx) => ({
type: 'set',
pin: a.pin,
value: $(`#actionsDropZone .dropped-item[data-idx="${idx}"] .action-value`).val()
}))
};
currentRules.push(rule);
renderRules();
$('#ruleBuilderContainer').addClass('hidden');
$('#savedRulesList').removeClass('hidden');
resetBuilder();
mostrarToast('Regla creada correctamente', { type: 'success' });
});
// Control manual de salidas
$(document).on('click', '.toggle-output', function() {
const pin = $(this).data('pin');
const currentState = $(this).data('state');
const newState = (currentState === 'ON') ? 'OFF' : 'ON';
const topic = `dispositivo/${currentChipId}/comando`;
const payload = { alias: pin, valor: newState };
const publisher = window.mqttRealtime
? window.mqttRealtime.publish(topic, payload, { qos: 0, retain: true })
: api.postJSON('/api/index.php?r=mqtt_publish', { topic, payload, qos: 0, retain: true });
publisher
.then(function() {
mostrarToast(`${pin} → ${newState}`, { type: 'success', delay: 1500 });
})
.catch(function() {
mostrarToast('Error al enviar comando', { type: 'danger' });
});
});
// Cargar reglas del dispositivo
function loadRules(chipid) {
$.get(`/api/index.php?r=get_reglas&chipid=${chipid}`, function(response) {
console.log('Respuesta get_reglas:', response);
if (response && response.success && Array.isArray(response.data)) {
currentRules = response.data;
} else {
currentRules = [];
console.warn('Respuesta inesperada:', response);
}
renderRules();
}).fail(function(xhr, status, error) {
console.error('Error al cargar reglas:', {
status: xhr.status,
statusText: xhr.statusText,
responseText: xhr.responseText,
error: error
});
currentRules = [];
renderRules();
let errorMsg = 'Error al cargar reglas';
try {
const resp = JSON.parse(xhr.responseText);
errorMsg = resp.message || errorMsg;
} catch(e) {
if (xhr.status === 404) errorMsg = 'Endpoint no encontrado';
else if (xhr.status === 401) errorMsg = 'No autenticado';
else if (xhr.status === 500) errorMsg = 'Error del servidor';
}
mostrarToast(errorMsg, { type: 'danger' });
});
}
// Renderizar reglas
function renderRules() {
const $list = $('#savedRulesList');
if (currentRules.length === 0) {
$list.html(`
<div class="text-center text-slate-500 py-8">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm">No hay reglas creadas</p>
<p class="text-xs mt-1">Arrastra pines o haz clic en "Nueva Regla"</p>
</div>
`);
$('#ruleCount').text('0');
return;
}
$list.empty();
currentRules.forEach(rule => {
$list.append(`
<div class="rule-card ${rule.enabled ? '' : 'disabled'}">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<input type="checkbox" class="rule-toggle" data-rule-id="${rule.id}" ${rule.enabled ? 'checked' : ''}>
<h6 class="font-bold text-slate-900">${rule.name}</h6>
</div>
<div class="flex items-center gap-2">
<button class="delete-rule text-red-600 hover:text-red-700" data-rule-id="${rule.id}">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="text-xs text-slate-600">
<span class="font-medium">Trigger:</span> ${formatTrigger(rule.trigger)}
</div>
${rule.conditions && rule.conditions.length > 0 ? `
<div class="text-xs text-slate-600 mt-1">
<span class="font-medium">Condiciones:</span> ${rule.conditions.length}
</div>
` : ''}
<div class="text-xs text-slate-600 mt-1">
<span class="font-medium">Acciones:</span> ${rule.actions.length}
</div>
</div>
`);
});
$('#ruleCount').text(currentRules.length);
}
function formatTrigger(trigger) {
if (trigger.type === 'input_change') {
return `${trigger.pin} → ${trigger.value}`;
} else if (trigger.type === 'timer') {
return `Timer: ${trigger.time}`;
}
return trigger.type;
}
// Event listeners
$('#deviceSelector').on('change', function() {
const chipid = $(this).val();
if (chipid) {
loadDevicePorts(chipid);
}
});
$('#btnNewRule').on('click', function() {
openRuleEditor();
});
function openRuleEditor(ruleId = null) {
// Limpiar formulario
$('#formRule')[0].reset();
$('#ruleId').val('');
$('#conditionsContainer').empty();
$('#actionsContainer').empty();
// Poblar selector de pines para trigger
const $triggerPin = $('#triggerPin');
$triggerPin.empty().append('<option value="">Selecciona...</option>');
for (const pin in currentPins) {
if (pin === 'update') continue;
const p = currentPins[pin];
if (p.modo === 'INPUT' || p.modo === 'INPUT_PULLUP') {
$triggerPin.append(`<option value="${pin}">${pin} - ${p.disp || 'Sin nombre'}</option>`);
}
}
// Si es edición, cargar datos
if (ruleId) {
const rule = currentRules.find(r => r.id === ruleId);
if (rule) {
$('#ruleId').val(rule.id);
$('#ruleName').val(rule.name);
$('#ruleEnabled').prop('checked', rule.enabled);
$('#triggerType').val(rule.trigger.type);
$('#triggerPin').val(rule.trigger.pin);
$('#triggerValue').val(rule.trigger.value);
// TODO: Cargar condiciones y acciones
}
}
// Abrir modal
$('#modalRuleEditor').removeClass('hidden');
}
// Cerrar modal
$(document).on('click', '[data-modal-close]', function() {
$(this).closest('.fixed').addClass('hidden');
});
$(document).on('click', '[data-modal-overlay]', function() {
$(this).closest('.fixed').addClass('hidden');
});
// Agregar condición
$('#btnAddCondition').on('click', function() {
const html = `
<div class="condition-item bg-slate-50 border border-slate-200 rounded-lg p-3 flex items-center gap-2">
<select class="condition-pin flex-1 px-3 py-2 border border-slate-300 rounded text-sm">
<option value="">Pin...</option>
${getInputPinsOptions()}
</select>
<select class="condition-operator px-3 py-2 border border-slate-300 rounded text-sm">
<option value="==">=</option>
<option value="!=">≠</option>
</select>
<select class="condition-value px-3 py-2 border border-slate-300 rounded text-sm">
<option value="ON">ON</option>
<option value="OFF">OFF</option>
</select>
<button type="button" class="remove-condition text-red-600 hover:text-red-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`;
$('#conditionsContainer').append(html);
});
// Agregar acción
$('#btnAddAction').on('click', function() {
const html = `
<div class="action-item bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-center gap-2">
<select class="action-type px-3 py-2 border border-slate-300 rounded text-sm">
<option value="set">Establecer</option>
<option value="delay">Esperar</option>
</select>
<select class="action-pin flex-1 px-3 py-2 border border-slate-300 rounded text-sm">
<option value="">Pin...</option>
${getOutputPinsOptions()}
</select>
<select class="action-value px-3 py-2 border border-slate-300 rounded text-sm">
<option value="ON">ON</option>
<option value="OFF">OFF</option>
</select>
<button type="button" class="remove-action text-red-600 hover:text-red-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`;
$('#actionsContainer').append(html);
});
function getInputPinsOptions() {
let html = '';
for (const pin in currentPins) {
if (pin === 'update') continue;
const p = currentPins[pin];
if (p.modo === 'INPUT' || p.modo === 'INPUT_PULLUP') {
html += `<option value="${pin}">${pin} - ${p.disp || 'Sin nombre'}</option>`;
}
}
return html;
}
function getOutputPinsOptions() {
let html = '';
for (const pin in currentPins) {
if (pin === 'update') continue;
const p = currentPins[pin];
if (p.modo === 'OUTPUT') {
html += `<option value="${pin}">${pin} - ${p.disp || 'Sin nombre'}</option>`;
}
}
return html;
}
// Remover condición/acción
$(document).on('click', '.remove-condition', function() {
$(this).closest('.condition-item').remove();
});
$(document).on('click', '.remove-action', function() {
$(this).closest('.action-item').remove();
});
// Guardar regla
$('#formRule').on('submit', function(e) {
e.preventDefault();
const rule = {
id: $('#ruleId').val() || 'r' + Date.now(),
name: $('#ruleName').val(),
enabled: $('#ruleEnabled').is(':checked'),
trigger: {
type: $('#triggerType').val(),
pin: $('#triggerPin').val(),
value: $('#triggerValue').val()
},
conditions: [],
actions: []
};
// Recopilar condiciones
$('.condition-item').each(function() {
rule.conditions.push({
pin: $(this).find('.condition-pin').val(),
operator: $(this).find('.condition-operator').val(),
value: $(this).find('.condition-value').val()
});
});
// Recopilar acciones
$('.action-item').each(function() {
rule.actions.push({
type: $(this).find('.action-type').val(),
pin: $(this).find('.action-pin').val(),
value: $(this).find('.action-value').val()
});
});
// Agregar o actualizar regla
const existingIndex = currentRules.findIndex(r => r.id === rule.id);
if (existingIndex >= 0) {
currentRules[existingIndex] = rule;
} else {
currentRules.push(rule);
}
renderRules();
$('#modalRuleEditor').addClass('hidden');
mostrarToast('Regla guardada correctamente', { type: 'success' });
});
// Editar regla
$(document).on('click', '.edit-rule', function() {
const ruleId = $(this).data('rule-id');
openRuleEditor(ruleId);
});
// Eliminar regla
$(document).on('click', '.delete-rule', function() {
const ruleId = $(this).data('rule-id');
if (confirm('¿Eliminar esta regla?')) {
currentRules = currentRules.filter(r => r.id !== ruleId);
renderRules();
mostrarToast('Regla eliminada', { type: 'success' });
}
});
// Toggle regla activa/inactiva
$(document).on('change', '.rule-toggle', function() {
const ruleId = $(this).data('rule-id');
const enabled = $(this).is(':checked');
const rule = currentRules.find(r => r.id === ruleId);
if (rule) {
rule.enabled = enabled;
renderRules();
}
});
// Guardar todas las reglas
$('#btnSaveRules').on('click', function() {
if (!currentChipId) return;
const $btn = $(this);
const originalText = $btn.html();
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Guardando...');
const payload = {
chipid: currentChipId,
rules: currentRules
};
api.postJSON('/api/index.php?r=save_reglas', payload)
.done(function(response) {
if (response.success) {
mostrarToast('Reglas guardadas correctamente', { type: 'success' });
} else {
mostrarToast(response.message || 'Error al guardar', { type: 'danger' });
}
})
.fail(function(xhr) {
const msg = (xhr.responseJSON && xhr.responseJSON.message) || 'Error al guardar reglas';
mostrarToast(msg, { type: 'danger' });
})
.always(function() {
$btn.prop('disabled', false).html(originalText);
});
});
// Inicialización
$(function() {
loadDevices();
});
</script>
<?php
$content = ob_get_clean();
if (!defined('RENDER_PARTIAL')) {
include __DIR__ . '/../layouts/layout.php';
}
?>