From 1b8925b1bfc2e9b880e6e5b9fd7f284899089459 Mon Sep 17 00:00:00 2001 From: GlassOnTin <63980135+GlassOnTin@users.noreply.github.com> Date: Sun, 17 Nov 2024 01:18:47 +0000 Subject: [PATCH] Include missing files! --- build.sh | 2 +- debian/changelog | 6 + gecko_controller/controller.py | 10 +- gecko_controller/web/app.py | 461 ++++++++++++++++++--- gecko_controller/web/static/app.js | 471 +++++++++++++++++++++- gecko_controller/web/templates/index.html | 7 +- setup.py | 2 +- 7 files changed, 896 insertions(+), 63 deletions(-) diff --git a/build.sh b/build.sh index 5806614..c4187a3 100755 --- a/build.sh +++ b/build.sh @@ -35,4 +35,4 @@ echo "3. Start the web interface: sudo systemctl start gecko-web" echo "4. Enable web interface autostart: sudo systemctl enable gecko-web" echo "5. Check status: sudo systemctl status gecko-web" echo -echo "The web interface will be available at http://localhost:8080" \ No newline at end of file +echo "The web interface will be available at http://localhost/" \ No newline at end of file diff --git a/debian/changelog b/debian/changelog index 5b84822..c9d52f0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +gecko-controller (0.7.2) stable; urgency=medium + + * Include missing files! + + -- GlassOnTin <63980135+GlassOnTin@users.noreply.github.com> Sun, 17 Nov 2024 01:18:47 +0000 + gecko-controller (0.7.1) stable; urgency=medium * When light sensor is disconnected, use -1 for values diff --git a/gecko_controller/controller.py b/gecko_controller/controller.py index 5de39e7..bf79140 100755 --- a/gecko_controller/controller.py +++ b/gecko_controller/controller.py @@ -390,11 +390,11 @@ def log_readings(self, temp, humidity, uva, uvb, uvc, light_status, heat_status) self.logger.info( "", extra={ - 'temp': temp if temp is not None else -999, - 'humidity': humidity if humidity is not None else -999, - 'uva': uva if uva is not None else -999, - 'uvb': uvb if uvb is not None else -999, - 'uvc': uvc if uvc is not None else -999, + 'temp': temp if temp is not None else -1, + 'humidity': humidity if humidity is not None else -1, + 'uva': uva if uva is not None else -1, + 'uvb': uvb if uvb is not None else -1, + 'uvc': uvc if uvc is not None else -1, 'light': 1 if light_status else 0, 'heat': 1 if heat_status else 0 } diff --git a/gecko_controller/web/app.py b/gecko_controller/web/app.py index eea323b..b5e3553 100755 --- a/gecko_controller/web/app.py +++ b/gecko_controller/web/app.py @@ -4,14 +4,23 @@ import os import re import csv +import sys +import time +import shutil +import stat from datetime import datetime, timedelta +from pathlib import Path import importlib.util -import sys from typing import Dict, Any, Tuple -import time +from typing import Tuple, Optional +import logging def get_app_paths(): """Determine the correct paths for templates and static files""" + # In your main() function + os.makedirs('/etc/gecko-controller', mode=0o755, exist_ok=True) + os.makedirs('/var/log/gecko-controller', mode=0o755, exist_ok=True) + # Check if we're running from the development directory current_dir = os.path.dirname(os.path.abspath(__file__)) dev_template_dir = os.path.join(current_dir, 'templates') @@ -89,7 +98,7 @@ def log_startup_info(): class ConfigValidationError(Exception): """Custom exception for config validation failures""" - pass + pass def validate_config_module(module) -> bool: """Validate that all required fields are present and of correct type""" @@ -179,51 +188,257 @@ def load_config_module(config_path: str) -> Tuple[Any, bool]: print(f"Error loading config from {config_path}: {str(e)}") return None, False -def create_backup() -> bool: - """Create a backup of the current config file""" +class ConfigError(Exception): + """Base exception for configuration errors""" + pass + +class ConfigPermissionError(ConfigError): + """Raised when there are permission issues with config files""" + pass + +class ConfigBackupError(ConfigError): + """Raised when backup operations fail""" + pass + +def check_file_permissions(path: str) -> Tuple[bool, Optional[str]]: + """ + Check if we have read/write permissions for a file or its parent directory if it doesn't exist + Returns: (has_permission, error_message) + """ try: - shutil.copy2(CONFIG_FILE, BACKUP_FILE) + path_obj = Path(path) + + # If file exists, check its permissions + if path_obj.exists(): + # Check if we can read and write + readable = os.access(path, os.R_OK) + writable = os.access(path, os.W_OK) + if not (readable and writable): + return False, f"Insufficient permissions for {path}. Current permissions: {stat.filemode(path_obj.stat().st_mode)}" + + # If file doesn't exist, check parent directory permissions + else: + parent = path_obj.parent + if not parent.exists(): + return False, f"Parent directory {parent} does not exist" + if not os.access(parent, os.W_OK): + return False, f"Cannot write to parent directory {parent}" + + return True, None + + except Exception as e: + return False, f"Error checking permissions: {str(e)}" + +def create_backup(config_file: str, backup_file: str, logger: logging.Logger) -> bool: + """ + Create a backup of the config file with proper permission checking + + Args: + config_file: Path to the main config file + backup_file: Path to the backup location + logger: Logger instance for recording operations + + Returns: + bool: True if backup was successful + + Raises: + ConfigPermissionError: If permission issues prevent backup + ConfigBackupError: If backup fails for other reasons + """ + try: + # Check if source config exists + if not os.path.exists(config_file): + raise ConfigBackupError(f"Config file {config_file} does not exist") + + # Check permissions on source and destination + src_ok, src_error = check_file_permissions(config_file) + if not src_ok: + raise ConfigPermissionError(f"Source file permission error: {src_error}") + + dst_ok, dst_error = check_file_permissions(backup_file) + if not dst_ok: + raise ConfigPermissionError(f"Destination file permission error: {dst_error}") + + # Create parent directory for backup if it doesn't exist + backup_dir = os.path.dirname(backup_file) + if not os.path.exists(backup_dir): + try: + os.makedirs(backup_dir, mode=0o755, exist_ok=True) + except Exception as e: + raise ConfigPermissionError(f"Failed to create backup directory: {str(e)}") + + # Perform the backup + shutil.copy2(config_file, backup_file) + + # Verify the backup + if not os.path.exists(backup_file): + raise ConfigBackupError("Backup file was not created") + + # Ensure backup is readable + if not os.access(backup_file, os.R_OK): + raise ConfigPermissionError("Created backup file is not readable") + + logger.info(f"Successfully created backup at {backup_file}") return True + + except (ConfigPermissionError, ConfigBackupError) as e: + logger.error(str(e)) + raise except Exception as e: - print(f"Failed to create backup: {str(e)}") - return False + msg = f"Unexpected error during backup: {str(e)}" + logger.error(msg) + raise ConfigBackupError(msg) -def restore_backup() -> bool: - """Restore config from backup file""" +def restore_backup(config_file: str, backup_file: str, logger: logging.Logger) -> bool: + """ + Restore config from backup with proper permission checking + + Args: + config_file: Path to the main config file + backup_file: Path to the backup location + logger: Logger instance for recording operations + + Returns: + bool: True if restoration was successful + + Raises: + ConfigPermissionError: If permission issues prevent restoration + ConfigBackupError: If restoration fails for other reasons + """ try: - if os.path.exists(BACKUP_FILE): - shutil.copy2(BACKUP_FILE, CONFIG_FILE) - return True - return False + # Check if backup exists + if not os.path.exists(backup_file): + raise ConfigBackupError("No backup file found") + + # Check permissions + src_ok, src_error = check_file_permissions(backup_file) + if not src_ok: + raise ConfigPermissionError(f"Backup file permission error: {src_error}") + + dst_ok, dst_error = check_file_permissions(config_file) + if not dst_ok: + raise ConfigPermissionError(f"Config file permission error: {dst_error}") + + # Perform the restoration + shutil.copy2(backup_file, config_file) + + # Verify the restoration + if not os.path.exists(config_file): + raise ConfigBackupError("Config file was not restored") + + # Ensure restored file is readable + if not os.access(config_file, os.R_OK): + raise ConfigPermissionError("Restored config file is not readable") + + logger.info(f"Successfully restored config from {backup_file}") + return True + + except (ConfigPermissionError, ConfigBackupError) as e: + logger.error(str(e)) + raise except Exception as e: - print(f"Failed to restore backup: {str(e)}") - return False + msg = f"Unexpected error during restoration: {str(e)}" + logger.error(msg) + raise ConfigBackupError(msg) -def write_config(config: Dict[str, Any]) -> bool: - """Write config back to file with backup and validation""" - # Create backup first - if not create_backup(): - raise ConfigValidationError("Failed to create backup, aborting config update") +def write_config(config: Dict[str, Any], logger: Optional[logging.Logger] = None) -> bool: + """ + Write config to file with atomic operation and validation + + Args: + config: Dictionary containing configuration values + logger: Optional logger instance for recording operations + + Returns: + bool: True if write was successful + + Raises: + ConfigPermissionError: If permission issues prevent writing + ConfigValidationError: If the new config is invalid + """ + # Use a null logger if none provided + if logger is None: + logger = logging.getLogger('null') + logger.addHandler(logging.NullHandler()) try: - # Generate the new config file content + # First validate the new config values before writing anything + for field, (expected_type, validator) in REQUIRED_CONFIG.items(): + if field not in config: + raise ConfigValidationError(f"Missing required field: {field}") + + value = config[field] + + # Perform type checking + if expected_type == 'int': + if not isinstance(value, int): + raise ConfigValidationError(f"Field {field} must be an integer") + elif expected_type == 'float': + if not isinstance(value, (int, float)): + raise ConfigValidationError(f"Field {field} must be a number") + value = float(value) + elif expected_type == 'time_str': + if not isinstance(value, str): + raise ConfigValidationError(f"Field {field} must be a time string (HH:MM)") + elif expected_type == 'dict': + if not isinstance(value, dict): + raise ConfigValidationError(f"Field {field} must be a dictionary") + + # Perform value validation + if not validator(value): + raise ConfigValidationError(f"Invalid value for {field}: {value}") + + # Check special case: DAY_TEMP > MIN_TEMP + if config['DAY_TEMP'] <= config['MIN_TEMP']: + raise ConfigValidationError("DAY_TEMP must be greater than MIN_TEMP") + + # Check permissions on config file + ok, error = check_file_permissions(CONFIG_FILE) + if not ok: + raise ConfigPermissionError(f"Config file permission error: {error}") + + # Generate the new config content content = create_config_content(config) - # Write the new config file - with open(CONFIG_FILE, 'w') as f: - f.write(content) + # Write to a temporary file first (atomic operation) + temp_file = f"{CONFIG_FILE}.tmp" + try: + with open(temp_file, 'w') as f: + f.write(content) + # Ensure content is written to disk + f.flush() + os.fsync(f.fileno()) + + # Set permissions on temp file to match intended config file + os.chmod(temp_file, 0o644) + + # Atomically replace the old config with the new one + os.replace(temp_file, CONFIG_FILE) - # Validate the new config + except Exception as e: + # Clean up temp file if it exists + if os.path.exists(temp_file): + try: + os.unlink(temp_file) + except: + pass + raise ConfigError(f"Failed to write config: {str(e)}") + + # Validate the newly written config module, success = load_config_module(CONFIG_FILE) - if not success: - raise ConfigValidationError("New config file failed validation") + if not success or module is None: + raise ConfigValidationError("New config file failed validation after writing") + logger.info("Successfully wrote and validated new configuration") return True + except (ConfigValidationError, ConfigPermissionError) as e: + logger.error(str(e)) + raise except Exception as e: - # Restore backup if anything goes wrong - restore_backup() - raise ConfigValidationError(f"Failed to update config: {str(e)}") + msg = f"Unexpected error writing config: {str(e)}" + logger.error(msg) + raise ConfigError(msg) @app.route('/api/config', methods=['POST']) def update_config(): @@ -239,24 +454,53 @@ def update_config(): 'message': f'Missing required fields: {", ".join(missing_fields)}' }), 400 - # Write and validate new config - if write_config(new_config): - # Wait briefly to ensure file is written - time.sleep(0.5) - # Restart the gecko-controller service to apply changes - os.system('systemctl restart gecko-controller') - return jsonify({'status': 'success'}) - else: + # Create backup first + try: + create_backup(CONFIG_FILE, BACKUP_FILE, app.logger) + except ConfigPermissionError as e: + return jsonify({ + 'status': 'error', + 'message': f'Permission error: {str(e)}' + }), 403 + except ConfigBackupError as e: return jsonify({ 'status': 'error', - 'message': 'Failed to update configuration' + 'message': f'Backup error: {str(e)}' }), 500 - except ConfigValidationError as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 400 + # Write and validate new config + try: + if write_config(new_config, app.logger): + time.sleep(0.5) + os.system('systemctl restart gecko-controller') + return jsonify({'status': 'success'}) + except ConfigValidationError as e: + # Try to restore from backup + try: + restore_backup(CONFIG_FILE, BACKUP_FILE, app.logger) + return jsonify({ + 'status': 'error', + 'message': f'Config validation failed and restored from backup: {str(e)}' + }), 400 + except (ConfigPermissionError, ConfigBackupError) as restore_error: + return jsonify({ + 'status': 'error', + 'message': f'Config validation failed and backup restoration also failed: {str(restore_error)}' + }), 500 + except Exception as e: + # Try to restore from backup + try: + restore_backup(CONFIG_FILE, BACKUP_FILE, app.logger) + return jsonify({ + 'status': 'error', + 'message': f'Config update failed and restored from backup: {str(e)}' + }), 500 + except (ConfigPermissionError, ConfigBackupError) as restore_error: + return jsonify({ + 'status': 'error', + 'message': f'Config update failed and backup restoration also failed: {str(restore_error)}' + }), 500 + except Exception as e: return jsonify({ 'status': 'error', @@ -264,17 +508,128 @@ def update_config(): }), 500 @app.route('/api/config/restore', methods=['POST']) -def restore_config(): - """Endpoint to restore config from backup""" +def restore_config_endpoint(): + """ + Endpoint to restore config from backup with comprehensive validation and error handling + + Returns: + JSON response indicating success or detailed error information + HTTP status codes: + - 200: Success + - 403: Permission denied + - 404: Backup not found + - 500: Server error or validation failure + """ try: - if restore_backup(): - time.sleep(0.5) # Wait briefly to ensure file is restored - os.system('systemctl restart gecko-controller') - return jsonify({'status': 'success'}) + # First verify backup exists and is readable + if not os.path.exists(BACKUP_FILE): + return jsonify({ + 'status': 'error', + 'message': 'No backup file found' + }), 404 + + # Pre-validate backup content before attempting restore + module, success = load_config_module(BACKUP_FILE) + if not success or module is None: + return jsonify({ + 'status': 'error', + 'message': 'Backup file failed validation, cannot restore' + }), 500 + + try: + # Attempt to restore using the improved restore_backup function + if restore_backup(CONFIG_FILE, BACKUP_FILE, app.logger): + # Double check the restored config + restored_module, restored_success = load_config_module(CONFIG_FILE) + if not restored_success or restored_module is None: + # If validation fails after restore, try to recover + app.logger.error("Restored config failed validation") + return jsonify({ + 'status': 'error', + 'message': 'Restored config failed validation' + }), 500 + + # Brief pause to ensure file operations are complete + time.sleep(0.5) + + try: + # Attempt to restart the service + result = os.system('systemctl restart gecko-controller') + if result != 0: + app.logger.warning("Service restart failed but config was restored") + return jsonify({ + 'status': 'partial', + 'message': 'Config restored but service restart failed' + }), 500 + + return jsonify({ + 'status': 'success', + 'message': 'Configuration restored and service restarted' + }) + + except Exception as service_error: + app.logger.error(f"Service restart error: {str(service_error)}") + return jsonify({ + 'status': 'partial', + 'message': 'Config restored but service restart failed' + }), 500 + + except ConfigPermissionError as e: + app.logger.error(f"Permission error during restore: {str(e)}") + return jsonify({ + 'status': 'error', + 'message': f'Permission denied: {str(e)}' + }), 403 + + except ConfigBackupError as e: + app.logger.error(f"Backup error during restore: {str(e)}") + return jsonify({ + 'status': 'error', + 'message': f'Backup error: {str(e)}' + }), 500 + + except Exception as e: + app.logger.error(f"Unexpected error in restore endpoint: {str(e)}") return jsonify({ 'status': 'error', - 'message': 'No backup file found' - }), 404 + 'message': f'Unexpected error: {str(e)}' + }), 500 + +def get_service_status(): + """Helper function to check gecko-controller service status""" + try: + result = os.system('systemctl is-active --quiet gecko-controller') + return result == 0 + except: + return False + +# Optional: Add a status endpoint to check service health +@app.route('/api/status', methods=['GET']) +def get_status(): + """Get current service and config status""" + try: + config_exists = os.path.exists(CONFIG_FILE) + backup_exists = os.path.exists(BACKUP_FILE) + service_running = get_service_status() + + # Try to validate current config + config_valid = False + if config_exists: + module, success = load_config_module(CONFIG_FILE) + config_valid = success and module is not None + + return jsonify({ + 'status': 'ok', + 'details': { + 'config_exists': config_exists, + 'config_valid': config_valid, + 'backup_exists': backup_exists, + 'service_running': service_running, + 'config_path': CONFIG_FILE, + 'backup_path': BACKUP_FILE + } + }) + except Exception as e: return jsonify({ 'status': 'error', diff --git a/gecko_controller/web/static/app.js b/gecko_controller/web/static/app.js index bb5db58..476bbd8 100644 --- a/gecko_controller/web/static/app.js +++ b/gecko_controller/web/static/app.js @@ -2,10 +2,401 @@ let tempHumidityChart = null; let uvChart = null; +console.log('Script loaded'); + +// Create status monitor container element and styling +const statusStyles = ` +.status-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; +} + +.status-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: white; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: transform 0.2s ease; +} + +.status-icon:hover { + transform: scale(1.05); +} + +.status-icon.running::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background: #4CAF50; + border: 2px solid white; + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} + +.status-icon.stopped::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background: #f44336; + border: 2px solid white; + box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.2); +} + +.status-details-card { + position: absolute; + top: 50px; + right: 0; + width: 300px; + background: white; + border-radius: 8px; + padding: 15px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + display: none; + animation: fadeIn 0.2s ease; +} + +.status-details-card.visible { + display: block; +} + +.status-details-card.running { + border-left: 4px solid #4CAF50; +} + +.status-details-card.stopped { + border-left: 4px solid #f44336; +} + +.status-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-weight: bold; +} + +.status-details { + font-size: 0.9em; + color: #666; + margin-top: 8px; +} + +.status-timestamp { + font-size: 0.8em; + color: #999; + margin-top: 5px; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Add pulse animation for initial attention */ +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.1); } + 100% { transform: scale(1); } +} + +.status-icon.pulse { + animation: pulse 2s ease infinite; +} +.status-controls { + position: absolute; + top: 5px; + left: -95px; + display: flex; + flex-direction: row; + gap: 10px; +} + +.control-indicator { + width: 35px; + height: 35px; + border-radius: 50%; + background: white; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.control-indicator i { + font-size: 18px; + color: #666; +} + +.control-indicator::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + bottom: 2px; + right: 2px; + border: 2px solid white; +} + +.control-indicator.active::after { + background: #4CAF50; + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} + +.control-indicator.inactive::after { + background: #666; + box-shadow: 0 0 0 2px rgba(102, 102, 102, 0.2); +} + +@keyframes fade { + 0% { opacity: 0.4; } + 50% { opacity: 1; } + 100% { opacity: 0.4; } +} + +.control-indicator.active i { + color: #ff9800; + animation: fade 2s infinite; +} + +.control-indicator .tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + left: -100px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.control-indicator:hover .tooltip { + opacity: 1; +} +`; + +// Add styles to document +const styleSheet = document.createElement("style"); +styleSheet.textContent = statusStyles; +document.head.appendChild(styleSheet); + +// Status monitor functionality +let statusCheckInterval = null; + +// Add these SVG icons for light and heat +const ICONS = { + light: ` + + + + + + + + + + `, + heat: ` + + ` +}; + +// Update the status monitoring functions +let lastLightStatus = false; +let lastHeatStatus = false; + +async function updateControlStatus() { + try { + const response = await fetch('/api/logs'); + if (!response.ok) throw new Error('Failed to fetch control status'); + const data = await response.json(); + + // Get the most recent status + const lastIndex = data.light.length - 1; + if (lastIndex >= 0) { + lastLightStatus = Boolean(data.light[lastIndex]); + lastHeatStatus = Boolean(data.heat[lastIndex]); + + updateControlIndicators(); + } + } catch (error) { + console.error('Error updating control status:', error); + } +} + +function updateControlIndicators() { + const controlsContainer = document.querySelector('.status-controls'); + if (!controlsContainer) return; + + // Update light indicator + const lightIndicator = controlsContainer.querySelector('.control-indicator.light-indicator'); + if (lightIndicator) { + lightIndicator.className = `control-indicator light ${lastLightStatus ? 'active' : 'inactive'}`; + lightIndicator.querySelector('.tooltip').textContent = `Light: ${lastLightStatus ? 'ON' : 'OFF'}`; + } + + // Update heat indicator + const heatIndicator = controlsContainer.querySelector('.control-indicator.heat-indicator'); + if (heatIndicator) { + heatIndicator.className = `control-indicator heat ${lastHeatStatus ? 'active' : 'inactive'}`; + heatIndicator.querySelector('.tooltip').textContent = `Heat: ${lastHeatStatus ? 'ON' : 'OFF'}`; + } +} + +// Single, unified initializeStatusMonitor function +function initializeStatusMonitor() { + let container = document.getElementById('statusContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'statusContainer'; + container.className = 'status-container'; + + // Create controls container + const controlsContainer = document.createElement('div'); + controlsContainer.className = 'status-controls'; + + // Add light indicator + const lightIndicator = document.createElement('div'); + lightIndicator.className = 'control-indicator light-indicator inactive'; + lightIndicator.innerHTML = ` + ${ICONS.light} + Light: OFF + `; + controlsContainer.appendChild(lightIndicator); + + // Add heat indicator + const heatIndicator = document.createElement('div'); + heatIndicator.className = 'control-indicator heat-indicator inactive'; + heatIndicator.innerHTML = ` + ${ICONS.heat} + Heat: OFF + `; + controlsContainer.appendChild(heatIndicator); + + // Create main status icon + const icon = document.createElement('div'); + icon.className = 'status-icon stopped pulse'; + + // Create details card + const detailsCard = document.createElement('div'); + detailsCard.className = 'status-details-card'; + + // Add click handlers + icon.addEventListener('click', () => { + detailsCard.classList.toggle('visible'); + icon.classList.remove('pulse'); + }); + + document.addEventListener('click', (event) => { + if (!container.contains(event.target)) { + detailsCard.classList.remove('visible'); + } + }); + + // Add all elements to container + container.appendChild(controlsContainer); + container.appendChild(icon); + container.appendChild(detailsCard); + document.body.appendChild(container); + } + + // Start status checking + updateStatus(); + updateControlStatus(); + + if (statusCheckInterval) { + clearInterval(statusCheckInterval); + } + statusCheckInterval = setInterval(() => { + updateStatus(); + updateControlStatus(); + }, 30000); +} + +async function updateStatus() { + const container = document.getElementById('statusContainer'); + if (!container) return; + + try { + const response = await fetch('/api/status'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + + const timestamp = new Date().toLocaleTimeString(); + const status = data.details.service_running ? 'running' : 'stopped'; + + // Update the icon state + const icon = container.querySelector('.status-icon'); + icon.className = `status-icon ${status}`; + + // Update the details card + const detailsCard = container.querySelector('.status-details-card'); + if (detailsCard) { + detailsCard.className = `status-details-card ${status}${detailsCard.classList.contains('visible') ? ' visible' : ''}`; + detailsCard.innerHTML = ` +
+ Service: ${status === 'running' ? 'Running' : 'Stopped'} +
+
+
Configuration: ${data.details.config_valid ? 'Valid' : 'Invalid'}
+
Backup Available: ${data.details.backup_exists ? 'Yes' : 'No'}
+
+
+ Last Updated: ${timestamp} +
+ `; + } + } catch (error) { + const icon = container.querySelector('.status-icon'); + icon.className = 'status-icon stopped'; + + const detailsCard = container.querySelector('.status-details-card'); + if (detailsCard) { + detailsCard.className = `status-details-card stopped${detailsCard.classList.contains('visible') ? ' visible' : ''}`; + detailsCard.innerHTML = ` +
+ Status Check Failed +
+
+ ${error.message} +
+
+ ${new Date().toLocaleTimeString()} +
+ `; + } + } +} + +async function mountStatusMonitor() { + const container = document.getElementById('statusContainer'); + const StatusMonitor = await import('./StatusMonitor.jsx'); + ReactDOM.render(React.createElement(StatusMonitor.default), container); +} + // Wait for everything to be ready document.addEventListener('DOMContentLoaded', function() { + // Initialize status monitor + initializeStatusMonitor(); + // Initialize charts try { + // Test Chart.js availability if (typeof Chart === 'undefined') { console.error('Chart.js not loaded'); @@ -173,23 +564,99 @@ async function updateCharts() { uvChart.data.datasets[2].data = data.uvc; uvChart.update(); } + + // Also check service status + const statusResponse = await fetch('/api/status'); + if (statusResponse.ok) { + const statusData = await statusResponse.json(); + } } catch (error) { console.error('Error in updateCharts:', error); } } +// Function to format I2C address +function formatI2CAddress(input) { + let value; + // If input is an element, get its value, otherwise treat input as the value + if (input instanceof HTMLElement) { + value = input.value; + } else { + value = String(input); + } + + // Remove any existing "0x" prefix and spaces + value = value.replace(/^0x/i, '').replace(/\s/g, ''); + + // Remove any non-hex characters + value = value.replace(/[^0-9A-Fa-f]/g, ''); + + // Limit to 2 characters + value = value.slice(0, 2); + + // Return formatted value or set input value if element was passed + const formattedValue = value ? `0x${value.toLowerCase()}` : ''; + if (input instanceof HTMLElement) { + input.value = formattedValue; + } + return formattedValue; +} +// Initialize when page loads +document.addEventListener('DOMContentLoaded', function() { + const i2cInput = document.getElementById('DISPLAY_ADDRESS'); + if (i2cInput) { + formatI2CAddress(i2cInput); + } +}); + async function loadConfig() { try { const response = await fetch('/api/config'); const config = await response.json(); console.log('Loaded config:', config); - // ... rest of your loadConfig code ... + + // Temperature settings + document.getElementById('MIN_TEMP').value = config.MIN_TEMP; + document.getElementById('DAY_TEMP').value = config.DAY_TEMP; + document.getElementById('TEMP_TOLERANCE').value = config.TEMP_TOLERANCE; + + // Lighting schedule + document.getElementById('LIGHT_ON_TIME').value = config.LIGHT_ON_TIME; + document.getElementById('LIGHT_OFF_TIME').value = config.LIGHT_OFF_TIME; + + // UV Thresholds + document.getElementById('UVA_THRESHOLDS_low').value = config.UVA_THRESHOLDS.low; + document.getElementById('UVA_THRESHOLDS_high').value = config.UVA_THRESHOLDS.high; + document.getElementById('UVB_THRESHOLDS_low').value = config.UVB_THRESHOLDS.low; + document.getElementById('UVB_THRESHOLDS_high').value = config.UVB_THRESHOLDS.high; + + // UV Sensor Configuration + document.getElementById('SENSOR_HEIGHT').value = config.SENSOR_HEIGHT; + document.getElementById('LAMP_DIST_FROM_BACK').value = config.LAMP_DIST_FROM_BACK; + document.getElementById('ENCLOSURE_HEIGHT').value = config.ENCLOSURE_HEIGHT; + document.getElementById('SENSOR_ANGLE').value = config.SENSOR_ANGLE; + + // Hardware Configuration + const displayAddressInput = document.getElementById('DISPLAY_ADDRESS'); + displayAddressInput.value = formatI2CAddress(config.DISPLAY_ADDRESS); + document.getElementById('DISPLAY_RESET').value = config.DISPLAY_RESET; + document.getElementById('LIGHT_RELAY').value = config.LIGHT_RELAY; + document.getElementById('HEAT_RELAY').value = config.HEAT_RELAY; + } catch (error) { console.error('Error loading config:', error); + const status = document.getElementById('status'); + status.textContent = 'Error loading configuration: ' + error.message; + status.className = 'status error'; + status.style.display = 'block'; + setTimeout(() => { + status.style.display = 'none'; + }, 3000); } } - +// Call loadConfig when the page loads +document.addEventListener('DOMContentLoaded', loadConfig); // Handle form submission document.getElementById('configForm').addEventListener('submit', async (e) => { diff --git a/gecko_controller/web/templates/index.html b/gecko_controller/web/templates/index.html index dc42f55..e482803 100644 --- a/gecko_controller/web/templates/index.html +++ b/gecko_controller/web/templates/index.html @@ -190,7 +190,12 @@

UV Sensor Configuration

Hardware Configuration

- + diff --git a/setup.py b/setup.py index f934673..6b009f7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="gecko_controller", - version="0.7.1", + version="0.7.2", packages=find_packages(), package_data={ 'gecko_controller': [