UNPKG

@frangoteam/fuxa

Version:

Web-based Process Visualization (SCADA/HMI/Dashboard) software

1,259 lines (1,070 loc) 62.7 kB
/** * FUXA Scheduler Service * * Event-driven scheduler with master control enforcement. * Uses node-schedule for cron-style job scheduling. */ 'use strict'; const schedule = require('node-schedule'); var Events = require('../events'); var logger; var runtime; // Track active node-schedule jobs by event ID var activeJobs = new Map(); // Track scheduler writes to prevent loops var schedulerWriting = new Set(); // Track active Event Mode schedules (key: tagId, value: { endTime, schedulerId, deviceName, eventIndex }) var activeEventModeSchedules = new Map(); /** * Initialize */ function init(settings, _logger, _runtime) { runtime = _runtime; logger = _logger || console; // Listen for tag changes to enforce master control runtime.events.on('tag-value:changed', onTagChanged); // Listen for EVERY client connection to send current event states runtime.io.on('connection', (socket) => { setAllInitialStates(); }); // Load existing schedulers and create jobs loadSchedulers(); return Promise.resolve({ updateScheduler, stopScheduler }); } /** * Load all schedulers from DB and create node-schedule jobs */ async function loadSchedulers() { try { const schedulers = await runtime.schedulerStorage.getAllSchedulers(); // Get project data to access deviceActions from property let projectData = null; try { projectData = await runtime.project.getProject(null, null); } catch (err) { logger.error('Failed to load project data:', err.message); } for (const scheduler of schedulers) { if (scheduler.id && scheduler.data) { // Get deviceActions from project property if not in scheduler.data.settings if (!scheduler.data.settings?.deviceActions && projectData) { // Find the scheduler gauge in the project views if (projectData?.hmi?.views) { let found = false; for (const view of projectData.hmi.views) { if (view.items) { for (const itemId in view.items) { const item = view.items[itemId]; // Check if this is the scheduler with matching ID if (item.id === scheduler.id && item.property?.deviceActions) { // Sync to scheduler data if (!scheduler.data.settings) { scheduler.data.settings = {}; } scheduler.data.settings.deviceActions = item.property.deviceActions; // Save back to database await runtime.schedulerStorage.setSchedulerData(scheduler.id, scheduler.data); found = true; break; } } } if (found) break; } } } await createSchedulerJobs(scheduler.id, scheduler.data); } } } catch (error) { logger.error(`Error loading schedulers: ${error.message}`); } } /** * Set initial states for ALL schedulers (called after runtime ready) */ async function setAllInitialStates() { try { const schedulers = await runtime.schedulerStorage.getAllSchedulers(); for (const scheduler of schedulers) { if (scheduler.id && scheduler.data) { await setInitialStates(scheduler.id, scheduler.data); await notifyEventStates(scheduler.id, scheduler.data); } } } catch (error) { logger.error(`Error setting initial states: ${error.message}`); } } /** * Notify clients of all event states for a scheduler */ async function notifyEventStates(schedulerId, schedulerData) { try { if (!schedulerData.settings?.devices) { return; } for (const device of schedulerData.settings.devices) { const schedules = schedulerData.schedules?.[device.name] || []; schedules.forEach((event, eventIndex) => { const dayNumbers = event.days .map((enabled, index) => enabled ? index : null) .filter(day => day !== null); const eventData = { label: event.label || `${device.name}_${event.startTime}${event.eventMode ? '_Event_' + event.duration + 's' : '-' + event.endTime}`, startTime: event.startTime, endTime: event.endTime, days: event.days, disabled: event.disabled, recurring: event.recurring, eventMode: event.eventMode, duration: event.duration, id: event.id }; checkAndNotifyEventState(schedulerId, device, eventData, eventIndex, dayNumbers); }); } } catch (error) { logger.error(`Error notifying event states: ${error.message}`); } } /** * Set initial tag states based on current scheduler state */ async function setInitialStates(schedulerId, schedulerData) { try { if (!schedulerData.settings?.devices) { return; } for (const device of schedulerData.settings.devices) { const schedules = schedulerData.schedules?.[device.name] || []; const isTimerModeActive = checkIfAnyEventActive(schedules); const isEventModeActive = checkIfAnyEventModeActive(device.name, schedulerId); const expectedValue = (isTimerModeActive || isEventModeActive) ? 1 : 0; await writeTagFromEvent(device.variableId, expectedValue, `Initial state for ${device.name}`); } } catch (error) { logger.error(`Error setting initial states: ${error.message}`); } } /** * Create node-schedule jobs for all events in a scheduler */ async function createSchedulerJobs(schedulerId, schedulerData) { try { if (!schedulerData.settings?.devices) { return; } for (const device of schedulerData.settings.devices) { const schedules = schedulerData.schedules?.[device.name] || []; if (device.variableId) { runtime.events.emit('tag-change:subscription', device.variableId); } for (let i = 0; i < schedules.length; i++) { await createEventJob(schedulerId, device, schedules[i], i); } } } catch (error) { logger.error(`Error creating scheduler jobs: ${error.message}`); } } /** * Create a single event job for a device */ async function createEventJob(schedulerId, device, event, eventIndex) { try { const isEventMode = event.eventMode === true && event.duration !== undefined; const eventData = { label: isEventMode ? `${device.name}_${event.startTime}_Event_${event.duration}s` : `${device.name}_${event.startTime}-${event.endTime}`, startTime: event.startTime, endTime: event.endTime, days: event.days, months: event.months, daysOfMonth: event.daysOfMonth, monthMode: event.monthMode, recurring: event.recurring !== false, eventMode: event.eventMode || false, duration: event.duration, id: event.id }; // Indices shift when events are added/deleted, but IDs are stable const jobId = `${schedulerId}_${device.name}_${event.id}`; if (activeJobs.has(jobId)) { activeJobs.get(jobId).cancel(); activeJobs.delete(jobId); } if (!event.days && !event.monthMode) { return; } if (event.monthMode && (!event.months || !event.daysOfMonth)) { return; } if (!event.startTime) { //logger.error(`Event missing start time`); return; } if (!isEventMode && !event.endTime) { //logger.error(`Timer mode event missing end time`); return; } if (isEventMode && (event.duration === undefined || event.duration <= 0)) { //logger.error(`Event mode requires valid duration`); return; } const startParts = event.startTime.split(':'); if (startParts.length !== 2) { //logger.error(`Invalid start time format: ${event.startTime}`); return; } const startHour = parseInt(startParts[0]); const startMin = parseInt(startParts[1]); if (isNaN(startHour) || isNaN(startMin)) { //logger.error(`Invalid start time values: ${event.startTime}`); return; } let endHour, endMin; if (!isEventMode) { const endParts = event.endTime.split(':'); if (endParts.length !== 2) { //logger.error(`Invalid end time format: ${event.endTime}`); return; } endHour = parseInt(endParts[0]); endMin = parseInt(endParts[1]); if (isNaN(endHour) || isNaN(endMin)) { //logger.error(`Invalid end time values: ${event.endTime}`); return; } } const dayNumbers = []; if (Array.isArray(event.days)) { event.days.forEach((isActive, dayIndex) => { if (isActive === true) { dayNumbers.push(dayIndex); } }); } const monthNumbers = []; if (Array.isArray(event.months)) { event.months.forEach((isActive, monthIndex) => { if (isActive === true) { monthNumbers.push(monthIndex); // Months are 0-11 (node-schedule format) } }); } const dayOfMonthNumbers = []; if (Array.isArray(event.daysOfMonth)) { event.daysOfMonth.forEach((isActive, dayIndex) => { if (isActive === true) { dayOfMonthNumbers.push(dayIndex + 1); // Days are 1-31 } }); } const isMonthMode = event.monthMode === true; if (isMonthMode) { if (monthNumbers.length === 0 || dayOfMonthNumbers.length === 0) { logger.warn(`Month mode schedule rejected: no months or days selected`); return; } } else { if (dayNumbers.length === 0) { return; } } const startRule = new schedule.RecurrenceRule(); if (isMonthMode) { startRule.month = monthNumbers; startRule.date = dayOfMonthNumbers; } else { startRule.dayOfWeek = dayNumbers; } startRule.hour = startHour; startRule.minute = startMin; let startJob = null; let endJob = null; try { startJob = schedule.scheduleJob(startRule, async () => { await writeTagFromEvent(device.variableId, 1, `Event "${eventData.label}" started`).catch(err => { logger.error(`Error in START callback: ${err.message}`); }); // Execute server-side device actions (Set Value, Run Script, etc.) const schedulers = await runtime.schedulerStorage.getAllSchedulers(); const scheduler = schedulers.find(s => s.id === schedulerId); if (scheduler?.data?.settings) { await executeDeviceActions(schedulerId, device.name, 'on', scheduler.data.settings); } if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: event.id, eventData: eventData, active: true }); } if (isEventMode) { const durationMs = event.duration * 1000; const startTime = Date.now(); const endTime = startTime + durationMs; if (!event.id) { logger.error(`Event missing ID! Cannot track Event Mode event.`); return; } const eventKey = event.id; const activeData = { endTime: endTime, schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: event.id, variableId: device.variableId }; activeEventModeSchedules.set(eventKey, activeData); const remainingTimeInterval = setInterval(() => { const remainingMs = activeData.endTime - Date.now(); const remaining = Math.max(0, Math.floor(remainingMs / 1000)); if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_REMAINING, { schedulerId: activeData.schedulerId, deviceName: activeData.deviceName, eventIndex: activeData.eventIndex, eventId: activeData.eventId, remaining: remaining }); } }, 1000); activeData.interval = remainingTimeInterval; const endTimeout = setTimeout(async () => { const tracked = activeEventModeSchedules.get(eventKey); if (tracked && tracked.interval) { clearInterval(tracked.interval); } activeEventModeSchedules.delete(eventKey); const targetDevice = tracked ? tracked.deviceName : device.name; const targetIndex = tracked ? tracked.eventIndex : eventIndex; const targetVariableId = tracked ? tracked.variableId : device.variableId; await writeTagFromEvent(targetVariableId, 0, `Event "${eventData.label}" ended after duration`).catch(err => { logger.error(`Error in duration END callback: ${err.message}`); }); // Execute server-side device actions (Set Value, Run Script, etc.) const schedulers = await runtime.schedulerStorage.getAllSchedulers(); const scheduler = schedulers.find(s => s.id === schedulerId); if (scheduler?.data?.settings) { await executeDeviceActions(schedulerId, targetDevice, 'off', scheduler.data.settings); } // Check if this is a non-recurring event that will delete itself const willDelete = eventData.recurring === false; let isLastDay = false; if (willDelete) { const currentDay = new Date().getDay(); isLastDay = await isLastDayOfWeekForEvent(dayNumbers, currentDay); } // Only emit active:false if NOT deleting (deletion triggers scheduler:updated which refreshes UI) if (runtime.io && !(willDelete && isLastDay)) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: targetDevice, eventIndex: targetIndex, eventId: eventData.id, eventData: eventData, active: false }); } if (willDelete && isLastDay) { await removeOneTimeEvent(schedulerId, targetDevice, targetIndex, eventData.id); } }, durationMs); activeData.endTimeout = endTimeout; } }); } catch (scheduleError) { logger.error(`Error scheduling START job: ${scheduleError.message}`); } if (!isEventMode) { const endRule = new schedule.RecurrenceRule(); if (isMonthMode) { endRule.month = monthNumbers; endRule.date = dayOfMonthNumbers; } else { endRule.dayOfWeek = dayNumbers; } endRule.hour = endHour; endRule.minute = endMin; try { endJob = schedule.scheduleJob(endRule, async () => { await writeTagFromEvent(device.variableId, 0, `Event "${eventData.label}" ended`).catch(err => { logger.error(`Error in END callback: ${err.message}`); }); // Execute server-side device actions (Set Value, Run Script, etc.) const schedulers = await runtime.schedulerStorage.getAllSchedulers(); const scheduler = schedulers.find(s => s.id === schedulerId); if (scheduler?.data?.settings) { await executeDeviceActions(schedulerId, device.name, 'off', scheduler.data.settings); } // Check if this is a non-recurring event that will delete itself const willDelete = eventData.recurring === false; let isLastDay = false; if (willDelete) { const currentDay = new Date().getDay(); isLastDay = await isLastDayOfWeekForEvent(dayNumbers, currentDay); } // Only emit active:false if NOT deleting (deletion triggers scheduler:updated which refreshes UI) if (runtime.io && !(willDelete && isLastDay)) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: eventData.id, eventData: eventData, active: false }); } if (willDelete && isLastDay) { await removeOneTimeEvent(schedulerId, device.name, eventIndex, eventData.id); } }); } catch (scheduleError) { logger.error(`Error scheduling END job: ${scheduleError.message}`); } } if (startJob) { activeJobs.set(`${jobId}_start`, startJob); } if (endJob) { activeJobs.set(`${jobId}_end`, endJob); } if (isEventMode) { if (!event.id) { logger.error(`Event missing ID! Cannot check for transfer.`); return; } const activeKey = event.id; if (activeEventModeSchedules.has(activeKey)) { const activeData = activeEventModeSchedules.get(activeKey); if (activeData.interval) { clearInterval(activeData.interval); activeData.interval = null; } activeData.deviceName = device.name; activeData.eventIndex = eventIndex; activeData.variableId = device.variableId; activeEventModeSchedules.set(activeKey, activeData); await writeTagFromEvent(device.variableId, 1, `Transferred Event Mode event started on ${device.name}`); if (runtime.io) { const remainingTime = Math.max(0, Math.ceil((activeData.endTime - Date.now()) / 1000)); runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: event.id, eventData: eventData, active: true, remainingTime: remainingTime }); const transferredInterval = setInterval(() => { const remainingMs = activeData.endTime - Date.now(); const remaining = Math.max(0, Math.floor(remainingMs / 1000)); if (runtime.io && remaining > 0) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_REMAINING, { schedulerId: activeData.schedulerId, deviceName: activeData.deviceName, eventIndex: activeData.eventIndex, eventId: activeData.eventId, remaining: remaining }); } else if (remaining <= 0) { clearInterval(transferredInterval); } }, 1000); activeData.interval = transferredInterval; } } } } catch (error) { logger.error(`Error creating job for event: ${error.message}`); logger.error(error.stack); } } /** * Check if event should be active right now and notify clients */ function checkAndNotifyEventState(schedulerId, device, eventData, eventIndex, dayNumbers) { try { const now = new Date(); const currentDay = now.getDay(); const currentHour = now.getHours(); const currentMin = now.getMinutes(); const currentTimeInMinutes = currentHour * 60 + currentMin; if (eventData.eventMode === true) { if (!eventData.id) { // Event has no ID, treat as inactive if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventData: eventData, active: false }); } return; } const eventKey = eventData.id; const activeEventMode = activeEventModeSchedules.get(eventKey); const isActive = !!(activeEventMode && activeEventMode.endTime > Date.now()); if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: eventData.id, eventData: eventData, active: isActive }); if (isActive && eventData.duration) { const remainingMs = activeEventMode.endTime - Date.now(); const remainingSeconds = Math.max(0, Math.floor(remainingMs / 1000)); runtime.io.emit(Events.IoEventTypes.SCHEDULER_REMAINING, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: eventData.id, remaining: remainingSeconds }); } } return; } if (!dayNumbers.includes(currentDay)) { if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: eventData.id, eventData: eventData, active: false }); } return; } if (!eventData.endTime) { return; } const [startHour, startMin] = eventData.startTime.split(':').map(Number); const [endHour, endMin] = eventData.endTime.split(':').map(Number); const startTimeInMinutes = startHour * 60 + startMin; const endTimeInMinutes = endHour * 60 + endMin; let isActive = false; if (endTimeInMinutes > startTimeInMinutes) { isActive = currentTimeInMinutes >= startTimeInMinutes && currentTimeInMinutes < endTimeInMinutes; } else { isActive = currentTimeInMinutes >= startTimeInMinutes || currentTimeInMinutes < endTimeInMinutes; } if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: eventIndex, eventId: eventData.id, eventData: eventData, active: isActive }); } } catch (error) { logger.error(`Error checking event state: ${error.message}`); } } /** * Check if the current day is the last scheduled day in the current week */ async function isLastDayOfWeekForEvent(dayNumbers, currentDay) { const remainingDays = dayNumbers.filter(day => day >= currentDay); if (remainingDays.length === 0) { return true; } remainingDays.sort((a, b) => a - b); return currentDay === remainingDays[remainingDays.length - 1]; } /** * Remove a one-time event after it has executed */ async function removeOneTimeEvent(schedulerId, deviceName, eventIndex, eventId) { try { const schedulerData = await runtime.schedulerStorage.getSchedulerData(schedulerId); if (!schedulerData) { logger.warn(`Cannot remove one-time event: scheduler ${schedulerId} not found`); return; } const deviceSchedules = schedulerData.schedules?.[deviceName]; if (!deviceSchedules || !Array.isArray(deviceSchedules)) { logger.warn(`Cannot remove one-time event: no schedules for device ${deviceName}`); return; } // Find event by ID (most reliable) or fallback to index let actualEventIndex = -1; if (eventId) { actualEventIndex = deviceSchedules.findIndex(e => e.id === eventId); } if (actualEventIndex === -1 && eventIndex >= 0 && eventIndex < deviceSchedules.length) { actualEventIndex = eventIndex; } if (actualEventIndex < 0 || actualEventIndex >= deviceSchedules.length) { logger.warn(`Cannot remove one-time event: event not found (id=${eventId}, index=${eventIndex})`); return; } const removedEvent = deviceSchedules.splice(actualEventIndex, 1)[0]; // Clean up any active Event Mode data for this event BEFORE saving/emitting // This prevents race conditions where notifyEventStates() sees stale active data if (eventId && activeEventModeSchedules.has(eventId)) { const activeData = activeEventModeSchedules.get(eventId); if (activeData.interval) { clearInterval(activeData.interval); } if (activeData.endTimeout) { clearTimeout(activeData.endTimeout); } activeEventModeSchedules.delete(eventId); } await runtime.schedulerStorage.setSchedulerData(schedulerId, schedulerData); // Cancel jobs using event ID if available const jobIdBase = eventId ? `${schedulerId}_${deviceName}_${eventId}` : `${schedulerId}_${deviceName}_${actualEventIndex}`; const startJobKey = `${jobIdBase}_start`; const endJobKey = `${jobIdBase}_end`; if (activeJobs.has(startJobKey)) { activeJobs.get(startJobKey).cancel(); activeJobs.delete(startJobKey); } if (activeJobs.has(endJobKey)) { activeJobs.get(endJobKey).cancel(); activeJobs.delete(endJobKey); } if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_UPDATED, { id: schedulerId, data: schedulerData }); } } catch (error) { logger.error(`Error removing one-time event: ${error.message}`); logger.error(error.stack); } } /** * Write tag value from event (with loop prevention) */ async function writeTagFromEvent(tagId, value, reason) { try { schedulerWriting.add(tagId); const result = await runtime.devices.setTagValue(tagId, value); if (result) { const deviceId = getDeviceIdFromTag(tagId); if (deviceId) { const values = {}; values[tagId] = { id: tagId, value: value, timestamp: Date.now() }; runtime.events.emit('device-value:changed', { id: deviceId, values: values }); } } setTimeout(() => { schedulerWriting.delete(tagId); }, 1000); } catch (error) { logger.error(`Error writing tag: ${error.message}`); schedulerWriting.delete(tagId); } } /** * Get device ID from tag ID */ function getDeviceIdFromTag(tagId) { try { const parts = tagId.split('.'); if (parts.length > 1) { return parts[0]; } return 'FuxaServer'; } catch (err) { return null; } } /** * MASTER CONTROL: When tag changes externally, check if event should override it */ async function onTagChanged(tagEvent) { const tagId = tagEvent.id; const currentValue = tagEvent.value ? 1 : 0; if (schedulerWriting.has(tagId)) { return; } try { const schedulers = await runtime.schedulerStorage.getAllSchedulers(); let device = null; let controllingScheduler = null; for (const scheduler of schedulers) { if (scheduler.data?.settings?.devices) { device = scheduler.data.settings.devices.find(d => d.variableId === tagId); if (device) { controllingScheduler = scheduler; break; } } } if (!device || !controllingScheduler) { return; } const schedules = controllingScheduler.data.schedules?.[device.name] || []; let isEventModeActive = false; for (const [key, eventInfo] of activeEventModeSchedules.entries()) { if (eventInfo.variableId === tagId && eventInfo.endTime > Date.now()) { isEventModeActive = true; break; } } const isTimerModeActive = checkIfAnyEventActive(schedules); const isEventActive = isEventModeActive || isTimerModeActive; const expectedValue = isEventActive ? 1 : 0; if (currentValue !== expectedValue) { await writeTagFromEvent(tagId, expectedValue, 'Master control enforcement'); } } catch (error) { logger.error(`Error in onTagChanged: ${error.message}`); } } /** * Check if any event is currently active */ function checkIfAnyEventActive(schedules) { if (!schedules || schedules.length === 0) { return false; } const now = new Date(); const currentDay = now.getDay(); const currentMonth = now.getMonth(); // 0-11 const currentDate = now.getDate(); // 1-31 const currentMinutes = now.getHours() * 60 + now.getMinutes(); for (const event of schedules) { // Check day-of-week mode if (!event.monthMode && event.days && event.days.length > 0) { if (event.days[currentDay] !== true) { continue; } } // Check month mode else if (event.monthMode && event.months && event.daysOfMonth) { if (event.months[currentMonth] !== true || event.daysOfMonth[currentDate - 1] !== true) { continue; } } else { continue; } if (event.eventMode === true) { continue; } if (!event.endTime) { continue; } const [startHour, startMin] = event.startTime.split(':').map(Number); const [endHour, endMin] = event.endTime.split(':').map(Number); const startMinutes = startHour * 60 + startMin; const endMinutes = endHour * 60 + endMin; let inRange = false; if (endMinutes < startMinutes) { inRange = currentMinutes >= startMinutes || currentMinutes < endMinutes; } else { inRange = currentMinutes >= startMinutes && currentMinutes < endMinutes; } if (inRange) { return true; } } return false; } /** * Check if any Event Mode events are currently active for a device */ function checkIfAnyEventModeActive(deviceName, schedulerId) { for (const [key, eventInfo] of activeEventModeSchedules.entries()) { if (eventInfo.deviceName === deviceName && eventInfo.schedulerId === schedulerId) { return true; } } return false; } /** * Update scheduler */ async function updateScheduler(schedulerId, schedulerData, oldData = null) { try { if (!oldData) { const schedulers = await runtime.schedulerStorage.getAllSchedulers(); const previousScheduler = schedulers.find(s => s.id === schedulerId); oldData = previousScheduler?.data; } if (oldData) { await handleTagChanges(schedulerId, oldData, schedulerData); } // Handle Event Mode duration changes BEFORE stopping/recreating jobs if (oldData) { await handleEventModifications(schedulerId, oldData, schedulerData); } if (oldData) { await handleEventDeletions(schedulerId, oldData, schedulerData); } stopScheduler(schedulerId); await createSchedulerJobs(schedulerId, schedulerData); await setInitialStates(schedulerId, schedulerData); // Notify all event states after update to ensure client has correct indices await notifyEventStates(schedulerId, schedulerData); } catch (error) { logger.error(`Error in updateScheduler: ${error.message}`); } } /** * Handle tag changes - reset old tags to 0 when device tag changes */ async function handleTagChanges(schedulerId, oldData, newData) { if (!oldData?.settings?.devices) { return; } const oldDevicesByName = new Map(); for (const device of oldData.settings.devices) { oldDevicesByName.set(device.name, device); } const newDevicesByName = new Map(); if (newData?.settings?.devices) { for (const device of newData.settings.devices) { newDevicesByName.set(device.name, device); } } for (const [deviceName, oldDevice] of oldDevicesByName) { const newDevice = newDevicesByName.get(deviceName); if (!newDevice) { await writeTagFromEvent(oldDevice.variableId, 0, 'Device deleted from scheduler'); } else if (oldDevice.variableId !== newDevice.variableId) { await writeTagFromEvent(oldDevice.variableId, 0, 'Tag change - resetting old tag'); } } } /** * Handle Event Mode duration changes for RUNNING events */ async function handleEventModifications(schedulerId, oldData, newData) { if (!oldData?.settings?.devices || !newData?.settings?.devices) { return; } // Check each device for events that exist in both old and new data for (const device of newData.settings.devices) { const oldSchedules = oldData.schedules?.[device.name] || []; const newSchedules = newData.schedules?.[device.name] || []; for (const newEvent of newSchedules) { if (!newEvent.eventMode || !newEvent.id) continue; // Find the corresponding old event by ID const oldEvent = oldSchedules.find(e => e.id === newEvent.id); if (!oldEvent) continue; // Event is new, not modified // Check if duration changed if (oldEvent.duration !== newEvent.duration) { // Check if this event is currently running const eventKey = newEvent.id; const activeData = activeEventModeSchedules.get(eventKey); if (activeData) { // Clear the old interval and timeout if (activeData.interval) { clearInterval(activeData.interval); activeData.interval = null; } if (activeData.endTimeout) { clearTimeout(activeData.endTimeout); activeData.endTimeout = null; } const newDurationMs = newEvent.duration * 1000; const newEndTime = Date.now() + newDurationMs; activeData.endTime = newEndTime; // Create new countdown interval const newInterval = setInterval(() => { const remainingMs = activeData.endTime - Date.now(); const remaining = Math.max(0, Math.floor(remainingMs / 1000)); if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_REMAINING, { schedulerId: activeData.schedulerId, deviceName: activeData.deviceName, eventIndex: activeData.eventIndex, eventId: activeData.eventId, remaining: remaining }); } }, 1000); activeData.interval = newInterval; // Create new END timeout with updated event data const newEndTimeout = setTimeout(async () => { const tracked = activeEventModeSchedules.get(eventKey); if (tracked && tracked.interval) { clearInterval(tracked.interval); } activeEventModeSchedules.delete(eventKey); const targetDevice = tracked ? tracked.deviceName : device.name; const targetIndex = tracked ? tracked.eventIndex : newSchedules.indexOf(newEvent); const targetVariableId = tracked ? tracked.variableId : device.variableId; await writeTagFromEvent(targetVariableId, 0, `Event ended after modified duration`).catch(err => { logger.error(`Error in modified duration END callback: ${err.message}`); }); // Check if this is a non-recurring event that will delete itself const dayNumbers = []; if (Array.isArray(newEvent.days)) { newEvent.days.forEach((isActive, dayIndex) => { if (isActive === true) dayNumbers.push(dayIndex); }); } const willDelete = newEvent.recurring === false; let isLastDay = false; if (willDelete) { const currentDay = new Date().getDay(); isLastDay = await isLastDayOfWeekForEvent(dayNumbers, currentDay); } // Only emit active:false if NOT deleting if (runtime.io && !(willDelete && isLastDay)) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: targetDevice, eventIndex: targetIndex, eventId: newEvent.id, eventData: { label: newEvent.label || `${device.name}_Event`, startTime: newEvent.startTime, eventMode: true, duration: newEvent.duration, id: newEvent.id }, active: false }); } if (willDelete && isLastDay) { await removeOneTimeEvent(schedulerId, targetDevice, targetIndex, newEvent.id); } }, newDurationMs); activeData.endTimeout = newEndTimeout; // Update the stored activeData activeEventModeSchedules.set(eventKey, activeData); // Immediately emit the new remaining time to client if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_REMAINING, { schedulerId: schedulerId, deviceName: device.name, eventIndex: newSchedules.indexOf(newEvent), eventId: newEvent.id, remaining: newEvent.duration }); } } } } } } /** * Handle event deletions */ async function handleEventDeletions(schedulerId, oldData, newData) { if (!oldData?.settings?.devices || !newData?.settings?.devices) { return; } const oldEventsByDevice = new Map(); for (const device of oldData.settings.devices) { const schedules = oldData.schedules?.[device.name] || []; for (const event of schedules) { if (!oldEventsByDevice.has(device.name)) { oldEventsByDevice.set(device.name, []); } oldEventsByDevice.get(device.name).push({ device, event }); } } const newEventsByDevice = new Map(); for (const device of newData.settings.devices) { const schedules = newData.schedules?.[device.name] || []; for (const event of schedules) { if (!newEventsByDevice.has(device.name)) { newEventsByDevice.set(device.name, []); } newEventsByDevice.get(device.name).push(event); } } for (const [deviceName, oldDeviceEvents] of oldEventsByDevice) { const newDeviceEvents = newEventsByDevice.get(deviceName) || []; const newEventIds = new Set(newDeviceEvents.map(e => e.id)); const deletedEvents = oldDeviceEvents.filter(({ event }) => !newEventIds.has(event.id)); if (deletedEvents.length > 0) { for (const { device, event } of deletedEvents) { const startJobId = `${schedulerId}_${device.name}_${event.id}_start`; const endJobId = `${schedulerId}_${device.name}_${event.id}_end`; let wasTransferred = false; if (event.eventMode && event.duration !== undefined) { for (const [otherDeviceName, otherDeviceEvents] of newEventsByDevice) { if (otherDeviceName !== deviceName) { const matchingEvent = otherDeviceEvents.find(e => e.id === event.id); if (matchingEvent) { const oldEventIndex = oldDeviceEvents.findIndex(({ event: e }) => e === event); const newEventIndex = newDeviceEvents.findIndex(e => e.id === event.id); const oldEventKey = event.id; const newEventKey = event.id; let activeData = null; if (activeEventModeSchedules.has(oldEventKey)) { activeData = activeEventModeSchedules.get(oldEventKey); } else { const oldEventKeyIndex = `${schedulerId}_${device.name}_${oldEventIndex}`; if (activeEventModeSchedules.has(oldEventKeyIndex)) { activeData = activeEventModeSchedules.get(oldEventKeyIndex); } } if (activeData) { if (runtime.io) { runtime.io.emit(Events.IoEventTypes.SCHEDULER_ACTIVE, { schedulerId: schedulerId, deviceName: device.name, eventIndex: oldEventIndex, eventId: event.id, eventData: { label: `Transferred from ${device.name}`, startTime: event.startTime, eventMode: true, duration: event.duration, id: event.id }, active: false }); } if (activeEventModeSchedules.has(oldEventKey)) { activeEventModeSchedules.delete(oldEventKey); } else { const oldEventKeyIndex2 = `${schedulerId}_${device.name}_${oldEventIndex}`; activeEventModeSchedules.delete(oldEventKeyIndex2); } activeData.deviceName = otherDeviceName; activeData.eventIndex = newEventIndex; activeData.variableId = newData.settings.devices.find(d => d.name === otherDeviceName)?.variableId || activeData.variableId; activeEventModeSchedules.set(newEventKey, activeData); wasTransferred = true; } break; } } } } if (!wasTransferred) { if (activeJobs.has(startJobId)) { activeJobs.get(startJobId).cancel(); activeJobs.delete(startJobId); } if (activeJobs.has(endJobId)) { activeJobs.get(endJobId).cancel(); activeJobs.delete(endJobId); } // If this is an Event Mode event that's currently running, stop it immediately if (event.eventMode && event.id) { // Find the event index in oldDeviceEvents const oldEventIndex = oldDeviceEvents.findIndex(({ event: e }) => e