diff --git a/.cursor/rules/reglas.mdc b/.cursor/rules/reglas.mdc index 23bfabf..a9cc54d 100644 --- a/.cursor/rules/reglas.mdc +++ b/.cursor/rules/reglas.mdc @@ -1,5 +1,5 @@ --- -alwaysApply: true +alwaysApply: false --- You can use .doc\MemoriaDeEvolucion.md to obtain a context of the latest modifications and concepts about this project. I would like that with the important knowledge and important decisions acquired in each modification you add them to MemoriaDeEvolucion.md maintaining the style that we already have of simple text without too much code and a summarized semantic. diff --git a/application_events.json b/application_events.json index cfc5c03..71f84fe 100644 --- a/application_events.json +++ b/application_events.json @@ -1755,8 +1755,114 @@ "udp_host": "127.0.0.1", "udp_port": 9870 } + }, + { + "timestamp": "2025-07-19T10:20:39.326530", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:21:08.778204", + "level": "error", + "event_type": "plc_connection_failed", + "message": "Failed to connect to PLC 10.1.33.11", + "details": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2 + } + }, + { + "timestamp": "2025-07-19T10:23:53.923405", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:26:00.730704", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:28:25.232935", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:45:16.831127", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:50:46.241841", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:54:12.806839", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T10:57:02.513632", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T11:01:07.447778", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T12:13:37.712539", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T12:13:43.277483", + "level": "error", + "event_type": "plc_connection_failed", + "message": "Failed to connect to PLC 10.1.33.11", + "details": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2 + } + }, + { + "timestamp": "2025-07-19T12:14:34.874959", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-07-19T12:16:12.281197", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} } ], - "last_updated": "2025-07-18T16:14:48.203024", - "total_entries": 157 + "last_updated": "2025-07-19T12:16:12.281197", + "total_entries": 171 } \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..5d7c6e9 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,33 @@ +""" +PLC S7-315 Data Streamer Core Module + +This module provides a complete solution for PLC data streaming, CSV recording, +and real-time monitoring for Siemens S7-315 PLCs. + +Classes: + PLCDataStreamer: Main orchestrator class + ConfigManager: Configuration and persistence management + PLCClient: PLC communication handling + DataStreamer: UDP streaming and CSV recording + EventLogger: Persistent event logging + InstanceManager: Single instance control and auto-recovery +""" + +from .plc_data_streamer import PLCDataStreamer +from .config_manager import ConfigManager +from .plc_client import PLCClient +from .streamer import DataStreamer +from .event_logger import EventLogger +from .instance_manager import InstanceManager + +__version__ = "2.0.0" +__author__ = "Industrial Automation Team" + +__all__ = [ + "PLCDataStreamer", + "ConfigManager", + "PLCClient", + "DataStreamer", + "EventLogger", + "InstanceManager", +] diff --git a/core/config_manager.py b/core/config_manager.py index e69de29..1d75058 100644 --- a/core/config_manager.py +++ b/core/config_manager.py @@ -0,0 +1,495 @@ +import json +import os +import sys +from datetime import datetime +from typing import Dict, Any, Optional, List, Set +from pathlib import Path + + +def resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + # Not running in a bundle + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +class ConfigManager: + """Manages all configuration persistence and validation""" + + def __init__(self, logger=None): + """Initialize configuration manager""" + self.logger = logger + + # Configuration file paths + self.config_file = resource_path("plc_config.json") + self.datasets_file = resource_path("plc_datasets.json") + self.state_file = resource_path("system_state.json") + + # Default configurations + self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2} + self.udp_config = {"host": "127.0.0.1", "port": 9870} + self.sampling_interval = 0.1 + + # Datasets management + self.datasets = {} # Dictionary of dataset_id -> dataset_config + self.active_datasets = set() # Set of active dataset IDs + self.current_dataset_id = None # Currently selected dataset for editing + + # System state for auto-recovery + self.last_state = { + "should_connect": False, + "should_stream": False, + "active_datasets": [], + } + self.auto_recovery_enabled = True + + # Load all configurations + self.load_all_configurations() + + def load_all_configurations(self): + """Load all configuration files""" + self.load_configuration() + self.load_datasets() + self.sync_streaming_variables() + self.load_system_state() + + if self.logger: + self.logger.info("All configurations loaded successfully") + + def load_configuration(self): + """Load PLC and UDP configuration from JSON file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, "r") as f: + config = json.load(f) + self.plc_config = config.get("plc_config", self.plc_config) + self.udp_config = config.get("udp_config", self.udp_config) + self.sampling_interval = config.get( + "sampling_interval", self.sampling_interval + ) + + if self.logger: + self.logger.info(f"Configuration loaded from {self.config_file}") + else: + if self.logger: + self.logger.info("No configuration file found, using defaults") + except Exception as e: + if self.logger: + self.logger.error(f"Error loading configuration: {e}") + + def save_configuration(self): + """Save PLC and UDP configuration to JSON file""" + try: + config = { + "plc_config": self.plc_config, + "udp_config": self.udp_config, + "sampling_interval": self.sampling_interval, + } + with open(self.config_file, "w") as f: + json.dump(config, f, indent=4) + + if self.logger: + self.logger.info(f"Configuration saved to {self.config_file}") + except Exception as e: + if self.logger: + self.logger.error(f"Error saving configuration: {e}") + + def load_datasets(self): + """Load datasets configuration from JSON file""" + try: + if os.path.exists(self.datasets_file): + with open(self.datasets_file, "r") as f: + datasets_data = json.load(f) + self.datasets = datasets_data.get("datasets", {}) + self.active_datasets = set(datasets_data.get("active_datasets", [])) + self.current_dataset_id = datasets_data.get("current_dataset_id") + + # Validate current_dataset_id exists + if ( + self.current_dataset_id + and self.current_dataset_id not in self.datasets + ): + self.current_dataset_id = None + + # Set default current dataset if none selected + if not self.current_dataset_id and self.datasets: + self.current_dataset_id = next(iter(self.datasets.keys())) + + if self.logger: + self.logger.info( + f"Datasets loaded from {self.datasets_file}: {len(self.datasets)} datasets, {len(self.active_datasets)} active" + ) + else: + if self.logger: + self.logger.info( + "No datasets file found, starting with empty datasets" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error loading datasets: {e}") + + def save_datasets(self): + """Save datasets configuration to JSON file""" + try: + datasets_data = { + "datasets": self.datasets, + "active_datasets": list(self.active_datasets), + "current_dataset_id": self.current_dataset_id, + "version": "1.0", + "last_update": datetime.now().isoformat(), + } + with open(self.datasets_file, "w") as f: + json.dump(datasets_data, f, indent=4) + + if self.logger: + self.logger.info( + f"Datasets configuration saved to {self.datasets_file}" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error saving datasets: {e}") + + def sync_streaming_variables(self): + """Synchronize streaming variables configuration""" + try: + sync_needed = False + for dataset_id, dataset_info in self.datasets.items(): + streaming_vars = dataset_info.get("streaming_variables", []) + variables_config = dataset_info.get("variables", {}) + + for var_name in streaming_vars: + if var_name in variables_config: + if not variables_config[var_name].get("streaming", False): + variables_config[var_name]["streaming"] = True + sync_needed = True + if self.logger: + self.logger.info( + f"Synchronized streaming flag for variable '{var_name}' in dataset '{dataset_id}'" + ) + + # Ensure variables not in streaming list have streaming=false + for var_name, var_config in variables_config.items(): + if var_name not in streaming_vars and var_config.get( + "streaming", False + ): + var_config["streaming"] = False + sync_needed = True + if self.logger: + self.logger.info( + f"Disabled streaming flag for variable '{var_name}' in dataset '{dataset_id}'" + ) + + if sync_needed: + self.save_datasets() + if self.logger: + self.logger.info("Streaming variables configuration synchronized") + + except Exception as e: + if self.logger: + self.logger.error(f"Error synchronizing streaming variables: {e}") + + def load_system_state(self): + """Load system state from JSON file""" + try: + if os.path.exists(self.state_file): + with open(self.state_file, "r") as f: + state_data = json.load(f) + self.last_state = state_data.get("last_state", self.last_state) + self.auto_recovery_enabled = state_data.get( + "auto_recovery_enabled", True + ) + + if self.logger: + self.logger.info(f"System state loaded from {self.state_file}") + else: + if self.logger: + self.logger.info( + "No system state file found, starting with defaults" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error loading system state: {e}") + + def save_system_state(self, connected=False, streaming=False, active_datasets=None): + """Save current system state to JSON file""" + try: + state_data = { + "last_state": { + "should_connect": connected, + "should_stream": streaming, + "active_datasets": list(active_datasets or []), + }, + "auto_recovery_enabled": self.auto_recovery_enabled, + "last_update": datetime.now().isoformat(), + } + + with open(self.state_file, "w") as f: + json.dump(state_data, f, indent=4) + + if self.logger: + self.logger.debug("System state saved") + except Exception as e: + if self.logger: + self.logger.error(f"Error saving system state: {e}") + + # PLC Configuration Methods + def update_plc_config(self, ip: str, rack: int, slot: int): + """Update PLC configuration""" + old_config = self.plc_config.copy() + self.plc_config = {"ip": ip, "rack": rack, "slot": slot} + self.save_configuration() + return {"old_config": old_config, "new_config": self.plc_config} + + def update_udp_config(self, host: str, port: int): + """Update UDP configuration""" + old_config = self.udp_config.copy() + self.udp_config = {"host": host, "port": port} + self.save_configuration() + return {"old_config": old_config, "new_config": self.udp_config} + + def update_sampling_interval(self, interval: float): + """Update sampling interval""" + old_interval = self.sampling_interval + self.sampling_interval = interval + self.save_configuration() + return {"old_interval": old_interval, "new_interval": interval} + + # Dataset Management Methods + def create_dataset( + self, dataset_id: str, name: str, prefix: str, sampling_interval: float = None + ): + """Create a new dataset""" + if dataset_id in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' already exists") + + new_dataset = { + "name": name, + "prefix": prefix, + "variables": {}, + "streaming_variables": [], + "sampling_interval": sampling_interval, + "enabled": False, + "created": datetime.now().isoformat(), + } + + self.datasets[dataset_id] = new_dataset + + # Set as current dataset if it's the first one + if not self.current_dataset_id: + self.current_dataset_id = dataset_id + + self.save_datasets() + return new_dataset + + def delete_dataset(self, dataset_id: str): + """Delete a dataset""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + dataset_info = self.datasets[dataset_id].copy() + del self.datasets[dataset_id] + + # Remove from active datasets if present + self.active_datasets.discard(dataset_id) + + # Update current dataset if this was selected + if self.current_dataset_id == dataset_id: + self.current_dataset_id = ( + next(iter(self.datasets.keys())) if self.datasets else None + ) + + self.save_datasets() + return dataset_info + + def get_current_dataset(self): + """Get the currently selected dataset""" + if self.current_dataset_id and self.current_dataset_id in self.datasets: + return self.datasets[self.current_dataset_id] + return None + + def get_dataset_variables(self, dataset_id: str): + """Get variables for a specific dataset""" + if dataset_id in self.datasets: + return self.datasets[dataset_id].get("variables", {}) + return {} + + def get_dataset_sampling_interval(self, dataset_id: str): + """Get sampling interval for a dataset (falls back to global if not set)""" + if dataset_id in self.datasets: + dataset_interval = self.datasets[dataset_id].get("sampling_interval") + return ( + dataset_interval + if dataset_interval is not None + else self.sampling_interval + ) + return self.sampling_interval + + def add_variable_to_dataset( + self, + dataset_id: str, + name: str, + area: str, + db: int, + offset: int, + var_type: str, + bit: int = None, + streaming: bool = False, + ): + """Add a variable to a specific dataset""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + # Validate area and type + area = area.lower() + if area not in ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"]: + raise ValueError(f"Unsupported area type: {area}") + + valid_types = [ + "real", + "int", + "bool", + "dint", + "word", + "byte", + "uint", + "udint", + "sint", + "usint", + ] + if var_type not in valid_types: + raise ValueError(f"Invalid data type: {var_type}") + + # Create variable configuration + var_config = { + "area": area, + "offset": offset, + "type": var_type, + "streaming": streaming, + } + + if area == "db": + var_config["db"] = db + if area in ["e", "a", "mb"] or (area == "db" and bit is not None): + var_config["bit"] = bit + + # Add to dataset + self.datasets[dataset_id]["variables"][name] = var_config + + # Update streaming variables list if streaming is enabled + if streaming: + if name not in self.datasets[dataset_id]["streaming_variables"]: + self.datasets[dataset_id]["streaming_variables"].append(name) + + self.save_datasets() + return var_config + + def remove_variable_from_dataset(self, dataset_id: str, name: str): + """Remove a variable from a specific dataset""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + if name not in self.datasets[dataset_id]["variables"]: + raise ValueError(f"Variable '{name}' not found in dataset '{dataset_id}'") + + var_config = self.datasets[dataset_id]["variables"][name].copy() + del self.datasets[dataset_id]["variables"][name] + + # Remove from streaming variables if present + if name in self.datasets[dataset_id]["streaming_variables"]: + self.datasets[dataset_id]["streaming_variables"].remove(name) + + self.save_datasets() + return var_config + + def toggle_variable_streaming(self, dataset_id: str, name: str, enabled: bool): + """Toggle streaming for a variable in a dataset""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + if name not in self.datasets[dataset_id]["variables"]: + raise ValueError(f"Variable '{name}' not found in dataset '{dataset_id}'") + + # Update the individual variable streaming flag + self.datasets[dataset_id]["variables"][name]["streaming"] = enabled + + # Update the streaming variables list + if enabled: + if name not in self.datasets[dataset_id]["streaming_variables"]: + self.datasets[dataset_id]["streaming_variables"].append(name) + else: + if name in self.datasets[dataset_id]["streaming_variables"]: + self.datasets[dataset_id]["streaming_variables"].remove(name) + + self.save_datasets() + + def activate_dataset(self, dataset_id: str): + """Mark a dataset as active""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + self.active_datasets.add(dataset_id) + self.datasets[dataset_id]["enabled"] = True + self.save_datasets() + + def deactivate_dataset(self, dataset_id: str): + """Mark a dataset as inactive""" + if dataset_id not in self.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + self.active_datasets.discard(dataset_id) + self.datasets[dataset_id]["enabled"] = False + self.save_datasets() + + def get_status(self): + """Get configuration status""" + total_variables = sum( + len(dataset["variables"]) for dataset in self.datasets.values() + ) + + # Count only variables that are in streaming_variables list AND have streaming=true + total_streaming_vars = 0 + for dataset in self.datasets.values(): + streaming_vars = dataset.get("streaming_variables", []) + variables_config = dataset.get("variables", {}) + active_streaming_vars = [ + var + for var in streaming_vars + if variables_config.get(var, {}).get("streaming", False) + ] + total_streaming_vars += len(active_streaming_vars) + + return { + "plc_config": self.plc_config, + "udp_config": self.udp_config, + "datasets_count": len(self.datasets), + "active_datasets_count": len(self.active_datasets), + "total_variables": total_variables, + "total_streaming_variables": total_streaming_vars, + "streaming_variables_count": total_streaming_vars, + "sampling_interval": self.sampling_interval, + "current_dataset_id": self.current_dataset_id, + "datasets": { + dataset_id: { + "name": info["name"], + "prefix": info["prefix"], + "variables_count": len(info["variables"]), + "streaming_count": len( + [ + var + for var in info.get("streaming_variables", []) + if info.get("variables", {}) + .get(var, {}) + .get("streaming", False) + ] + ), + "sampling_interval": info.get("sampling_interval"), + "enabled": info.get("enabled", False), + "active": dataset_id in self.active_datasets, + } + for dataset_id, info in self.datasets.items() + }, + } diff --git a/core/event_logger.py b/core/event_logger.py new file mode 100644 index 0000000..e42ca17 --- /dev/null +++ b/core/event_logger.py @@ -0,0 +1,165 @@ +import json +import os +import sys +from datetime import datetime +from typing import Dict, Any, List + + +def resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + # Not running in a bundle + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +class EventLogger: + """Handles persistent event logging and retrieval""" + + def __init__(self, logger=None, max_entries=1000): + """Initialize event logger""" + self.logger = logger + self.max_entries = max_entries + self.events_log_file = resource_path("application_events.json") + self.events_log = [] + + # Load existing events + self.load_events_log() + + def load_events_log(self): + """Load events log from JSON file""" + try: + if os.path.exists(self.events_log_file): + with open(self.events_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.events_log = data.get("events", []) + # Limit log size on load + if len(self.events_log) > self.max_entries: + self.events_log = self.events_log[-self.max_entries :] + + if self.logger: + self.logger.info( + f"Events log loaded: {len(self.events_log)} entries" + ) + else: + self.events_log = [] + if self.logger: + self.logger.info( + "No events log file found, starting with empty log" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error loading events log: {e}") + self.events_log = [] + + def save_events_log(self): + """Save events log to JSON file""" + try: + log_data = { + "events": self.events_log, + "last_updated": datetime.now().isoformat(), + "total_entries": len(self.events_log), + } + with open(self.events_log_file, "w", encoding="utf-8") as f: + json.dump(log_data, f, indent=2, ensure_ascii=False) + except Exception as e: + if self.logger: + self.logger.error(f"Error saving events log: {e}") + + def log_event( + self, level: str, event_type: str, message: str, details: Dict[str, Any] = None + ): + """Add an event to the persistent log""" + try: + event = { + "timestamp": datetime.now().isoformat(), + "level": level, # info, warning, error + "event_type": event_type, # connection, disconnection, error, config_change, etc. + "message": message, + "details": details or {}, + } + + self.events_log.append(event) + + # Limit log size + if len(self.events_log) > self.max_entries: + self.events_log = self.events_log[-self.max_entries :] + + # Save to file + self.save_events_log() + + # Also log to regular logger if available + if self.logger: + if level == "error": + self.logger.error(f"[{event_type}] {message}") + elif level == "warning": + self.logger.warning(f"[{event_type}] {message}") + else: + self.logger.info(f"[{event_type}] {message}") + + except Exception as e: + if self.logger: + self.logger.error(f"Error adding event to log: {e}") + + def get_recent_events(self, limit: int = 50) -> List[Dict[str, Any]]: + """Get recent events from the log""" + return self.events_log[-limit:] if self.events_log else [] + + def get_events_by_level(self, level: str, limit: int = 50) -> List[Dict[str, Any]]: + """Get events filtered by level""" + filtered_events = [ + event for event in self.events_log if event.get("level") == level + ] + return filtered_events[-limit:] if filtered_events else [] + + def get_events_by_type( + self, event_type: str, limit: int = 50 + ) -> List[Dict[str, Any]]: + """Get events filtered by type""" + filtered_events = [ + event for event in self.events_log if event.get("event_type") == event_type + ] + return filtered_events[-limit:] if filtered_events else [] + + def clear_events_log(self): + """Clear all events from the log""" + self.events_log = [] + self.save_events_log() + if self.logger: + self.logger.info("Events log cleared") + + def get_log_stats(self) -> Dict[str, Any]: + """Get statistics about the events log""" + if not self.events_log: + return { + "total_events": 0, + "levels": {}, + "types": {}, + "oldest_event": None, + "newest_event": None, + } + + levels = {} + types = {} + + for event in self.events_log: + level = event.get("level", "unknown") + event_type = event.get("event_type", "unknown") + + levels[level] = levels.get(level, 0) + 1 + types[event_type] = types.get(event_type, 0) + 1 + + return { + "total_events": len(self.events_log), + "levels": levels, + "types": types, + "oldest_event": ( + self.events_log[0].get("timestamp") if self.events_log else None + ), + "newest_event": ( + self.events_log[-1].get("timestamp") if self.events_log else None + ), + } diff --git a/core/instance_manager.py b/core/instance_manager.py new file mode 100644 index 0000000..39acc56 --- /dev/null +++ b/core/instance_manager.py @@ -0,0 +1,227 @@ +import os +import atexit +import psutil +import time +from typing import Optional, Callable + + +class InstanceManager: + """Manages single instance control and auto-recovery functionality""" + + def __init__(self, logger=None, lock_filename="plc_streamer.lock"): + """Initialize instance manager""" + self.logger = logger + self.lock_file = lock_filename + self.lock_fd = None + self._cleanup_registered = False + + def acquire_instance_lock(self) -> bool: + """Acquire lock to ensure single instance execution""" + try: + # Check if lock file exists + if os.path.exists(self.lock_file): + # Read PID from existing lock file + with open(self.lock_file, "r") as f: + try: + old_pid = int(f.read().strip()) + + # Check if process is still running + if psutil.pid_exists(old_pid): + # Get process info to verify it's our application + try: + proc = psutil.Process(old_pid) + cmdline = " ".join(proc.cmdline()) + # More specific check - only block if it's really our application + if ( + ( + "main.py" in cmdline + and "S7_snap7_Stremer_n_Log" in cmdline + ) + or ("plc_streamer" in cmdline.lower()) + or ("PLCDataStreamer" in cmdline) + ): + if self.logger: + self.logger.error( + f"Another instance is already running (PID: {old_pid})" + ) + self.logger.error(f"Command line: {cmdline}") + return False + else: + # Different Python process, remove stale lock + if self.logger: + self.logger.info( + f"Found different Python process (PID: {old_pid}), removing stale lock" + ) + os.remove(self.lock_file) + except (psutil.NoSuchProcess, psutil.AccessDenied): + # Process doesn't exist or can't access, continue + pass + + # Old process is dead, remove stale lock file + os.remove(self.lock_file) + if self.logger: + self.logger.info("Removed stale lock file") + + except (ValueError, IOError): + # Invalid lock file, remove it + os.remove(self.lock_file) + if self.logger: + self.logger.info("Removed invalid lock file") + + # Create new lock file with current PID + with open(self.lock_file, "w") as f: + f.write(str(os.getpid())) + + # Register cleanup function only once + if not self._cleanup_registered: + atexit.register(self.release_instance_lock) + self._cleanup_registered = True + + if self.logger: + self.logger.info( + f"Instance lock acquired: {self.lock_file} (PID: {os.getpid()})" + ) + return True + + except Exception as e: + if self.logger: + self.logger.error(f"Error acquiring instance lock: {e}") + return False + + def release_instance_lock(self): + """Release instance lock""" + try: + # Remove lock file + if os.path.exists(self.lock_file): + os.remove(self.lock_file) + if self.logger: + self.logger.info("Instance lock released") + + except Exception as e: + if self.logger: + self.logger.error(f"Error releasing instance lock: {e}") + + def is_process_running(self, pid: int) -> bool: + """Check if a process with given PID is running""" + try: + return psutil.pid_exists(pid) + except Exception: + return False + + def get_process_info(self, pid: int) -> Optional[dict]: + """Get information about a process""" + try: + if not psutil.pid_exists(pid): + return None + + proc = psutil.Process(pid) + return { + "pid": pid, + "name": proc.name(), + "cmdline": proc.cmdline(), + "status": proc.status(), + "create_time": proc.create_time(), + "memory_info": proc.memory_info()._asdict(), + "cpu_percent": proc.cpu_percent(), + } + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return None + + def attempt_auto_recovery(self, config_manager, plc_client, data_streamer) -> bool: + """Attempt to restore previous system state""" + if not config_manager.auto_recovery_enabled: + if self.logger: + self.logger.info("Auto-recovery disabled, skipping state restoration") + return False + + if self.logger: + self.logger.info("Attempting auto-recovery of previous state...") + + recovery_success = False + + try: + # Try to restore connection + if config_manager.last_state.get("should_connect", False): + if self.logger: + self.logger.info("Attempting to restore PLC connection...") + + if plc_client.connect(): + if self.logger: + self.logger.info("PLC connection restored successfully") + + # Try to restore streaming if connection was successful + if config_manager.last_state.get("should_stream", False): + if self.logger: + self.logger.info("Attempting to restore streaming...") + + # Setup UDP socket first + if not data_streamer.setup_udp_socket(): + if self.logger: + self.logger.warning( + "Failed to setup UDP socket during auto-recovery" + ) + return False + + # Restore active datasets + restored_datasets = config_manager.last_state.get( + "active_datasets", [] + ) + activated_count = 0 + + for dataset_id in restored_datasets: + if dataset_id in config_manager.datasets: + try: + data_streamer.activate_dataset(dataset_id) + activated_count += 1 + except Exception as e: + if self.logger: + self.logger.warning( + f"Failed to restore dataset {dataset_id}: {e}" + ) + + if activated_count > 0: + recovery_success = True + if self.logger: + self.logger.info( + f"Streaming restored successfully: {activated_count} datasets activated" + ) + else: + if self.logger: + self.logger.warning( + "Failed to restore streaming: no datasets activated" + ) + else: + recovery_success = True # Connection restored successfully + else: + if self.logger: + self.logger.warning("Failed to restore PLC connection") + else: + recovery_success = True # No connection was expected + + except Exception as e: + if self.logger: + self.logger.error(f"Error during auto-recovery: {e}") + recovery_success = False + + return recovery_success + + def wait_for_safe_startup(self, delay_seconds: float = 1.0): + """Wait for a safe startup delay to ensure previous instance cleanup""" + if delay_seconds > 0: + if self.logger: + self.logger.info(f"Waiting {delay_seconds}s for safe startup...") + time.sleep(delay_seconds) + + def force_cleanup_stale_locks(self): + """Force cleanup of stale lock files (use with caution)""" + try: + if os.path.exists(self.lock_file): + os.remove(self.lock_file) + if self.logger: + self.logger.info("Forced cleanup of lock file") + return True + return False + except Exception as e: + if self.logger: + self.logger.error(f"Error during forced cleanup: {e}") + return False diff --git a/core/plc_client.py b/core/plc_client.py index 4d93f8c..9c6f75c 100644 --- a/core/plc_client.py +++ b/core/plc_client.py @@ -1,1916 +1,281 @@ -from flask import ( - Flask, - render_template, - request, - jsonify, - redirect, - url_for, - send_from_directory, -) import snap7 import snap7.util -import json -import socket -import time -import logging -import threading -from datetime import datetime -from typing import Dict, Any, Optional, List import struct -import os -import csv -from pathlib import Path -import atexit -import psutil -import sys - -app = Flask(__name__) -app.secret_key = "plc_streamer_secret_key" - -def resource_path(relative_path): - """ Get absolute path to resource, works for dev and for PyInstaller """ - try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - base_path = sys._MEIPASS - except Exception: - # Not running in a bundle - base_path = os.path.abspath(".") - - return os.path.join(base_path, relative_path) +import logging +from typing import Dict, Any, Optional -class PLCDataStreamer: - def __init__(self): - """Initialize the PLC data streamer""" - # Configuration file paths - # Use resource_path to handle bundled and script paths correctly - self.config_file = resource_path("plc_config.json") - self.variables_file = resource_path("plc_variables.json") - self.datasets_file = resource_path("plc_datasets.json") - self.state_file = resource_path("system_state.json") - self.events_log_file = resource_path("application_events.json") +class PLCClient: + """Handles PLC communication operations""" - # Default configuration - self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2} - self.udp_config = {"host": "127.0.0.1", "port": 9870} - - # Multiple datasets structure - self.datasets = {} # Dictionary of dataset_id -> dataset_config - self.active_datasets = set() # Set of active dataset IDs - self.current_dataset_id = None # Currently selected dataset for editing - - # Dataset streaming threads and files - self.dataset_threads = {} # dataset_id -> thread object - self.dataset_csv_files = {} # dataset_id -> file handle - self.dataset_csv_writers = {} # dataset_id -> csv writer - self.dataset_csv_hours = {} # dataset_id -> current hour - self.dataset_using_modification_files = ( - {} - ) # dataset_id -> bool (track modification files) - - # System states + def __init__(self, logger=None): + """Initialize PLC client""" + self.logger = logger self.plc = None - self.udp_socket = None self.connected = False - self.streaming = False - self.stream_thread = None - self.sampling_interval = 0.1 - # Auto-recovery settings - self.auto_recovery_enabled = True - self.last_state = { - "should_connect": False, - "should_stream": False, - "should_record_csv": False, - } - - # Single instance control - self.lock_file = "plc_streamer.lock" - self.lock_fd = None - - # Events log for persistent logging - self.events_log = [] - self.max_log_entries = 1000 # Maximum number of log entries to keep - - # Setup logging first - self.setup_logging() - - # Load configuration from files - self.load_configuration() - self.load_datasets() # Load multiple datasets configuration - self.sync_streaming_variables() # Synchronize streaming variables configuration - self.load_system_state() - self.load_events_log() - - # Acquire instance lock and attempt auto-recovery - if self.acquire_instance_lock(): - # Small delay to ensure previous instance has fully cleaned up - time.sleep(1) - self.log_event( - "info", - "Application started", - "Application initialization completed successfully", - ) - self.attempt_auto_recovery() - else: - raise RuntimeError("Another instance of the application is already running") - - def setup_logging(self): - """Configure the logging system""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[logging.FileHandler("plc_data.log"), logging.StreamHandler()], - ) - self.logger = logging.getLogger(__name__) - - def load_configuration(self): - """Load PLC and UDP configuration from JSON file""" - try: - if os.path.exists(self.config_file): - with open(self.config_file, "r") as f: - config = json.load(f) - self.plc_config = config.get("plc_config", self.plc_config) - self.udp_config = config.get("udp_config", self.udp_config) - self.sampling_interval = config.get( - "sampling_interval", self.sampling_interval - ) - self.logger.info(f"Configuration loaded from {self.config_file}") - else: - self.logger.info("No configuration file found, using defaults") - except Exception as e: - self.logger.error(f"Error loading configuration: {e}") - - def save_configuration(self): - """Save PLC and UDP configuration to JSON file""" - try: - config = { - "plc_config": self.plc_config, - "udp_config": self.udp_config, - "sampling_interval": self.sampling_interval, - } - with open(self.config_file, "w") as f: - json.dump(config, f, indent=4) - self.logger.info(f"Configuration saved to {self.config_file}") - except Exception as e: - self.logger.error(f"Error saving configuration: {e}") - - def load_datasets(self): - """Load datasets configuration from JSON file""" - try: - if os.path.exists(self.datasets_file): - with open(self.datasets_file, "r") as f: - datasets_data = json.load(f) - self.datasets = datasets_data.get("datasets", {}) - self.active_datasets = set(datasets_data.get("active_datasets", [])) - self.current_dataset_id = datasets_data.get("current_dataset_id") - - # Validate current_dataset_id exists - if ( - self.current_dataset_id - and self.current_dataset_id not in self.datasets - ): - self.current_dataset_id = None - - # Set default current dataset if none selected - if not self.current_dataset_id and self.datasets: - self.current_dataset_id = next(iter(self.datasets.keys())) - - self.logger.info( - f"Datasets loaded from {self.datasets_file}: {len(self.datasets)} datasets, {len(self.active_datasets)} active" - ) - else: - self.logger.info("No datasets file found, starting with empty datasets") - except Exception as e: - self.logger.error(f"Error loading datasets: {e}") - - def save_datasets(self): - """Save datasets configuration to JSON file""" - try: - datasets_data = { - "datasets": self.datasets, - "active_datasets": list(self.active_datasets), - "current_dataset_id": self.current_dataset_id, - "version": "1.0", - "last_update": datetime.now().isoformat(), - } - with open(self.datasets_file, "w") as f: - json.dump(datasets_data, f, indent=4) - self.logger.info(f"Datasets configuration saved to {self.datasets_file}") - except Exception as e: - self.logger.error(f"Error saving datasets: {e}") - - def sync_streaming_variables(self): - """Synchronize streaming variables configuration - ensure variables in streaming_variables list have streaming=true""" - try: - sync_needed = False - for dataset_id, dataset_info in self.datasets.items(): - streaming_vars = dataset_info.get("streaming_variables", []) - variables_config = dataset_info.get("variables", {}) - - for var_name in streaming_vars: - if var_name in variables_config: - # If variable is in streaming list but doesn't have streaming=true, fix it - if not variables_config[var_name].get("streaming", False): - variables_config[var_name]["streaming"] = True - sync_needed = True - self.logger.info( - f"Synchronized streaming flag for variable '{var_name}' in dataset '{dataset_id}'" - ) - - # Also ensure variables not in streaming list have streaming=false - for var_name, var_config in variables_config.items(): - if var_name not in streaming_vars and var_config.get( - "streaming", False - ): - var_config["streaming"] = False - sync_needed = True - self.logger.info( - f"Disabled streaming flag for variable '{var_name}' in dataset '{dataset_id}'" - ) - - if sync_needed: - self.save_datasets() - self.logger.info("Streaming variables configuration synchronized") - - except Exception as e: - self.logger.error(f"Error synchronizing streaming variables: {e}") - - def create_dataset( - self, dataset_id: str, name: str, prefix: str, sampling_interval: float = None - ): - """Create a new dataset""" - if dataset_id in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' already exists") - - new_dataset = { - "name": name, - "prefix": prefix, - "variables": {}, - "streaming_variables": [], - "sampling_interval": sampling_interval, - "enabled": False, - "created": datetime.now().isoformat(), - } - - self.datasets[dataset_id] = new_dataset - - # Set as current dataset if it's the first one - if not self.current_dataset_id: - self.current_dataset_id = dataset_id - - self.save_datasets() - - self.log_event( - "info", - "dataset_created", - f"Dataset created: {name} (prefix: {prefix})", - { - "dataset_id": dataset_id, - "name": name, - "prefix": prefix, - "sampling_interval": sampling_interval, - }, - ) - - def delete_dataset(self, dataset_id: str): - """Delete a dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - # Stop dataset if it's active - if dataset_id in self.active_datasets: - self.stop_dataset(dataset_id) - - dataset_info = self.datasets[dataset_id].copy() - del self.datasets[dataset_id] - - # Update current dataset if this was selected - if self.current_dataset_id == dataset_id: - self.current_dataset_id = ( - next(iter(self.datasets.keys())) if self.datasets else None - ) - - self.save_datasets() - - self.log_event( - "info", - "dataset_deleted", - f"Dataset deleted: {dataset_info['name']}", - {"dataset_id": dataset_id, "dataset_info": dataset_info}, - ) - - def get_current_dataset(self): - """Get the currently selected dataset""" - if self.current_dataset_id and self.current_dataset_id in self.datasets: - return self.datasets[self.current_dataset_id] - return None - - def get_dataset_variables(self, dataset_id: str): - """Get variables for a specific dataset""" - if dataset_id in self.datasets: - return self.datasets[dataset_id].get("variables", {}) - return {} - - def get_dataset_sampling_interval(self, dataset_id: str): - """Get sampling interval for a dataset (falls back to global if not set)""" - if dataset_id in self.datasets: - dataset_interval = self.datasets[dataset_id].get("sampling_interval") - return ( - dataset_interval - if dataset_interval is not None - else self.sampling_interval - ) - return self.sampling_interval - - def add_variable_to_dataset( - self, - dataset_id: str, - name: str, - area: str, - db: int, - offset: int, - var_type: str, - bit: int = None, - streaming: bool = False, - ): - """Add a variable to a specific dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - # Validate area and type (reuse existing validation logic) - area = area.lower() - if area not in ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"]: - raise ValueError( - f"Unsupported area type: {area}. Supported: db, mw, m, pew, pe, paw, pa, e, a, mb" - ) - - valid_types = [ - "real", - "int", - "bool", - "dint", - "word", - "byte", - "uint", - "udint", - "sint", - "usint", - ] - if var_type not in valid_types: - raise ValueError( - f"Invalid data type: {var_type}. Supported: {', '.join(valid_types)}" - ) - - # Create variable configuration - var_config = { - "area": area, - "offset": offset, - "type": var_type, - "streaming": streaming, - } - - if area == "db": - var_config["db"] = db - if area in ["e", "a", "mb"] or (area == "db" and bit is not None): - var_config["bit"] = bit - - # Add to dataset - self.datasets[dataset_id]["variables"][name] = var_config - - # Update streaming variables list if streaming is enabled - if streaming: - if name not in self.datasets[dataset_id]["streaming_variables"]: - self.datasets[dataset_id]["streaming_variables"].append(name) - - self.save_datasets() - - # Create new CSV file if dataset is active and variables were modified - self.create_new_dataset_csv_file_for_variable_modification(dataset_id) - - # Log the addition - area_description = { - "db": ( - f"DB{db}.DBX{offset}.{bit}" if bit is not None else f"DB{db}.{offset}" - ), - "mw": f"MW{offset}", - "m": f"M{offset}", - "pew": f"PEW{offset}", - "pe": f"PE{offset}", - "paw": f"PAW{offset}", - "pa": f"PA{offset}", - "e": f"E{offset}.{bit}", - "a": f"A{offset}.{bit}", - "mb": f"M{offset}.{bit}", - } - - self.log_event( - "info", - "variable_added", - f"Variable added to dataset '{self.datasets[dataset_id]['name']}': {name} -> {area_description[area]} ({var_type})", - { - "dataset_id": dataset_id, - "name": name, - "area": area, - "db": db if area == "db" else None, - "offset": offset, - "bit": bit, - "type": var_type, - "streaming": streaming, - }, - ) - - def remove_variable_from_dataset(self, dataset_id: str, name: str): - """Remove a variable from a specific dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - if name not in self.datasets[dataset_id]["variables"]: - raise ValueError(f"Variable '{name}' not found in dataset '{dataset_id}'") - - var_config = self.datasets[dataset_id]["variables"][name].copy() - del self.datasets[dataset_id]["variables"][name] - - # Remove from streaming variables if present - if name in self.datasets[dataset_id]["streaming_variables"]: - self.datasets[dataset_id]["streaming_variables"].remove(name) - - self.save_datasets() - - # Create new CSV file if dataset is active and variables were modified - self.create_new_dataset_csv_file_for_variable_modification(dataset_id) - - self.log_event( - "info", - "variable_removed", - f"Variable removed from dataset '{self.datasets[dataset_id]['name']}': {name}", - {"dataset_id": dataset_id, "name": name, "removed_config": var_config}, - ) - - def toggle_variable_streaming(self, dataset_id: str, name: str, enabled: bool): - """Toggle streaming for a variable in a dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - if name not in self.datasets[dataset_id]["variables"]: - raise ValueError(f"Variable '{name}' not found in dataset '{dataset_id}'") - - # Update the individual variable streaming flag - self.datasets[dataset_id]["variables"][name]["streaming"] = enabled - - # Update the streaming variables list - if enabled: - if name not in self.datasets[dataset_id]["streaming_variables"]: - self.datasets[dataset_id]["streaming_variables"].append(name) - else: - if name in self.datasets[dataset_id]["streaming_variables"]: - self.datasets[dataset_id]["streaming_variables"].remove(name) - - self.save_datasets() - - self.logger.info( - f"Dataset '{dataset_id}' variable {name} streaming: {'enabled' if enabled else 'disabled'}" - ) - - def activate_dataset(self, dataset_id: str): - """Activate a dataset for streaming and CSV recording""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - if not self.connected: - raise RuntimeError("Cannot activate dataset: PLC not connected") - - self.active_datasets.add(dataset_id) - self.datasets[dataset_id]["enabled"] = True - self.save_datasets() - - # Start streaming thread for this dataset - self.start_dataset_streaming(dataset_id) - - dataset_info = self.datasets[dataset_id] - self.log_event( - "info", - "dataset_activated", - f"Dataset activated: {dataset_info['name']}", - { - "dataset_id": dataset_id, - "variables_count": len(dataset_info["variables"]), - "streaming_count": len(dataset_info["streaming_variables"]), - "prefix": dataset_info["prefix"], - }, - ) - - def deactivate_dataset(self, dataset_id: str): - """Deactivate a dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - self.active_datasets.discard(dataset_id) - self.datasets[dataset_id]["enabled"] = False - self.save_datasets() - - # Stop streaming thread for this dataset - self.stop_dataset_streaming(dataset_id) - - dataset_info = self.datasets[dataset_id] - self.log_event( - "info", - "dataset_deactivated", - f"Dataset deactivated: {dataset_info['name']}", - {"dataset_id": dataset_id}, - ) - - def start_dataset_streaming(self, dataset_id: str): - """Start streaming thread for a specific dataset""" - if dataset_id not in self.datasets: - return False - - if dataset_id in self.dataset_threads: - return True # Already running - - # Create and start thread for this dataset - thread = threading.Thread( - target=self.dataset_streaming_loop, args=(dataset_id,) - ) - thread.daemon = True - self.dataset_threads[dataset_id] = thread - thread.start() - - dataset_info = self.datasets[dataset_id] - interval = self.get_dataset_sampling_interval(dataset_id) - - self.logger.info( - f"Started streaming for dataset '{dataset_info['name']}' (interval: {interval}s)" - ) - return True - - def stop_dataset_streaming(self, dataset_id: str): - """Stop streaming thread for a specific dataset""" - if dataset_id in self.dataset_threads: - # The thread will detect this and stop - thread = self.dataset_threads[dataset_id] - if thread.is_alive(): - thread.join(timeout=2) - del self.dataset_threads[dataset_id] - - # Close CSV file if open - if dataset_id in self.dataset_csv_files: - self.dataset_csv_files[dataset_id].close() - del self.dataset_csv_files[dataset_id] - del self.dataset_csv_writers[dataset_id] - del self.dataset_csv_hours[dataset_id] - # Reset modification file flag - self.dataset_using_modification_files.pop(dataset_id, None) - - dataset_info = self.datasets.get(dataset_id, {}) - self.logger.info( - f"Stopped streaming for dataset '{dataset_info.get('name', dataset_id)}'" - ) - - def dataset_streaming_loop(self, dataset_id: str): - """Streaming loop for a specific dataset""" - dataset_info = self.datasets[dataset_id] - interval = self.get_dataset_sampling_interval(dataset_id) - - self.logger.info( - f"Dataset '{dataset_info['name']}' streaming loop started (interval: {interval}s)" - ) - - consecutive_errors = 0 - max_consecutive_errors = 5 - - while dataset_id in self.active_datasets and self.connected: - try: - start_time = time.time() - - # Read variables for this dataset - dataset_variables = self.get_dataset_variables(dataset_id) - all_data = self.read_dataset_variables(dataset_id, dataset_variables) - - if all_data: - consecutive_errors = 0 - - # Write to CSV (all variables) - self.write_dataset_csv_data(dataset_id, all_data) - - # Get filtered data for streaming - only variables that are in streaming_variables list AND have streaming=true - streaming_variables = dataset_info.get("streaming_variables", []) - dataset_vars_config = dataset_info.get("variables", {}) - streaming_data = { - name: value - for name, value in all_data.items() - if name in streaming_variables - and dataset_vars_config.get(name, {}).get("streaming", False) - } - - # Send filtered data to PlotJuggler - if streaming_data: - self.send_to_plotjuggler(streaming_data) - - # Log data - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - self.logger.info( - f"[{timestamp}] Dataset '{dataset_info['name']}': CSV: {len(all_data)} vars, Streaming: {len(streaming_data)} vars" - ) - else: - consecutive_errors += 1 - if consecutive_errors >= max_consecutive_errors: - self.log_event( - "error", - "dataset_streaming_error", - f"Multiple consecutive read failures for dataset '{dataset_info['name']}' ({consecutive_errors}). Stopping streaming.", - { - "dataset_id": dataset_id, - "consecutive_errors": consecutive_errors, - }, - ) - break - - # Maintain sampling interval - elapsed = time.time() - start_time - sleep_time = max(0, interval - elapsed) - time.sleep(sleep_time) - - except Exception as e: - consecutive_errors += 1 - self.log_event( - "error", - "dataset_streaming_error", - f"Error in dataset '{dataset_info['name']}' streaming loop: {str(e)}", - { - "dataset_id": dataset_id, - "error": str(e), - "consecutive_errors": consecutive_errors, - }, - ) - - if consecutive_errors >= max_consecutive_errors: - self.log_event( - "error", - "dataset_streaming_error", - f"Too many consecutive errors for dataset '{dataset_info['name']}'. Stopping streaming.", - { - "dataset_id": dataset_id, - "consecutive_errors": consecutive_errors, - }, - ) - break - - time.sleep(1) # Wait before retry - - # Clean up when exiting - self.stop_dataset_streaming(dataset_id) - self.logger.info(f"Dataset '{dataset_info['name']}' streaming loop ended") - - def read_dataset_variables( - self, dataset_id: str, variables: Dict[str, Any] - ) -> Dict[str, Any]: - """Read all variables for a specific dataset""" - data = {} - - for var_name, var_config in variables.items(): - try: - value = self.read_variable(var_config) - data[var_name] = value - except Exception as e: - self.logger.warning( - f"Error reading variable {var_name} in dataset {dataset_id}: {e}" - ) - data[var_name] = None - - return data - - def get_dataset_csv_file_path( - self, dataset_id: str, use_modification_timestamp: bool = False - ) -> str: - """Get the CSV file path for a specific dataset""" - if dataset_id not in self.datasets: - raise ValueError(f"Dataset '{dataset_id}' does not exist") - - now = datetime.now() - prefix = self.datasets[dataset_id]["prefix"] - - if use_modification_timestamp: - time_suffix = now.strftime("%H_%M_%S") - filename = f"{prefix}_{time_suffix}.csv" - else: - hour = now.strftime("%H") - filename = f"{prefix}_{hour}.csv" - - directory = self.get_csv_directory_path() - return os.path.join(directory, filename) - - def setup_dataset_csv_file(self, dataset_id: str): - """Setup CSV file for a specific dataset""" - current_hour = datetime.now().hour - - # If we're using a modification file and the hour hasn't changed, keep using it - if ( - self.dataset_using_modification_files.get(dataset_id, False) - and dataset_id in self.dataset_csv_hours - and self.dataset_csv_hours[dataset_id] == current_hour - and dataset_id in self.dataset_csv_files - ): - return - - # Check if we need to create a new file - if ( - dataset_id not in self.dataset_csv_hours - or self.dataset_csv_hours[dataset_id] != current_hour - or dataset_id not in self.dataset_csv_files - ): - - # Close previous file if open - if dataset_id in self.dataset_csv_files: - self.dataset_csv_files[dataset_id].close() - - # Create directory and file for current hour - self.ensure_csv_directory() - csv_path = self.get_dataset_csv_file_path(dataset_id) - - # Check if file exists to determine if we need headers - file_exists = os.path.exists(csv_path) - - self.dataset_csv_files[dataset_id] = open( - csv_path, "a", newline="", encoding="utf-8" - ) - self.dataset_csv_writers[dataset_id] = csv.writer( - self.dataset_csv_files[dataset_id] - ) - self.dataset_csv_hours[dataset_id] = current_hour - - # Reset modification file flag when creating regular hourly file - self.dataset_using_modification_files[dataset_id] = False - - # Write headers if it's a new file - dataset_variables = self.get_dataset_variables(dataset_id) - if not file_exists and dataset_variables: - headers = ["timestamp"] + list(dataset_variables.keys()) - self.dataset_csv_writers[dataset_id].writerow(headers) - self.dataset_csv_files[dataset_id].flush() - self.logger.info( - f"CSV file created for dataset '{self.datasets[dataset_id]['name']}': {csv_path}" - ) - - def write_dataset_csv_data(self, dataset_id: str, data: Dict[str, Any]): - """Write data to CSV file for a specific dataset""" - if dataset_id not in self.active_datasets: - return - - try: - self.setup_dataset_csv_file(dataset_id) - - if dataset_id in self.dataset_csv_writers: - # Create timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - - # Create row with all variables for this dataset - dataset_variables = self.get_dataset_variables(dataset_id) - row = [timestamp] - for var_name in dataset_variables.keys(): - row.append(data.get(var_name, None)) - - self.dataset_csv_writers[dataset_id].writerow(row) - self.dataset_csv_files[dataset_id].flush() - - except Exception as e: - self.logger.error(f"Error writing CSV data for dataset {dataset_id}: {e}") - - def create_new_dataset_csv_file_for_variable_modification(self, dataset_id: str): - """Create a new CSV file for a dataset when variables are modified during active recording""" - if dataset_id not in self.active_datasets: - return - - try: - # Close current file if open - if dataset_id in self.dataset_csv_files: - self.dataset_csv_files[dataset_id].close() - del self.dataset_csv_files[dataset_id] - del self.dataset_csv_writers[dataset_id] - self.logger.info( - f"Closed previous CSV file for dataset '{self.datasets[dataset_id]['name']}' due to variable modification" - ) - - # Create new file with modification timestamp - self.ensure_csv_directory() - csv_path = self.get_dataset_csv_file_path( - dataset_id, use_modification_timestamp=True - ) - - self.dataset_csv_files[dataset_id] = open( - csv_path, "w", newline="", encoding="utf-8" - ) - self.dataset_csv_writers[dataset_id] = csv.writer( - self.dataset_csv_files[dataset_id] - ) - - # Mark that we're using a modification file and set current hour - self.dataset_using_modification_files[dataset_id] = True - self.dataset_csv_hours[dataset_id] = datetime.now().hour - - # Write headers with new variable configuration - dataset_variables = self.get_dataset_variables(dataset_id) - if dataset_variables: - headers = ["timestamp"] + list(dataset_variables.keys()) - self.dataset_csv_writers[dataset_id].writerow(headers) - self.dataset_csv_files[dataset_id].flush() - - dataset_name = self.datasets[dataset_id]["name"] - self.logger.info( - f"New CSV file created after variable modification for dataset '{dataset_name}': {csv_path}" - ) - self.log_event( - "info", - "dataset_csv_file_created", - f"New CSV file created after variable modification for dataset '{dataset_name}': {os.path.basename(csv_path)}", - { - "dataset_id": dataset_id, - "file_path": csv_path, - "variables_count": len(dataset_variables), - "reason": "variable_modification", - }, - ) - - except Exception as e: - dataset_name = self.datasets.get(dataset_id, {}).get("name", dataset_id) - self.logger.error( - f"Error creating new CSV file after variable modification for dataset '{dataset_name}': {e}" - ) - self.log_event( - "error", - "dataset_csv_error", - f"Failed to create new CSV file after variable modification for dataset '{dataset_name}': {str(e)}", - {"dataset_id": dataset_id, "error": str(e)}, - ) - - def load_system_state(self): - """Load system state from JSON file""" - try: - if os.path.exists(self.state_file): - with open(self.state_file, "r") as f: - state_data = json.load(f) - self.last_state = state_data.get("last_state", self.last_state) - self.auto_recovery_enabled = state_data.get( - "auto_recovery_enabled", True - ) - self.logger.info(f"System state loaded from {self.state_file}") - else: - self.logger.info("No system state file found, starting with defaults") - except Exception as e: - self.logger.error(f"Error loading system state: {e}") - - def save_system_state(self): - """Save current system state to JSON file""" - try: - state_data = { - "last_state": { - "should_connect": self.connected, - "should_stream": self.streaming, - "active_datasets": list(self.active_datasets), - }, - "auto_recovery_enabled": self.auto_recovery_enabled, - "last_update": datetime.now().isoformat(), - } - - with open(self.state_file, "w") as f: - json.dump(state_data, f, indent=4) - self.logger.debug("System state saved") - except Exception as e: - self.logger.error(f"Error saving system state: {e}") - - def attempt_auto_recovery(self): - """Attempt to restore previous system state""" - if not self.auto_recovery_enabled: - self.logger.info("Auto-recovery disabled, skipping state restoration") - return - - self.logger.info("Attempting auto-recovery of previous state...") - - # Try to restore connection - if self.last_state.get("should_connect", False): - self.logger.info("Attempting to restore PLC connection...") - if self.connect_plc(): - self.logger.info("PLC connection restored successfully") - - # Try to restore streaming if connection was successful - if self.last_state.get("should_stream", False): - self.logger.info("Attempting to restore streaming...") - - # Setup UDP socket first - if not self.setup_udp_socket(): - self.logger.warning( - "Failed to setup UDP socket during auto-recovery" - ) - return - - # Restore active datasets - restored_datasets = self.last_state.get("active_datasets", []) - activated_count = 0 - - for dataset_id in restored_datasets: - if dataset_id in self.datasets: - try: - self.activate_dataset(dataset_id) - activated_count += 1 - except Exception as e: - self.logger.warning( - f"Failed to restore dataset {dataset_id}: {e}" - ) - - if activated_count > 0: - self.streaming = True - self.save_system_state() - self.logger.info( - f"Streaming restored successfully: {activated_count} datasets activated" - ) - else: - self.logger.warning( - "Failed to restore streaming: no datasets activated" - ) - else: - self.logger.warning("Failed to restore PLC connection") - - def acquire_instance_lock(self) -> bool: - """Acquire lock to ensure single instance execution""" - try: - # Check if lock file exists - if os.path.exists(self.lock_file): - # Read PID from existing lock file - with open(self.lock_file, "r") as f: - try: - old_pid = int(f.read().strip()) - - # Check if process is still running - if psutil.pid_exists(old_pid): - # Get process info to verify it's our application - try: - proc = psutil.Process(old_pid) - cmdline = " ".join(proc.cmdline()) - if "main.py" in cmdline or "plc" in cmdline.lower(): - self.logger.error( - f"Another instance is already running (PID: {old_pid})" - ) - return False - except (psutil.NoSuchProcess, psutil.AccessDenied): - # Process doesn't exist or can't access, continue - pass - - # Old process is dead, remove stale lock file - os.remove(self.lock_file) - self.logger.info("Removed stale lock file") - - except (ValueError, IOError): - # Invalid lock file, remove it - os.remove(self.lock_file) - self.logger.info("Removed invalid lock file") - - # Create new lock file with current PID - with open(self.lock_file, "w") as f: - f.write(str(os.getpid())) - - # Register cleanup function - atexit.register(self.release_instance_lock) - - self.logger.info( - f"Instance lock acquired: {self.lock_file} (PID: {os.getpid()})" - ) - return True - - except Exception as e: - self.logger.error(f"Error acquiring instance lock: {e}") - return False - - def release_instance_lock(self): - """Release instance lock""" - try: - # Remove lock file - if os.path.exists(self.lock_file): - os.remove(self.lock_file) - self.logger.info("Instance lock released") - - except Exception as e: - self.logger.error(f"Error releasing instance lock: {e}") - - def save_variables(self): - """Save variables configuration to JSON file""" - try: - # Update streaming state in variables before saving - for var_name in self.variables: - self.variables[var_name]["streaming"] = ( - var_name in self.streaming_variables - ) - - with open(self.variables_file, "w") as f: - json.dump(self.variables, f, indent=4) - self.logger.info(f"Variables saved to {self.variables_file}") - except Exception as e: - self.logger.error(f"Error saving variables: {e}") - - def update_plc_config(self, ip: str, rack: int, slot: int): - """Update PLC configuration""" - old_config = self.plc_config.copy() - self.plc_config = {"ip": ip, "rack": rack, "slot": slot} - self.save_configuration() - - config_details = {"old_config": old_config, "new_config": self.plc_config} - self.log_event( - "info", - "config_change", - f"PLC configuration updated: {ip}:{rack}/{slot}", - config_details, - ) - - def update_udp_config(self, host: str, port: int): - """Update UDP configuration""" - old_config = self.udp_config.copy() - self.udp_config = {"host": host, "port": port} - self.save_configuration() - - config_details = {"old_config": old_config, "new_config": self.udp_config} - self.log_event( - "info", - "config_change", - f"UDP configuration updated: {host}:{port}", - config_details, - ) - - def update_sampling_interval(self, interval: float): - """Update sampling interval""" - old_interval = self.sampling_interval - self.sampling_interval = interval - self.save_configuration() - - config_details = {"old_interval": old_interval, "new_interval": interval} - self.log_event( - "info", - "config_change", - f"Sampling interval updated: {interval}s", - config_details, - ) - - def add_variable( - self, name: str, area: str, db: int, offset: int, var_type: str, bit: int = None - ): - """Add a variable for polling""" - area = area.lower() - - # Validate area type - ahora incluye รกreas de bits individuales - if area not in ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"]: - raise ValueError( - f"Unsupported area type: {area}. Supported: db, mw, m, pew, pe, paw, pa, e, a, mb" - ) - - # Validate data type - valid_types = [ - "real", - "int", - "bool", - "dint", - "word", - "byte", - "uint", - "udint", - "sint", - "usint", - ] - if var_type not in valid_types: - raise ValueError( - f"Invalid data type: {var_type}. Supported: {', '.join(valid_types)}" - ) - - # Para รกreas de bits individuales, el tipo debe ser bool y bit debe estar especificado - if area in ["e", "a", "mb"] and var_type != "bool": - raise ValueError(f"For bit areas ({area}), data type must be 'bool'") - - if area in ["e", "a", "mb"] and bit is None: - raise ValueError( - f"For bit areas ({area}), bit position must be specified (0-7)" - ) - - # Validar rango de bit para todas las รกreas que lo soporten - if bit is not None and (bit < 0 or bit > 7): - raise ValueError("Bit position must be between 0 and 7") - - # Create variable configuration - var_config = { - "area": area, - "offset": offset, - "type": var_type, - "streaming": False, - } - - # Add DB number only for DB area - if area == "db": - var_config["db"] = db - - # Add bit position for bit areas and DB with specific bit - if area in ["e", "a", "mb"] or (area == "db" and bit is not None): - var_config["bit"] = bit - - self.variables[name] = var_config - self.save_variables() - - variable_details = { - "name": name, - "area": area, - "db": db if area == "db" else None, - "offset": offset, - "bit": bit, - "type": var_type, - "total_variables": len(self.variables), - } - - # Updated area description to include bit addresses - area_description = { - "db": ( - f"DB{db}.DBX{offset}.{bit}" if bit is not None else f"DB{db}.{offset}" - ), - "mw": f"MW{offset}", - "m": f"M{offset}", - "pew": f"PEW{offset}", - "pe": f"PE{offset}", - "paw": f"PAW{offset}", - "pa": f"PA{offset}", - "e": f"E{offset}.{bit}", - "a": f"A{offset}.{bit}", - "mb": f"M{offset}.{bit}", - } - - self.log_event( - "info", - "variable_added", - f"Variable added: {name} -> {area_description[area]} ({var_type})", - variable_details, - ) - self.create_new_csv_file_for_variable_modification() - - def remove_variable(self, name: str): - """Remove a variable from polling""" - if name in self.variables: - var_config = self.variables[name].copy() - del self.variables[name] - # Also remove from streaming variables if present - self.streaming_variables.discard(name) - self.save_variables() - - variable_details = { - "name": name, - "removed_config": var_config, - "total_variables": len(self.variables), - } - self.log_event( - "info", - "variable_removed", - f"Variable removed: {name}", - variable_details, - ) - self.create_new_csv_file_for_variable_modification() - - def toggle_streaming_variable(self, name: str, enabled: bool): - """Enable or disable a variable for streaming""" - if name in self.variables: - if enabled: - self.streaming_variables.add(name) - else: - self.streaming_variables.discard(name) - - # Save changes to persist streaming configuration - self.save_variables() - - self.logger.info( - f"Variable {name} streaming: {'enabled' if enabled else 'disabled'}" - ) - - def get_csv_directory_path(self) -> str: - """Get the directory path for current day's CSV files""" - now = datetime.now() - day_folder = now.strftime("%d-%m-%Y") - return os.path.join("records", day_folder) - - def get_csv_file_path(self, use_modification_timestamp: bool = False) -> str: - """Get the complete file path for current hour's CSV file""" - now = datetime.now() - - if use_modification_timestamp: - # Create filename with hour_min_sec format for variable modifications - time_suffix = now.strftime("%H_%M_%S") - filename = f"_{time_suffix}.csv" - else: - # Standard hourly format - hour = now.strftime("%H") - filename = f"{hour}.csv" - - directory = self.get_csv_directory_path() - return os.path.join(directory, filename) - - def ensure_csv_directory(self): - """Create CSV directory structure if it doesn't exist""" - directory = self.get_csv_directory_path() - Path(directory).mkdir(parents=True, exist_ok=True) - - def create_new_csv_file_for_variable_modification(self): - """Create a new CSV file when variables are modified during active recording""" - if not self.csv_recording: - return - - try: - # Close current file if open - if self.current_csv_file: - self.current_csv_file.close() - self.logger.info( - f"Closed previous CSV file due to variable modification" - ) - - # Create new file with modification timestamp - self.ensure_csv_directory() - csv_path = self.get_csv_file_path(use_modification_timestamp=True) - - self.current_csv_file = open(csv_path, "w", newline="", encoding="utf-8") - self.current_csv_writer = csv.writer(self.current_csv_file) - - # Mark that we're using a modification file and set current hour - self.using_modification_file = True - self.current_hour = datetime.now().hour - - # Write headers with new variable configuration - if self.variables: - headers = ["timestamp"] + list(self.variables.keys()) - self.current_csv_writer.writerow(headers) - self.current_csv_file.flush() - self.csv_headers_written = True - - self.logger.info( - f"New CSV file created after variable modification: {csv_path}" - ) - self.log_event( - "info", - "csv_file_created", - f"New CSV file created after variable modification: {os.path.basename(csv_path)}", - { - "file_path": csv_path, - "variables_count": len(self.variables), - "reason": "variable_modification", - }, - ) - - except Exception as e: - self.logger.error( - f"Error creating new CSV file after variable modification: {e}" - ) - self.log_event( - "error", - "csv_error", - f"Failed to create new CSV file after variable modification: {str(e)}", - {"error": str(e)}, - ) - - def setup_csv_file(self): - """Setup CSV file for the current hour""" - current_hour = datetime.now().hour - - # If we're using a modification file and the hour hasn't changed, keep using it - if ( - self.using_modification_file - and self.current_hour == current_hour - and self.current_csv_file is not None - ): - return - - # Check if we need to create a new file - if self.current_hour != current_hour or self.current_csv_file is None: - # Close previous file if open - if self.current_csv_file: - self.current_csv_file.close() - - # Create directory and file for current hour - self.ensure_csv_directory() - csv_path = self.get_csv_file_path() - - # Check if file exists to determine if we need headers - file_exists = os.path.exists(csv_path) - - self.current_csv_file = open(csv_path, "a", newline="", encoding="utf-8") - self.current_csv_writer = csv.writer(self.current_csv_file) - self.current_hour = current_hour - - # Reset modification file flag when creating regular hourly file - self.using_modification_file = False - - # Write headers if it's a new file - if not file_exists and self.variables: - headers = ["timestamp"] + list(self.variables.keys()) - self.current_csv_writer.writerow(headers) - self.current_csv_file.flush() - self.csv_headers_written = True - self.logger.info(f"CSV file created: {csv_path}") - - def write_csv_data(self, data: Dict[str, Any]): - """Write data to CSV file""" - if not self.csv_recording or not self.variables: - return - - try: - self.setup_csv_file() - - if self.current_csv_writer: - # Create timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - - # Create row with all variables (use None for missing values) - row = [timestamp] - for var_name in self.variables.keys(): - row.append(data.get(var_name, None)) - - self.current_csv_writer.writerow(row) - self.current_csv_file.flush() - - except Exception as e: - self.logger.error(f"Error writing CSV data: {e}") - - def get_streaming_data(self, all_data: Dict[str, Any]) -> Dict[str, Any]: - """Filter data for streaming based on selected variables""" - if not self.streaming_variables: - return all_data - - return { - name: value - for name, value in all_data.items() - if name in self.streaming_variables - } - - def connect_plc(self) -> bool: + def connect(self, ip: str, rack: int, slot: int) -> bool: """Connect to S7-315 PLC""" try: if self.plc: self.plc.disconnect() self.plc = snap7.client.Client() - self.plc.connect( - self.plc_config["ip"], self.plc_config["rack"], self.plc_config["slot"] - ) - + self.plc.connect(ip, rack, slot) self.connected = True - self.save_system_state() - connection_details = { - "ip": self.plc_config["ip"], - "rack": self.plc_config["rack"], - "slot": self.plc_config["slot"], - } - self.log_event( - "info", - "plc_connection", - f"Successfully connected to PLC {self.plc_config['ip']}", - connection_details, - ) + if self.logger: + self.logger.info(f"Successfully connected to PLC {ip}:{rack}/{slot}") return True except Exception as e: self.connected = False - error_details = { - "ip": self.plc_config["ip"], - "rack": self.plc_config["rack"], - "slot": self.plc_config["slot"], - "error": str(e), - } - self.log_event( - "error", - "plc_connection_failed", - f"Failed to connect to PLC {self.plc_config['ip']}: {str(e)}", - error_details, - ) + if self.logger: + self.logger.error( + f"Failed to connect to PLC {ip}:{rack}/{slot}: {str(e)}" + ) return False - def disconnect_plc(self): + def disconnect(self): """Disconnect from PLC""" try: if self.plc: self.plc.disconnect() self.connected = False - self.save_system_state() - - self.log_event( - "info", - "plc_disconnection", - f"Disconnected from PLC {self.plc_config['ip']}", - ) + if self.logger: + self.logger.info("Disconnected from PLC") except Exception as e: - self.log_event( - "error", - "plc_disconnection_error", - f"Error disconnecting from PLC: {str(e)}", - {"error": str(e)}, - ) + if self.logger: + self.logger.error(f"Error disconnecting from PLC: {str(e)}") + + def is_connected(self) -> bool: + """Check if PLC is connected""" + return self.connected and self.plc is not None def read_variable(self, var_config: Dict[str, Any]) -> Any: """Read a specific variable from the PLC""" + if not self.is_connected(): + return None + try: area_type = var_config.get("area", "db").lower() offset = var_config["offset"] var_type = var_config["type"] - bit = var_config.get("bit") # Extract bit position for bit areas + bit = var_config.get("bit") if area_type == "db": - # Data Block access (existing functionality) - db = var_config["db"] - if var_type == "real": - raw_data = self.plc.db_read(db, offset, 4) - value = struct.unpack(">f", raw_data)[0] - elif var_type == "int": - raw_data = self.plc.db_read(db, offset, 2) - value = struct.unpack(">h", raw_data)[0] - elif var_type == "bool": - raw_data = self.plc.db_read(db, offset, 1) - if bit is not None: - # Use snap7.util.get_bool for specific bit extraction - value = snap7.util.get_bool(raw_data, 0, bit) - else: - # Default to bit 0 for backward compatibility - value = bool(raw_data[0] & 0x01) - elif var_type == "dint": - raw_data = self.plc.db_read(db, offset, 4) - value = struct.unpack(">l", raw_data)[0] - elif var_type == "word": - raw_data = self.plc.db_read(db, offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "byte": - raw_data = self.plc.db_read(db, offset, 1) - value = struct.unpack(">B", raw_data)[0] - elif var_type == "uint": - raw_data = self.plc.db_read(db, offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "udint": - raw_data = self.plc.db_read(db, offset, 4) - value = struct.unpack(">L", raw_data)[0] - elif var_type == "sint": - raw_data = self.plc.db_read(db, offset, 1) - value = struct.unpack(">b", raw_data)[0] - elif var_type == "usint": - raw_data = self.plc.db_read(db, offset, 1) - value = struct.unpack(">B", raw_data)[0] - else: - return None - - elif area_type == "mw" or area_type == "m": - # Memory Words / Markers access - if var_type == "real": - raw_data = self.plc.mb_read(offset, 4) - value = struct.unpack(">f", raw_data)[0] - elif var_type == "int": - raw_data = self.plc.mb_read(offset, 2) - value = struct.unpack(">h", raw_data)[0] - elif var_type == "bool": - raw_data = self.plc.mb_read(offset, 1) - value = bool(raw_data[0] & 0x01) - elif var_type == "dint": - raw_data = self.plc.mb_read(offset, 4) - value = struct.unpack(">l", raw_data)[0] - elif var_type == "word": - raw_data = self.plc.mb_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "byte": - raw_data = self.plc.mb_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - elif var_type == "uint": - raw_data = self.plc.mb_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "udint": - raw_data = self.plc.mb_read(offset, 4) - value = struct.unpack(">L", raw_data)[0] - elif var_type == "sint": - raw_data = self.plc.mb_read(offset, 1) - value = struct.unpack(">b", raw_data)[0] - elif var_type == "usint": - raw_data = self.plc.mb_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - else: - return None - - elif area_type == "pew" or area_type == "pe": - # Process Input Words access - if var_type == "real": - raw_data = self.plc.eb_read(offset, 4) - value = struct.unpack(">f", raw_data)[0] - elif var_type == "int": - raw_data = self.plc.eb_read(offset, 2) - value = struct.unpack(">h", raw_data)[0] - elif var_type == "bool": - raw_data = self.plc.eb_read(offset, 1) - value = bool(raw_data[0] & 0x01) - elif var_type == "dint": - raw_data = self.plc.eb_read(offset, 4) - value = struct.unpack(">l", raw_data)[0] - elif var_type == "word": - raw_data = self.plc.eb_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "byte": - raw_data = self.plc.eb_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - elif var_type == "uint": - raw_data = self.plc.eb_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "udint": - raw_data = self.plc.eb_read(offset, 4) - value = struct.unpack(">L", raw_data)[0] - elif var_type == "sint": - raw_data = self.plc.eb_read(offset, 1) - value = struct.unpack(">b", raw_data)[0] - elif var_type == "usint": - raw_data = self.plc.eb_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - else: - return None - - elif area_type == "paw" or area_type == "pa": - # Process Output Words access - if var_type == "real": - raw_data = self.plc.ab_read(offset, 4) - value = struct.unpack(">f", raw_data)[0] - elif var_type == "int": - raw_data = self.plc.ab_read(offset, 2) - value = struct.unpack(">h", raw_data)[0] - elif var_type == "bool": - raw_data = self.plc.ab_read(offset, 1) - value = bool(raw_data[0] & 0x01) - elif var_type == "dint": - raw_data = self.plc.ab_read(offset, 4) - value = struct.unpack(">l", raw_data)[0] - elif var_type == "word": - raw_data = self.plc.ab_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "byte": - raw_data = self.plc.ab_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - elif var_type == "uint": - raw_data = self.plc.ab_read(offset, 2) - value = struct.unpack(">H", raw_data)[0] - elif var_type == "udint": - raw_data = self.plc.ab_read(offset, 4) - value = struct.unpack(">L", raw_data)[0] - elif var_type == "sint": - raw_data = self.plc.ab_read(offset, 1) - value = struct.unpack(">b", raw_data)[0] - elif var_type == "usint": - raw_data = self.plc.ab_read(offset, 1) - value = struct.unpack(">B", raw_data)[0] - else: - return None - + return self._read_db_variable(var_config, offset, var_type, bit) + elif area_type in ["mw", "m"]: + return self._read_memory_variable(offset, var_type) + elif area_type in ["pew", "pe"]: + return self._read_input_variable(offset, var_type) + elif area_type in ["paw", "pa"]: + return self._read_output_variable(offset, var_type) elif area_type == "e": - # Process Input Bits access (E5.1 format) - if var_type == "bool": - raw_data = self.plc.eb_read(offset, 1) - # Use snap7.util.get_bool for proper bit extraction - value = snap7.util.get_bool(raw_data, 0, bit) - else: - return None - + return self._read_input_bit(offset, bit) elif area_type == "a": - # Process Output Bits access (A3.7 format) - if var_type == "bool": - raw_data = self.plc.ab_read(offset, 1) - # Use snap7.util.get_bool for proper bit extraction - value = snap7.util.get_bool(raw_data, 0, bit) - else: - return None - + return self._read_output_bit(offset, bit) elif area_type == "mb": - # Memory Bits access (M10.0 format) - if var_type == "bool": - raw_data = self.plc.mb_read(offset, 1) - # Use snap7.util.get_bool for proper bit extraction - value = snap7.util.get_bool(raw_data, 0, bit) - else: - return None - + return self._read_memory_bit(offset, bit) else: - self.logger.error(f"Unsupported area type: {area_type}") + if self.logger: + self.logger.error(f"Unsupported area type: {area_type}") return None - return value - except Exception as e: - self.logger.error(f"Error reading variable: {e}") + if self.logger: + self.logger.error(f"Error reading variable: {e}") return None - def read_all_variables(self) -> Dict[str, Any]: - """Read all configured variables""" - if not self.connected or not self.plc: + def _read_db_variable( + self, var_config: Dict[str, Any], offset: int, var_type: str, bit: Optional[int] + ) -> Any: + """Read from Data Block""" + db = var_config["db"] + + if var_type == "real": + raw_data = self.plc.db_read(db, offset, 4) + return struct.unpack(">f", raw_data)[0] + elif var_type == "int": + raw_data = self.plc.db_read(db, offset, 2) + return struct.unpack(">h", raw_data)[0] + elif var_type == "bool": + raw_data = self.plc.db_read(db, offset, 1) + if bit is not None: + return snap7.util.get_bool(raw_data, 0, bit) + else: + return bool(raw_data[0] & 0x01) + elif var_type == "dint": + raw_data = self.plc.db_read(db, offset, 4) + return struct.unpack(">l", raw_data)[0] + elif var_type == "word": + raw_data = self.plc.db_read(db, offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "byte": + raw_data = self.plc.db_read(db, offset, 1) + return struct.unpack(">B", raw_data)[0] + elif var_type == "uint": + raw_data = self.plc.db_read(db, offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "udint": + raw_data = self.plc.db_read(db, offset, 4) + return struct.unpack(">L", raw_data)[0] + elif var_type == "sint": + raw_data = self.plc.db_read(db, offset, 1) + return struct.unpack(">b", raw_data)[0] + elif var_type == "usint": + raw_data = self.plc.db_read(db, offset, 1) + return struct.unpack(">B", raw_data)[0] + return None + + def _read_memory_variable(self, offset: int, var_type: str) -> Any: + """Read from Memory/Markers""" + if var_type == "real": + raw_data = self.plc.mb_read(offset, 4) + return struct.unpack(">f", raw_data)[0] + elif var_type == "int": + raw_data = self.plc.mb_read(offset, 2) + return struct.unpack(">h", raw_data)[0] + elif var_type == "bool": + raw_data = self.plc.mb_read(offset, 1) + return bool(raw_data[0] & 0x01) + elif var_type == "dint": + raw_data = self.plc.mb_read(offset, 4) + return struct.unpack(">l", raw_data)[0] + elif var_type == "word": + raw_data = self.plc.mb_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "byte": + raw_data = self.plc.mb_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + elif var_type == "uint": + raw_data = self.plc.mb_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "udint": + raw_data = self.plc.mb_read(offset, 4) + return struct.unpack(">L", raw_data)[0] + elif var_type == "sint": + raw_data = self.plc.mb_read(offset, 1) + return struct.unpack(">b", raw_data)[0] + elif var_type == "usint": + raw_data = self.plc.mb_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + return None + + def _read_input_variable(self, offset: int, var_type: str) -> Any: + """Read from Process Inputs""" + if var_type == "real": + raw_data = self.plc.eb_read(offset, 4) + return struct.unpack(">f", raw_data)[0] + elif var_type == "int": + raw_data = self.plc.eb_read(offset, 2) + return struct.unpack(">h", raw_data)[0] + elif var_type == "bool": + raw_data = self.plc.eb_read(offset, 1) + return bool(raw_data[0] & 0x01) + elif var_type == "dint": + raw_data = self.plc.eb_read(offset, 4) + return struct.unpack(">l", raw_data)[0] + elif var_type == "word": + raw_data = self.plc.eb_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "byte": + raw_data = self.plc.eb_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + elif var_type == "uint": + raw_data = self.plc.eb_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "udint": + raw_data = self.plc.eb_read(offset, 4) + return struct.unpack(">L", raw_data)[0] + elif var_type == "sint": + raw_data = self.plc.eb_read(offset, 1) + return struct.unpack(">b", raw_data)[0] + elif var_type == "usint": + raw_data = self.plc.eb_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + return None + + def _read_output_variable(self, offset: int, var_type: str) -> Any: + """Read from Process Outputs""" + if var_type == "real": + raw_data = self.plc.ab_read(offset, 4) + return struct.unpack(">f", raw_data)[0] + elif var_type == "int": + raw_data = self.plc.ab_read(offset, 2) + return struct.unpack(">h", raw_data)[0] + elif var_type == "bool": + raw_data = self.plc.ab_read(offset, 1) + return bool(raw_data[0] & 0x01) + elif var_type == "dint": + raw_data = self.plc.ab_read(offset, 4) + return struct.unpack(">l", raw_data)[0] + elif var_type == "word": + raw_data = self.plc.ab_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "byte": + raw_data = self.plc.ab_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + elif var_type == "uint": + raw_data = self.plc.ab_read(offset, 2) + return struct.unpack(">H", raw_data)[0] + elif var_type == "udint": + raw_data = self.plc.ab_read(offset, 4) + return struct.unpack(">L", raw_data)[0] + elif var_type == "sint": + raw_data = self.plc.ab_read(offset, 1) + return struct.unpack(">b", raw_data)[0] + elif var_type == "usint": + raw_data = self.plc.ab_read(offset, 1) + return struct.unpack(">B", raw_data)[0] + return None + + def _read_input_bit(self, offset: int, bit: int) -> bool: + """Read from Process Input Bits""" + raw_data = self.plc.eb_read(offset, 1) + return snap7.util.get_bool(raw_data, 0, bit) + + def _read_output_bit(self, offset: int, bit: int) -> bool: + """Read from Process Output Bits""" + raw_data = self.plc.ab_read(offset, 1) + return snap7.util.get_bool(raw_data, 0, bit) + + def _read_memory_bit(self, offset: int, bit: int) -> bool: + """Read from Memory Bits""" + raw_data = self.plc.mb_read(offset, 1) + return snap7.util.get_bool(raw_data, 0, bit) + + def read_multiple_variables( + self, variables: Dict[str, Dict[str, Any]] + ) -> Dict[str, Any]: + """Read multiple variables from the PLC""" + if not self.is_connected(): return {} data = {} - for var_name, var_config in self.variables.items(): - value = self.read_variable(var_config) - if value is not None: + for var_name, var_config in variables.items(): + try: + value = self.read_variable(var_config) data[var_name] = value + except Exception as e: + if self.logger: + self.logger.warning(f"Error reading variable {var_name}: {e}") + data[var_name] = None return data - def setup_udp_socket(self) -> bool: - """Setup UDP socket""" - try: - if self.udp_socket: - self.udp_socket.close() + def get_connection_info(self) -> Dict[str, Any]: + """Get current connection information""" + return {"connected": self.connected, "client_available": self.plc is not None} - self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.logger.info( - f"UDP socket configured for {self.udp_config['host']}:{self.udp_config['port']}" - ) + def test_connection(self, ip: str, rack: int, slot: int) -> bool: + """Test connection to PLC without permanently connecting""" + try: + test_client = snap7.client.Client() + test_client.connect(ip, rack, slot) + test_client.disconnect() return True - except Exception as e: - self.logger.error(f"Error configuring UDP socket: {e}") + if self.logger: + self.logger.debug(f"Connection test failed: {e}") return False - - def send_to_plotjuggler(self, data: Dict[str, Any]): - """Send data to PlotJuggler via UDP JSON""" - if not self.udp_socket: - return - - try: - message = {"timestamp": time.time(), "data": data} - - json_message = json.dumps(message) - self.udp_socket.sendto( - json_message.encode("utf-8"), - (self.udp_config["host"], self.udp_config["port"]), - ) - - except Exception as e: - self.logger.error(f"Error sending data to PlotJuggler: {e}") - - def streaming_loop(self): - """Main streaming loop""" - self.logger.info( - f"Starting streaming with interval of {self.sampling_interval}s" - ) - - consecutive_errors = 0 - max_consecutive_errors = 5 - - while self.streaming: - try: - start_time = time.time() - - # Read all variables - all_data = self.read_all_variables() - - if all_data: - # Reset error counter on successful read - consecutive_errors = 0 - - # Write to CSV (all variables) - self.write_csv_data(all_data) - - # Get filtered data for streaming - streaming_data = self.get_streaming_data(all_data) - - # Send filtered data to PlotJuggler - if streaming_data: - self.send_to_plotjuggler(streaming_data) - - # Log data - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - self.logger.info( - f"[{timestamp}] CSV: {len(all_data)} vars, Streaming: {len(streaming_data)} vars" - ) - else: - consecutive_errors += 1 - if consecutive_errors >= max_consecutive_errors: - self.log_event( - "error", - "streaming_error", - f"Multiple consecutive read failures ({consecutive_errors}). Stopping streaming.", - {"consecutive_errors": consecutive_errors}, - ) - break - - # Maintain sampling interval - elapsed = time.time() - start_time - sleep_time = max(0, self.sampling_interval - elapsed) - time.sleep(sleep_time) - - except Exception as e: - consecutive_errors += 1 - self.log_event( - "error", - "streaming_error", - f"Error in streaming loop: {str(e)}", - {"error": str(e), "consecutive_errors": consecutive_errors}, - ) - - if consecutive_errors >= max_consecutive_errors: - self.log_event( - "error", - "streaming_error", - "Too many consecutive errors. Stopping streaming.", - {"consecutive_errors": consecutive_errors}, - ) - break - - time.sleep(1) # Wait before retry - - def start_streaming(self) -> bool: - """Start data streaming - activates all datasets with variables""" - if not self.connected: - self.log_event( - "error", "streaming_error", "Cannot start streaming: PLC not connected" - ) - return False - - if not self.datasets: - self.log_event( - "error", - "streaming_error", - "Cannot start streaming: No datasets configured", - ) - return False - - if not self.setup_udp_socket(): - self.log_event( - "error", - "streaming_error", - "Cannot start streaming: UDP socket setup failed", - ) - return False - - # Activate all datasets that have variables - activated_count = 0 - for dataset_id, dataset_info in self.datasets.items(): - if dataset_info.get("variables"): - try: - self.activate_dataset(dataset_id) - activated_count += 1 - except Exception as e: - self.logger.warning(f"Failed to activate dataset {dataset_id}: {e}") - - if activated_count == 0: - self.log_event( - "error", - "streaming_error", - "Cannot start streaming: No datasets with variables configured", - ) - return False - - self.streaming = True - self.save_system_state() - - self.log_event( - "info", - "streaming_started", - f"Multi-dataset streaming started: {activated_count} datasets activated", - { - "activated_datasets": activated_count, - "total_datasets": len(self.datasets), - "udp_host": self.udp_config["host"], - "udp_port": self.udp_config["port"], - }, - ) - return True - - def stop_streaming(self): - """Stop streaming - deactivates all active datasets""" - self.streaming = False - - # Stop all dataset streaming threads - active_datasets_copy = self.active_datasets.copy() - for dataset_id in active_datasets_copy: - try: - self.deactivate_dataset(dataset_id) - except Exception as e: - self.logger.warning(f"Error deactivating dataset {dataset_id}: {e}") - - # Close UDP socket - if self.udp_socket: - self.udp_socket.close() - self.udp_socket = None - - self.save_system_state() - - datasets_stopped = len(active_datasets_copy) - self.log_event( - "info", - "streaming_stopped", - f"Multi-dataset streaming stopped: {datasets_stopped} datasets deactivated", - ) - - def get_status(self) -> Dict[str, Any]: - """Get current system status""" - total_variables = sum( - len(dataset["variables"]) for dataset in self.datasets.values() - ) - - # Count only variables that are in streaming_variables list AND have streaming=true - total_streaming_vars = 0 - for dataset in self.datasets.values(): - streaming_vars = dataset.get("streaming_variables", []) - variables_config = dataset.get("variables", {}) - active_streaming_vars = [ - var - for var in streaming_vars - if variables_config.get(var, {}).get("streaming", False) - ] - total_streaming_vars += len(active_streaming_vars) - - return { - "plc_connected": self.connected, - "streaming": self.streaming, - "plc_config": self.plc_config, - "udp_config": self.udp_config, - "datasets_count": len(self.datasets), - "active_datasets_count": len(self.active_datasets), - "total_variables": total_variables, - "total_streaming_variables": total_streaming_vars, - "streaming_variables_count": total_streaming_vars, # Add this for frontend compatibility - "sampling_interval": self.sampling_interval, - "current_dataset_id": self.current_dataset_id, - "datasets": { - dataset_id: { - "name": info["name"], - "prefix": info["prefix"], - "variables_count": len(info["variables"]), - "streaming_count": len( - [ - var - for var in info.get("streaming_variables", []) - if info.get("variables", {}) - .get(var, {}) - .get("streaming", False) - ] - ), - "sampling_interval": info.get("sampling_interval"), - "enabled": info.get("enabled", False), - "active": dataset_id in self.active_datasets, - } - for dataset_id, info in self.datasets.items() - }, - } - - def log_event( - self, level: str, event_type: str, message: str, details: Dict[str, Any] = None - ): - """Add an event to the persistent log""" - try: - event = { - "timestamp": datetime.now().isoformat(), - "level": level, # info, warning, error - "event_type": event_type, # connection, disconnection, error, config_change, etc. - "message": message, - "details": details or {}, - } - - self.events_log.append(event) - - # Limit log size - if len(self.events_log) > self.max_log_entries: - self.events_log = self.events_log[-self.max_log_entries :] - - # Save to file - self.save_events_log() - - # Also log to regular logger - if level == "error": - self.logger.error(f"[{event_type}] {message}") - elif level == "warning": - self.logger.warning(f"[{event_type}] {message}") - else: - self.logger.info(f"[{event_type}] {message}") - - except Exception as e: - self.logger.error(f"Error adding event to log: {e}") - - def load_events_log(self): - """Load events log from JSON file""" - try: - if os.path.exists(self.events_log_file): - with open(self.events_log_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.events_log = data.get("events", []) - # Limit log size on load - if len(self.events_log) > self.max_log_entries: - self.events_log = self.events_log[-self.max_log_entries :] - self.logger.info(f"Events log loaded: {len(self.events_log)} entries") - else: - self.events_log = [] - self.logger.info("No events log file found, starting with empty log") - except Exception as e: - self.logger.error(f"Error loading events log: {e}") - self.events_log = [] - - def save_events_log(self): - """Save events log to JSON file""" - try: - log_data = { - "events": self.events_log, - "last_updated": datetime.now().isoformat(), - "total_entries": len(self.events_log), - } - with open(self.events_log_file, "w", encoding="utf-8") as f: - json.dump(log_data, f, indent=2, ensure_ascii=False) - except Exception as e: - self.logger.error(f"Error saving events log: {e}") - - def get_recent_events(self, limit: int = 50) -> List[Dict[str, Any]]: - """Get recent events from the log""" - return self.events_log[-limit:] if self.events_log else [] - diff --git a/core/plc_data_streamer.py b/core/plc_data_streamer.py new file mode 100644 index 0000000..5764262 --- /dev/null +++ b/core/plc_data_streamer.py @@ -0,0 +1,530 @@ +import logging +from typing import Dict, Any, Optional, List +import psutil +import os +import math +from datetime import datetime + +try: + # Try relative imports first (when used as a package) + from .config_manager import ConfigManager + from .plc_client import PLCClient + from .streamer import DataStreamer + from .event_logger import EventLogger + from .instance_manager import InstanceManager +except ImportError: + # Fallback to absolute imports (when run directly) + from core.config_manager import ConfigManager + from core.plc_client import PLCClient + from core.streamer import DataStreamer + from core.event_logger import EventLogger + from core.instance_manager import InstanceManager + + +class PLCDataStreamer: + """Main orchestrator class that coordinates all PLC streaming components""" + + def __init__(self): + """Initialize the PLC data streamer orchestrator""" + try: + # Setup logging first + self.setup_logging() + self.logger.info("Logging system initialized") + + # Initialize all components step by step with error handling + self.logger.info("Initializing ConfigManager...") + self.config_manager = ConfigManager(self.logger) + self.logger.info("ConfigManager initialized successfully") + + self.logger.info("Initializing EventLogger...") + self.event_logger = EventLogger(self.logger) + self.logger.info("EventLogger initialized successfully") + + self.logger.info("Initializing PLCClient...") + self.plc_client = PLCClient(self.logger) + self.logger.info("PLCClient initialized successfully") + + self.logger.info("Initializing DataStreamer...") + self.data_streamer = DataStreamer( + self.config_manager, self.plc_client, self.event_logger, self.logger + ) + self.logger.info("DataStreamer initialized successfully") + + self.logger.info("Initializing InstanceManager...") + self.instance_manager = InstanceManager(self.logger) + self.logger.info("InstanceManager initialized successfully") + + # Acquire instance lock and attempt auto-recovery + self.logger.info("Acquiring instance lock...") + if self.instance_manager.acquire_instance_lock(): + # Small delay to ensure previous instance has fully cleaned up + self.instance_manager.wait_for_safe_startup(1.0) + self.event_logger.log_event( + "info", + "application_started", + "Application initialization completed successfully", + ) + self.logger.info("Attempting auto-recovery...") + self.attempt_auto_recovery() + self.logger.info("Initialization completed successfully") + else: + raise RuntimeError( + "Another instance of the application is already running" + ) + + except Exception as e: + if hasattr(self, "logger"): + self.logger.error(f"Error during initialization: {e}") + self.logger.exception("Full traceback:") + else: + print(f"Error during initialization: {e}") + import traceback + + traceback.print_exc() + raise + + def setup_logging(self): + """Configure the logging system""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("plc_data.log"), logging.StreamHandler()], + ) + self.logger = logging.getLogger(__name__) + + # PLC Connection Methods + def connect_plc(self) -> bool: + """Connect to PLC""" + success = self.plc_client.connect( + self.config_manager.plc_config["ip"], + self.config_manager.plc_config["rack"], + self.config_manager.plc_config["slot"], + ) + + if success: + self.config_manager.save_system_state( + connected=True, + streaming=self.data_streamer.is_streaming(), + active_datasets=self.data_streamer.get_active_datasets(), + ) + self.event_logger.log_event( + "info", + "plc_connection", + f"Successfully connected to PLC {self.config_manager.plc_config['ip']}", + self.config_manager.plc_config, + ) + else: + self.event_logger.log_event( + "error", + "plc_connection_failed", + f"Failed to connect to PLC {self.config_manager.plc_config['ip']}", + self.config_manager.plc_config, + ) + + return success + + def disconnect_plc(self): + """Disconnect from PLC""" + self.data_streamer.stop_streaming() + self.plc_client.disconnect() + + self.config_manager.save_system_state( + connected=False, streaming=False, active_datasets=set() + ) + + self.event_logger.log_event( + "info", + "plc_disconnection", + f"Disconnected from PLC {self.config_manager.plc_config['ip']}", + ) + + # Configuration Methods + def update_plc_config(self, ip: str, rack: int, slot: int): + """Update PLC configuration""" + config_details = self.config_manager.update_plc_config(ip, rack, slot) + self.event_logger.log_event( + "info", + "config_change", + f"PLC configuration updated: {ip}:{rack}/{slot}", + config_details, + ) + + def update_udp_config(self, host: str, port: int): + """Update UDP configuration""" + config_details = self.config_manager.update_udp_config(host, port) + self.event_logger.log_event( + "info", + "config_change", + f"UDP configuration updated: {host}:{port}", + config_details, + ) + + def update_sampling_interval(self, interval: float): + """Update sampling interval""" + config_details = self.config_manager.update_sampling_interval(interval) + self.event_logger.log_event( + "info", + "config_change", + f"Sampling interval updated: {interval}s", + config_details, + ) + + # Dataset Management Methods + def create_dataset( + self, dataset_id: str, name: str, prefix: str, sampling_interval: float = None + ): + """Create a new dataset""" + new_dataset = self.config_manager.create_dataset( + dataset_id, name, prefix, sampling_interval + ) + self.event_logger.log_event( + "info", + "dataset_created", + f"Dataset created: {name} (prefix: {prefix})", + { + "dataset_id": dataset_id, + "name": name, + "prefix": prefix, + "sampling_interval": sampling_interval, + }, + ) + return new_dataset + + def delete_dataset(self, dataset_id: str): + """Delete a dataset""" + # Stop dataset if it's active + if dataset_id in self.config_manager.active_datasets: + self.data_streamer.deactivate_dataset(dataset_id) + + dataset_info = self.config_manager.delete_dataset(dataset_id) + self.event_logger.log_event( + "info", + "dataset_deleted", + f"Dataset deleted: {dataset_info['name']}", + {"dataset_id": dataset_id, "dataset_info": dataset_info}, + ) + + def activate_dataset(self, dataset_id: str): + """Activate a dataset for streaming""" + if not self.plc_client.is_connected(): + raise RuntimeError("Cannot activate dataset: PLC not connected") + + self.data_streamer.activate_dataset(dataset_id) + + def deactivate_dataset(self, dataset_id: str): + """Deactivate a dataset""" + self.data_streamer.deactivate_dataset(dataset_id) + + # Variable Management Methods + def add_variable_to_dataset( + self, + dataset_id: str, + name: str, + area: str, + db: int, + offset: int, + var_type: str, + bit: int = None, + streaming: bool = False, + ): + """Add a variable to a dataset""" + var_config = self.config_manager.add_variable_to_dataset( + dataset_id, name, area, db, offset, var_type, bit, streaming + ) + + # Create new CSV file if dataset is active and variables were modified + self.data_streamer.create_new_dataset_csv_file_for_variable_modification( + dataset_id + ) + + # Log the addition + area_description = self._get_area_description(area, db, offset, bit) + self.event_logger.log_event( + "info", + "variable_added", + f"Variable added to dataset '{self.config_manager.datasets[dataset_id]['name']}': {name} -> {area_description} ({var_type})", + { + "dataset_id": dataset_id, + "name": name, + "area": area, + "db": db if area == "db" else None, + "offset": offset, + "bit": bit, + "type": var_type, + "streaming": streaming, + }, + ) + + return var_config + + def remove_variable_from_dataset(self, dataset_id: str, name: str): + """Remove a variable from a dataset""" + var_config = self.config_manager.remove_variable_from_dataset(dataset_id, name) + + # Create new CSV file if dataset is active and variables were modified + self.data_streamer.create_new_dataset_csv_file_for_variable_modification( + dataset_id + ) + + self.event_logger.log_event( + "info", + "variable_removed", + f"Variable removed from dataset '{self.config_manager.datasets[dataset_id]['name']}': {name}", + {"dataset_id": dataset_id, "name": name, "removed_config": var_config}, + ) + + return var_config + + def toggle_variable_streaming(self, dataset_id: str, name: str, enabled: bool): + """Toggle streaming for a variable in a dataset""" + self.config_manager.toggle_variable_streaming(dataset_id, name, enabled) + self.logger.info( + f"Dataset '{dataset_id}' variable {name} streaming: {'enabled' if enabled else 'disabled'}" + ) + + # Streaming Methods + def start_streaming(self) -> bool: + """Start streaming""" + success = self.data_streamer.start_streaming() + return success + + def stop_streaming(self): + """Stop streaming""" + self.data_streamer.stop_streaming() + + # Status and Information Methods + def get_status(self) -> Dict[str, Any]: + """Get current status of the application""" + status = { + "plc_connected": self.plc_client.is_connected(), + "streaming": self.data_streamer.is_streaming(), + "csv_recording": self.data_streamer.is_csv_recording(), + "udp_config": self.config_manager.udp_config, + "plc_config": self.config_manager.plc_config, + "datasets_count": len(self.config_manager.datasets), + "active_datasets_count": len(self.config_manager.active_datasets), + "total_variables": sum( + len(dataset["variables"]) + for dataset in self.config_manager.datasets.values() + ), + "streaming_variables_count": sum( + len(dataset.get("streaming_variables", [])) + for dataset in self.config_manager.datasets.values() + ), + "sampling_interval": self.config_manager.sampling_interval, + "disk_space_info": self.get_disk_space_info(), + } + return status + + def get_disk_space_info(self) -> Dict[str, Any]: + """Get information about disk space usage and recording time estimates""" + try: + # Get the records directory path + records_path = "records" + if not os.path.exists(records_path): + os.makedirs(records_path) + + # Get disk usage for the drive where records are stored + usage = psutil.disk_usage(os.path.abspath(records_path)) + + # Calculate average CSV file size (estimate based on active datasets) + avg_file_size_per_hour = self._estimate_csv_size_per_hour() + + # Calculate recording time left (in hours) + hours_left = usage.free / ( + avg_file_size_per_hour if avg_file_size_per_hour > 0 else 1024 * 1024 + ) # Default to 1MB if no estimate + days_left = hours_left / 24 + + # Format the time left + if hours_left > 48: + time_left = f"{days_left:.1f} days" + else: + time_left = f"{hours_left:.1f} hours" + + # Format human readable sizes + free_space = self._format_size(usage.free) + total_space = self._format_size(usage.total) + used_space = self._format_size(usage.used) + + return { + "free_space": free_space, + "total_space": total_space, + "used_space": used_space, + "percent_used": usage.percent, + "recording_time_left": time_left, + "avg_file_size_per_hour": self._format_size(avg_file_size_per_hour), + } + except Exception as e: + if hasattr(self, "logger"): + self.logger.error(f"Error calculating disk space: {e}") + return None + + def _estimate_csv_size_per_hour(self) -> float: + """Estimate CSV file size per hour based on active datasets and variables""" + try: + # Get active datasets + active_dataset_ids = self.config_manager.active_datasets + if not active_dataset_ids: + return 0 + + # Get CSV directory to check existing files + records_dir = "records" + if not os.path.exists(records_dir): + # If no records directory exists yet, make a rough estimate + return self._rough_size_estimate() + + # Try to find actual CSV files to calculate average size + total_size = 0 + file_count = 0 + + # Look at today's directory if it exists + today_dir = os.path.join(records_dir, datetime.now().strftime("%d-%m-%Y")) + if os.path.exists(today_dir): + for filename in os.listdir(today_dir): + if filename.endswith(".csv"): + file_path = os.path.join(today_dir, filename) + if os.path.isfile(file_path): + total_size += os.path.getsize(file_path) + file_count += 1 + + # If we found files, calculate the average size per hour + if file_count > 0: + avg_size = total_size / file_count + # Multiply by active datasets (if we have data from fewer datasets) + active_count = len(active_dataset_ids) + if active_count > file_count: + avg_size = (avg_size / file_count) * active_count + return avg_size + else: + # Fallback to rough estimate + return self._rough_size_estimate() + + except Exception as e: + if hasattr(self, "logger"): + self.logger.error(f"Error estimating CSV size: {e}") + # Return a reasonable default (500KB per hour per dataset) + return 500 * 1024 * len(self.config_manager.active_datasets) + + def _rough_size_estimate(self) -> float: + """Make a rough estimate of CSV file size per hour""" + active_datasets = self.config_manager.active_datasets + total_vars = 0 + + # Count variables in active datasets + for dataset_id in active_datasets: + if dataset_id in self.config_manager.datasets: + total_vars += len( + self.config_manager.datasets[dataset_id].get("variables", {}) + ) + + # Estimate based on: + # - Each variable produces one value per sampling interval + # - Each value with timestamp takes about 20 bytes on average + # - Sample interval in seconds + sampling_interval = self.config_manager.sampling_interval + if sampling_interval <= 0: + sampling_interval = 0.1 # Default + + # Calculate bytes per hour + records_per_hour = 3600 / sampling_interval + bytes_per_hour = total_vars * records_per_hour * 20 + + # Add 10% overhead for CSV formatting + return bytes_per_hour * 1.1 + + def _format_size(self, size_bytes): + """Format file size in a human-readable format""" + if size_bytes == 0: + return "0B" + + size_names = ("B", "KB", "MB", "GB", "TB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + + return f"{s} {size_names[i]}" + + def get_datasets(self): + """Get all datasets information""" + return { + "datasets": self.config_manager.datasets, + "active_datasets": list(self.config_manager.active_datasets), + "current_dataset_id": self.config_manager.current_dataset_id, + } + + def get_dataset_variables(self, dataset_id: str): + """Get variables for a specific dataset""" + return self.config_manager.get_dataset_variables(dataset_id) + + def get_recent_events(self, limit: int = 50): + """Get recent events from the log""" + return self.event_logger.get_recent_events(limit) + + # Auto-recovery and Instance Management + def attempt_auto_recovery(self): + """Attempt to restore previous system state""" + return self.instance_manager.attempt_auto_recovery( + self.config_manager, self.plc_client, self.data_streamer + ) + + def release_instance_lock(self): + """Release instance lock""" + self.instance_manager.release_instance_lock() + + # Utility Methods + def _get_area_description( + self, area: str, db: int = None, offset: int = 0, bit: int = None + ) -> str: + """Get area description for logging""" + area_descriptions = { + "db": ( + f"DB{db}.DBX{offset}.{bit}" if bit is not None else f"DB{db}.{offset}" + ), + "mw": f"MW{offset}", + "m": f"M{offset}", + "pew": f"PEW{offset}", + "pe": f"PE{offset}", + "paw": f"PAW{offset}", + "pa": f"PA{offset}", + "e": f"E{offset}.{bit}", + "a": f"A{offset}.{bit}", + "mb": f"M{offset}.{bit}", + } + return area_descriptions.get(area.lower(), f"{area.upper()}{offset}") + + # Properties for backward compatibility + @property + def datasets(self): + """Get datasets (backward compatibility)""" + return self.config_manager.datasets + + @property + def active_datasets(self): + """Get active datasets (backward compatibility)""" + return self.config_manager.active_datasets + + @property + def current_dataset_id(self): + """Get current dataset ID (backward compatibility)""" + return self.config_manager.current_dataset_id + + @current_dataset_id.setter + def current_dataset_id(self, value): + """Set current dataset ID (backward compatibility)""" + self.config_manager.current_dataset_id = value + self.config_manager.save_datasets() + + @property + def connected(self): + """Get connection status (backward compatibility)""" + return self.plc_client.is_connected() + + @property + def streaming(self): + """Get streaming status (backward compatibility)""" + return self.data_streamer.is_streaming() + + def save_datasets(self): + """Save datasets (backward compatibility)""" + self.config_manager.save_datasets() diff --git a/core/streamer.py b/core/streamer.py index e69de29..f5a5576 100644 --- a/core/streamer.py +++ b/core/streamer.py @@ -0,0 +1,605 @@ +import json +import socket +import time +import threading +import csv +import os +import sys +from datetime import datetime +from typing import Dict, Any, Optional, Set +from pathlib import Path + + +def resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + # Not running in a bundle + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +class DataStreamer: + """Handles data streaming, CSV recording, and dataset management""" + + def __init__(self, config_manager, plc_client, event_logger, logger=None): + """Initialize data streamer""" + self.config_manager = config_manager + self.plc_client = plc_client + self.event_logger = event_logger + self.logger = logger + + # UDP streaming setup + self.udp_socket = None + + # Streaming state + self.streaming = False + + # Dataset streaming threads and files + self.dataset_threads = {} # dataset_id -> thread object + self.dataset_csv_files = {} # dataset_id -> file handle + self.dataset_csv_writers = {} # dataset_id -> csv writer + self.dataset_csv_hours = {} # dataset_id -> current hour + self.dataset_using_modification_files = {} # dataset_id -> bool + + def setup_udp_socket(self) -> bool: + """Setup UDP socket for PlotJuggler communication""" + try: + if self.udp_socket: + self.udp_socket.close() + + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + if self.logger: + self.logger.info( + f"UDP socket configured for {self.config_manager.udp_config['host']}:{self.config_manager.udp_config['port']}" + ) + return True + + except Exception as e: + if self.logger: + self.logger.error(f"Error configuring UDP socket: {e}") + return False + + def send_to_plotjuggler(self, data: Dict[str, Any]): + """Send data to PlotJuggler via UDP JSON""" + if not self.udp_socket: + return + + try: + message = {"timestamp": time.time(), "data": data} + json_message = json.dumps(message) + self.udp_socket.sendto( + json_message.encode("utf-8"), + ( + self.config_manager.udp_config["host"], + self.config_manager.udp_config["port"], + ), + ) + + except Exception as e: + if self.logger: + self.logger.error(f"Error sending data to PlotJuggler: {e}") + + def get_csv_directory_path(self) -> str: + """Get the directory path for current day's CSV files""" + now = datetime.now() + day_folder = now.strftime("%d-%m-%Y") + return os.path.join("records", day_folder) + + def ensure_csv_directory(self): + """Create CSV directory structure if it doesn't exist""" + directory = self.get_csv_directory_path() + Path(directory).mkdir(parents=True, exist_ok=True) + + def get_dataset_csv_file_path( + self, dataset_id: str, use_modification_timestamp: bool = False + ) -> str: + """Get the CSV file path for a specific dataset""" + if dataset_id not in self.config_manager.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + now = datetime.now() + prefix = self.config_manager.datasets[dataset_id]["prefix"] + + if use_modification_timestamp: + time_suffix = now.strftime("%H_%M_%S") + filename = f"{prefix}_{time_suffix}.csv" + else: + hour = now.strftime("%H") + filename = f"{prefix}_{hour}.csv" + + directory = self.get_csv_directory_path() + return os.path.join(directory, filename) + + def setup_dataset_csv_file(self, dataset_id: str): + """Setup CSV file for a specific dataset""" + current_hour = datetime.now().hour + + # If we're using a modification file and the hour hasn't changed, keep using it + if ( + self.dataset_using_modification_files.get(dataset_id, False) + and dataset_id in self.dataset_csv_hours + and self.dataset_csv_hours[dataset_id] == current_hour + and dataset_id in self.dataset_csv_files + ): + return + + # Check if we need to create a new file + if ( + dataset_id not in self.dataset_csv_hours + or self.dataset_csv_hours[dataset_id] != current_hour + or dataset_id not in self.dataset_csv_files + ): + # Close previous file if open + if dataset_id in self.dataset_csv_files: + self.dataset_csv_files[dataset_id].close() + + # Create directory and file for current hour + self.ensure_csv_directory() + csv_path = self.get_dataset_csv_file_path(dataset_id) + + # Check if file exists to determine if we need headers + file_exists = os.path.exists(csv_path) + + self.dataset_csv_files[dataset_id] = open( + csv_path, "a", newline="", encoding="utf-8" + ) + self.dataset_csv_writers[dataset_id] = csv.writer( + self.dataset_csv_files[dataset_id] + ) + self.dataset_csv_hours[dataset_id] = current_hour + + # Reset modification file flag when creating regular hourly file + self.dataset_using_modification_files[dataset_id] = False + + # Write headers if it's a new file + dataset_variables = self.config_manager.get_dataset_variables(dataset_id) + if not file_exists and dataset_variables: + headers = ["timestamp"] + list(dataset_variables.keys()) + self.dataset_csv_writers[dataset_id].writerow(headers) + self.dataset_csv_files[dataset_id].flush() + + if self.logger: + self.logger.info( + f"CSV file created for dataset '{self.config_manager.datasets[dataset_id]['name']}': {csv_path}" + ) + + def write_dataset_csv_data(self, dataset_id: str, data: Dict[str, Any]): + """Write data to CSV file for a specific dataset""" + if dataset_id not in self.config_manager.active_datasets: + return + + try: + self.setup_dataset_csv_file(dataset_id) + + if dataset_id in self.dataset_csv_writers: + # Create timestamp + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + # Create row with all variables for this dataset + dataset_variables = self.config_manager.get_dataset_variables( + dataset_id + ) + row = [timestamp] + for var_name in dataset_variables.keys(): + row.append(data.get(var_name, None)) + + self.dataset_csv_writers[dataset_id].writerow(row) + self.dataset_csv_files[dataset_id].flush() + + except Exception as e: + if self.logger: + self.logger.error( + f"Error writing CSV data for dataset {dataset_id}: {e}" + ) + + def create_new_dataset_csv_file_for_variable_modification(self, dataset_id: str): + """Create a new CSV file for a dataset when variables are modified during active recording""" + if dataset_id not in self.config_manager.active_datasets: + return + + try: + # Close current file if open + if dataset_id in self.dataset_csv_files: + self.dataset_csv_files[dataset_id].close() + del self.dataset_csv_files[dataset_id] + del self.dataset_csv_writers[dataset_id] + + if self.logger: + self.logger.info( + f"Closed previous CSV file for dataset '{self.config_manager.datasets[dataset_id]['name']}' due to variable modification" + ) + + # Create new file with modification timestamp + self.ensure_csv_directory() + csv_path = self.get_dataset_csv_file_path( + dataset_id, use_modification_timestamp=True + ) + + self.dataset_csv_files[dataset_id] = open( + csv_path, "w", newline="", encoding="utf-8" + ) + self.dataset_csv_writers[dataset_id] = csv.writer( + self.dataset_csv_files[dataset_id] + ) + + # Mark that we're using a modification file and set current hour + self.dataset_using_modification_files[dataset_id] = True + self.dataset_csv_hours[dataset_id] = datetime.now().hour + + # Write headers with new variable configuration + dataset_variables = self.config_manager.get_dataset_variables(dataset_id) + if dataset_variables: + headers = ["timestamp"] + list(dataset_variables.keys()) + self.dataset_csv_writers[dataset_id].writerow(headers) + self.dataset_csv_files[dataset_id].flush() + + dataset_name = self.config_manager.datasets[dataset_id]["name"] + if self.logger: + self.logger.info( + f"New CSV file created after variable modification for dataset '{dataset_name}': {csv_path}" + ) + + self.event_logger.log_event( + "info", + "dataset_csv_file_created", + f"New CSV file created after variable modification for dataset '{dataset_name}': {os.path.basename(csv_path)}", + { + "dataset_id": dataset_id, + "file_path": csv_path, + "variables_count": len(dataset_variables), + "reason": "variable_modification", + }, + ) + + except Exception as e: + dataset_name = self.config_manager.datasets.get(dataset_id, {}).get( + "name", dataset_id + ) + if self.logger: + self.logger.error( + f"Error creating new CSV file after variable modification for dataset '{dataset_name}': {e}" + ) + + self.event_logger.log_event( + "error", + "dataset_csv_error", + f"Failed to create new CSV file after variable modification for dataset '{dataset_name}': {str(e)}", + {"dataset_id": dataset_id, "error": str(e)}, + ) + + def read_dataset_variables( + self, dataset_id: str, variables: Dict[str, Any] + ) -> Dict[str, Any]: + """Read all variables for a specific dataset""" + data = {} + + for var_name, var_config in variables.items(): + try: + value = self.plc_client.read_variable(var_config) + data[var_name] = value + except Exception as e: + if self.logger: + self.logger.warning( + f"Error reading variable {var_name} in dataset {dataset_id}: {e}" + ) + data[var_name] = None + + return data + + def dataset_streaming_loop(self, dataset_id: str): + """Streaming loop for a specific dataset""" + dataset_info = self.config_manager.datasets[dataset_id] + interval = self.config_manager.get_dataset_sampling_interval(dataset_id) + + if self.logger: + self.logger.info( + f"Dataset '{dataset_info['name']}' streaming loop started (interval: {interval}s)" + ) + + consecutive_errors = 0 + max_consecutive_errors = 5 + + while ( + dataset_id in self.config_manager.active_datasets + and self.plc_client.is_connected() + ): + try: + start_time = time.time() + + # Read variables for this dataset + dataset_variables = self.config_manager.get_dataset_variables( + dataset_id + ) + all_data = self.read_dataset_variables(dataset_id, dataset_variables) + + if all_data: + consecutive_errors = 0 + + # Write to CSV (all variables) + self.write_dataset_csv_data(dataset_id, all_data) + + # Get filtered data for streaming - only variables that are in streaming_variables list AND have streaming=true + streaming_variables = dataset_info.get("streaming_variables", []) + dataset_vars_config = dataset_info.get("variables", {}) + streaming_data = { + name: value + for name, value in all_data.items() + if name in streaming_variables + and dataset_vars_config.get(name, {}).get("streaming", False) + } + + # Send filtered data to PlotJuggler + if streaming_data: + self.send_to_plotjuggler(streaming_data) + + # Log data + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + if self.logger: + self.logger.info( + f"[{timestamp}] Dataset '{dataset_info['name']}': CSV: {len(all_data)} vars, Streaming: {len(streaming_data)} vars" + ) + else: + consecutive_errors += 1 + if consecutive_errors >= max_consecutive_errors: + self.event_logger.log_event( + "error", + "dataset_streaming_error", + f"Multiple consecutive read failures for dataset '{dataset_info['name']}' ({consecutive_errors}). Stopping streaming.", + { + "dataset_id": dataset_id, + "consecutive_errors": consecutive_errors, + }, + ) + break + + # Maintain sampling interval + elapsed = time.time() - start_time + sleep_time = max(0, interval - elapsed) + time.sleep(sleep_time) + + except Exception as e: + consecutive_errors += 1 + self.event_logger.log_event( + "error", + "dataset_streaming_error", + f"Error in dataset '{dataset_info['name']}' streaming loop: {str(e)}", + { + "dataset_id": dataset_id, + "error": str(e), + "consecutive_errors": consecutive_errors, + }, + ) + + if consecutive_errors >= max_consecutive_errors: + self.event_logger.log_event( + "error", + "dataset_streaming_error", + f"Too many consecutive errors for dataset '{dataset_info['name']}'. Stopping streaming.", + { + "dataset_id": dataset_id, + "consecutive_errors": consecutive_errors, + }, + ) + break + + time.sleep(1) # Wait before retry + + # Clean up when exiting + self.stop_dataset_streaming(dataset_id) + if self.logger: + self.logger.info(f"Dataset '{dataset_info['name']}' streaming loop ended") + + def start_dataset_streaming(self, dataset_id: str): + """Start streaming thread for a specific dataset""" + if dataset_id not in self.config_manager.datasets: + return False + + if dataset_id in self.dataset_threads: + return True # Already running + + # Create and start thread for this dataset + thread = threading.Thread( + target=self.dataset_streaming_loop, args=(dataset_id,) + ) + thread.daemon = True + self.dataset_threads[dataset_id] = thread + thread.start() + + dataset_info = self.config_manager.datasets[dataset_id] + interval = self.config_manager.get_dataset_sampling_interval(dataset_id) + + if self.logger: + self.logger.info( + f"Started streaming for dataset '{dataset_info['name']}' (interval: {interval}s)" + ) + return True + + def stop_dataset_streaming(self, dataset_id: str): + """Stop streaming thread for a specific dataset""" + if dataset_id in self.dataset_threads: + # The thread will detect this and stop + thread = self.dataset_threads[dataset_id] + if thread.is_alive(): + thread.join(timeout=2) + del self.dataset_threads[dataset_id] + + # Close CSV file if open + if dataset_id in self.dataset_csv_files: + self.dataset_csv_files[dataset_id].close() + del self.dataset_csv_files[dataset_id] + del self.dataset_csv_writers[dataset_id] + del self.dataset_csv_hours[dataset_id] + # Reset modification file flag + self.dataset_using_modification_files.pop(dataset_id, None) + + dataset_info = self.config_manager.datasets.get(dataset_id, {}) + if self.logger: + self.logger.info( + f"Stopped streaming for dataset '{dataset_info.get('name', dataset_id)}'" + ) + + def activate_dataset(self, dataset_id: str): + """Activate a dataset for streaming and CSV recording""" + if dataset_id not in self.config_manager.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + if not self.plc_client.is_connected(): + raise RuntimeError("Cannot activate dataset: PLC not connected") + + self.config_manager.activate_dataset(dataset_id) + + # Start streaming thread for this dataset + self.start_dataset_streaming(dataset_id) + + dataset_info = self.config_manager.datasets[dataset_id] + self.event_logger.log_event( + "info", + "dataset_activated", + f"Dataset activated: {dataset_info['name']}", + { + "dataset_id": dataset_id, + "variables_count": len(dataset_info["variables"]), + "streaming_count": len(dataset_info["streaming_variables"]), + "prefix": dataset_info["prefix"], + }, + ) + + def deactivate_dataset(self, dataset_id: str): + """Deactivate a dataset""" + if dataset_id not in self.config_manager.datasets: + raise ValueError(f"Dataset '{dataset_id}' does not exist") + + self.config_manager.deactivate_dataset(dataset_id) + + # Stop streaming thread for this dataset + self.stop_dataset_streaming(dataset_id) + + dataset_info = self.config_manager.datasets[dataset_id] + self.event_logger.log_event( + "info", + "dataset_deactivated", + f"Dataset deactivated: {dataset_info['name']}", + {"dataset_id": dataset_id}, + ) + + def start_streaming(self) -> bool: + """Start data streaming - activates all datasets with variables""" + if not self.plc_client.is_connected(): + self.event_logger.log_event( + "error", "streaming_error", "Cannot start streaming: PLC not connected" + ) + return False + + if not self.config_manager.datasets: + self.event_logger.log_event( + "error", + "streaming_error", + "Cannot start streaming: No datasets configured", + ) + return False + + if not self.setup_udp_socket(): + self.event_logger.log_event( + "error", + "streaming_error", + "Cannot start streaming: UDP socket setup failed", + ) + return False + + # Activate all datasets that have variables + activated_count = 0 + for dataset_id, dataset_info in self.config_manager.datasets.items(): + if dataset_info.get("variables"): + try: + self.activate_dataset(dataset_id) + activated_count += 1 + except Exception as e: + if self.logger: + self.logger.warning( + f"Failed to activate dataset {dataset_id}: {e}" + ) + + if activated_count == 0: + self.event_logger.log_event( + "error", + "streaming_error", + "Cannot start streaming: No datasets with variables configured", + ) + return False + + self.streaming = True + self.config_manager.save_system_state( + connected=self.plc_client.is_connected(), + streaming=True, + active_datasets=self.config_manager.active_datasets, + ) + + self.event_logger.log_event( + "info", + "streaming_started", + f"Multi-dataset streaming started: {activated_count} datasets activated", + { + "activated_datasets": activated_count, + "total_datasets": len(self.config_manager.datasets), + "udp_host": self.config_manager.udp_config["host"], + "udp_port": self.config_manager.udp_config["port"], + }, + ) + return True + + def stop_streaming(self): + """Stop streaming - deactivates all active datasets""" + self.streaming = False + + # Stop all dataset streaming threads + active_datasets_copy = self.config_manager.active_datasets.copy() + for dataset_id in active_datasets_copy: + try: + self.deactivate_dataset(dataset_id) + except Exception as e: + if self.logger: + self.logger.warning(f"Error deactivating dataset {dataset_id}: {e}") + + # Close UDP socket + if self.udp_socket: + self.udp_socket.close() + self.udp_socket = None + + self.config_manager.save_system_state( + connected=self.plc_client.is_connected(), + streaming=False, + active_datasets=set(), + ) + + datasets_stopped = len(active_datasets_copy) + self.event_logger.log_event( + "info", + "streaming_stopped", + f"Multi-dataset streaming stopped: {datasets_stopped} datasets deactivated", + ) + + def is_streaming(self) -> bool: + """Check if streaming is active""" + return self.streaming + + def is_csv_recording(self) -> bool: + """Check if CSV recording is active""" + return bool(self.dataset_csv_files) and self.streaming + + def get_active_datasets(self) -> Set[str]: + """Get set of currently active dataset IDs""" + return self.config_manager.active_datasets.copy() + + def get_streaming_stats(self) -> Dict[str, Any]: + """Get streaming statistics""" + return { + "streaming": self.streaming, + "active_datasets": len(self.config_manager.active_datasets), + "active_threads": len(self.dataset_threads), + "open_csv_files": len(self.dataset_csv_files), + "udp_socket_active": self.udp_socket is not None, + } diff --git a/main.py b/main.py index 8d354de..7d148bd 100644 --- a/main.py +++ b/main.py @@ -23,13 +23,14 @@ from pathlib import Path import atexit import psutil import sys -from core.plc_client import PLCDataStreamer +from core import PLCDataStreamer app = Flask(__name__) app.secret_key = "plc_streamer_secret_key" + def resource_path(relative_path): - """ Get absolute path to resource, works for dev and for PyInstaller """ + """Get absolute path to resource, works for dev and for PyInstaller""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS @@ -57,6 +58,12 @@ def serve_image(filename): return send_from_directory(".images", filename) +@app.route("/static/") +def serve_static(filename): + """Serve static files (CSS, JS, etc.)""" + return send_from_directory("static", filename) + + @app.route("/") def index(): """Main page""" @@ -739,7 +746,7 @@ def get_events(): { "success": True, "events": events, - "total_events": len(streamer.events_log), + "total_events": len(streamer.event_logger.events_log), "showing": len(events), } ) diff --git a/static/css/pico.min.css b/static/css/pico.min.css new file mode 100644 index 0000000..e10ec26 --- /dev/null +++ b/static/css/pico.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS โœจ v2.1.1 (https://picocss.com) + * Copyright 2019-2025 - Licensed under MIT + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"โ€‹"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index d371c5e..16f5653 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,291 +1,168 @@ - + + PLC S7-315 Streamer & Logger + + + + + -
-
+ +
+ + + +
+ +
+ +

- ๐Ÿญ PLC S7-315 Streamer & Logger + -- PLC S7-31x Streamer & Logger

Real-time monitoring and streaming system

-
+ -
-
-
- ๐Ÿ”Œ PLC: Disconnected -
-
- ๐Ÿ“ก Streaming: Inactive -
-
- ๐Ÿ“Š Datasets: {{ status.datasets_count }} ({{ status.active_datasets_count }} active) -
-
- ๐Ÿ“‹ Variables: {{ status.total_variables }} -
-
- โฑ๏ธ Interval: {{ status.sampling_interval }}s -
-
- ๐Ÿ’พ CSV: Inactive +
+
+ ๐Ÿ”Œ PLC: Disconnected +
+
-
+
+ ๐Ÿ“ก Streaming: Inactive +
+ +
+
+
+ ๐Ÿ“Š Datasets: {{ status.datasets_count }} ({{ status.active_datasets_count }} active) +
+
+ ๐Ÿ“‹ Variables: {{ status.total_variables }} +
+
+ โฑ๏ธ Interval: {{ status.sampling_interval }}s +
+
+ ๐Ÿ’พ CSV: Inactive +
+
+ ๐Ÿ’ฝ Disk Space: Calculating... +
+
-
-

โš™๏ธ PLC S7-315 Configuration

+
+
โš™๏ธ PLC S7-315 Configuration
-
- +
-
- + +
-
- + +
+
- - - + + +
-
+ -
-

๐ŸŒ UDP Gateway Configuration (PlotJuggler)

+
+
๐ŸŒ UDP Gateway Configuration (PlotJuggler)
-
- +
-
- + +
-
- + +
+
- - + +
-
+ -
-

๐Ÿ“Š Dataset Management

+
+
๐Ÿ“Š Dataset Management
-
- +
-
- - + +
+ +
-
+
- + -
-

๐Ÿš€ Multi-Dataset Streaming Control

+
+
๐Ÿš€ Multi-Dataset Streaming Control

๐Ÿ“ก Streaming Mode: Only variables marked for streaming in active datasets are sent to PlotJuggler

@@ -909,15 +771,15 @@ global one

- - - + + +
-
+ -
-

๐Ÿ“‹ Application Events Log

+
+
๐Ÿ“‹ Application Events Log

๐Ÿ“ Event Tracking: Connection events, configuration changes, errors and system status

@@ -926,8 +788,8 @@
- - + +