diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1eb95e4..8c5d354 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -175,7 +175,8 @@ jobs: push: true tags: ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:${{ steps.docker_tags.outputs.tag }} cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + # CHANGED: mode=min speeds up export by only caching final layers, avoiding massive I/O + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=min platforms: linux/amd64,linux/arm64 - name: Move cache @@ -231,7 +232,12 @@ jobs: NEW_TAG="${{ needs.build.outputs.tag }}" # Get all matching tags, exclude the new tag if it exists, and get the latest - PREVIOUS_TAG=$(git tag -l "${TAG_PATTERN}" | grep -v "^${NEW_TAG}$" | sort -V | tail -n1) + # For main branch, also exclude dev tags (v-dev-*) to prevent incorrect comparisons + if [ "$BRANCH_NAME" = "main" ]; then + PREVIOUS_TAG=$(git tag -l "${TAG_PATTERN}" | grep -v "^${NEW_TAG}$" | grep -v "^v-.*-" | sort -V | tail -n1) + else + PREVIOUS_TAG=$(git tag -l "${TAG_PATTERN}" | grep -v "^${NEW_TAG}$" | sort -V | tail -n1) + fi if [ -z "$PREVIOUS_TAG" ]; then echo "No previous tag found, showing last 20 commits" @@ -491,7 +497,7 @@ jobs: const summary = summaryBody.join('\n').trim(); // Create enhanced PR body with AI summary - const marker = ''; + const marker = ''; const summarySection = `${marker}\n\n${summary}\n\n---\n📊 Analyzed **${commitCount}** commit(s) | 🕐 Updated: ${timestamp} | Generated by GitHub Actions\n\n---\n\n`; // Get current PR details @@ -541,5 +547,4 @@ jobs: body: newBody }); - console.log(`✅ Updated PR #${context.issue.number}`); - + console.log(`✅ Updated PR #${context.issue.number}`); \ No newline at end of file diff --git a/.github/workflows/snd.yml b/.github/workflows/snd.yml index 1a14cc5..88f7829 100644 --- a/.github/workflows/snd.yml +++ b/.github/workflows/snd.yml @@ -59,7 +59,8 @@ jobs: push: true tags: ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:snd cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + # OPTIMIZATION: Changed mode=max to mode=min to speed up export on self-hosted runners + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=min platforms: linux/amd64,linux/arm64 - name: Move cache @@ -72,4 +73,4 @@ jobs: run: | docker pull ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:snd IMAGE_SIZE=$(docker images --format "{{.Size}}" ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:snd | head -n1) - echo "Docker image size: $IMAGE_SIZE" + echo "Docker image size: $IMAGE_SIZE" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e736cba --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +docker-compose.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9996a85..8bec088 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,8 @@ COPY requirements.txt requirements-arm64.txt ./ # Install dependencies based on architecture ARG TARGETPLATFORM RUN pip install --no-cache-dir --upgrade pip && \ - if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + if [ "$TARGETPLATFORM" = "linux/arm64" ]; \ + then \ pip install --no-cache-dir -r requirements-arm64.txt; \ else \ pip install --no-cache-dir -r requirements.txt; \ @@ -38,23 +39,34 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN useradd -m -u 1000 appuser # Copy Python packages and binaries from builder +# NOTE: We generally do NOT need to chown site-packages; read-only access is sufficient for the app. COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY --from=builder /usr/local/bin /usr/local/bin -# Copy application code (this layer changes frequently, so it's last) -COPY convert.py detect.py alpaca_safety_monitor.py start_services.sh ./ +# Copy application code with ownership set explicitly during copy +# This avoids the expensive "chown -R" layer later +COPY --chown=appuser:appuser convert.py detect.py main.py start_services.sh ./ +COPY --chown=appuser:appuser alpaca/ ./alpaca/ +COPY --chown=appuser:appuser templates/ ./templates/ -# Fix line endings and make the startup script executable and set ownership +# Fix line endings and make the startup script executable +# We only chown the specific script we modified if necessary, not the whole /app recursively RUN dos2unix start_services.sh && \ chmod +x start_services.sh && \ - chown -R appuser:appuser /app + chown appuser:appuser start_services.sh + +# Create configuration directory +RUN mkdir -p /config && chown appuser:appuser /config # Switch to non-root user USER appuser +# Set configuration file path +ENV CONFIG_FILE=/config/alpaca_config.json + # FIX: Add healthcheck to ensure container restarts if Python process hangs HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:${ALPACA_PORT:-11111}/api/v1/safetymonitor/${ALPACA_DEVICE_NUMBER:-0}/connected || exit 1 # Run the startup script to launch unified service -CMD ["./start_services.sh"] +CMD ["./start_services.sh"] \ No newline at end of file diff --git a/alpaca/__init__.py b/alpaca/__init__.py new file mode 100644 index 0000000..c8631a9 --- /dev/null +++ b/alpaca/__init__.py @@ -0,0 +1,58 @@ +""" +Alpaca Package - ASCOM Alpaca SafetyMonitor +""" +import os +from flask import Flask +from flask_cors import CORS +from .config import AlpacaConfig +from .device import AlpacaSafetyMonitor +from .routes.api import api_bp, init_api +from .routes.management import mgmt_bp, init_mgmt +from detect import Config as DetectConfig + +def create_app(): + """Flask application factory""" + app = Flask(__name__, template_folder='../templates') + CORS(app) + + # 1. Initialize configuration from Environment Variables (Base Config) + # This establishes the defaults if no file exists. + alpaca_cfg = AlpacaConfig( + port=int(os.getenv('ALPACA_PORT', '11111')), + device_number=int(os.getenv('ALPACA_DEVICE_NUMBER', '0')), + detection_interval=int(os.getenv('DETECT_INTERVAL', '30')), + update_interval=int(os.getenv('ALPACA_UPDATE_INTERVAL', '30')) + ) + + # 2. Load from file and override environment settings if file exists + # This ensures user settings (in file) take precedence over preconfigured env vars, + # but Env vars are still preserved if the file doesn't specify them. + file_settings = AlpacaConfig.load_settings_from_file() + if file_settings: + # Update our base config with ONLY the values explicitly in the file + for key, value in file_settings.items(): + setattr(alpaca_cfg, key, value) + + # 3. Save the final configuration to ensure the file exists and is current + alpaca_cfg.save_to_file() + + detect_cfg = DetectConfig.from_env() + + # Sync detect_config interval with alpaca_config + detect_cfg.detect_interval = alpaca_cfg.detection_interval + + # Initialize Core Device + safety_monitor = AlpacaSafetyMonitor(alpaca_cfg, detect_cfg) + + # Initialize Routes with Monitor Instance + init_api(safety_monitor) + init_mgmt(safety_monitor) + + # Register Blueprints + app.register_blueprint(api_bp, url_prefix='/api') + app.register_blueprint(mgmt_bp, url_prefix='') + + # Store monitor for access in main.py + app.safety_monitor = safety_monitor + + return app, safety_monitor diff --git a/alpaca/config.py b/alpaca/config.py new file mode 100644 index 0000000..7c2e119 --- /dev/null +++ b/alpaca/config.py @@ -0,0 +1,117 @@ +""" +Configuration management for the Alpaca server +""" +import os +import json +import logging +from dataclasses import dataclass, asdict, field +from typing import Dict +from zoneinfo import ZoneInfo +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Global helper +def get_current_time(timezone_str: str = 'UTC') -> datetime: + """Get current time in specified timezone""" + try: + tz = ZoneInfo(timezone_str) + return datetime.now(tz) + except Exception: + # Fallback to UTC if timezone is invalid + return datetime.now(ZoneInfo('UTC')) + +# ASCOM Error Codes +ERROR_SUCCESS = 0 +ERROR_NOT_IMPLEMENTED = 0x400 # 1024 +ERROR_INVALID_VALUE = 0x401 # 1025 +ERROR_NOT_CONNECTED = 0x407 # 1031 +ERROR_UNSPECIFIED = 0x500 # 1280 + +# Available cloud conditions from ML model +ALL_CLOUD_CONDITIONS = ['Clear', 'Mostly Cloudy', 'Overcast', 'Rain', 'Snow', 'Wisps of clouds'] + + +@dataclass +class AlpacaConfig: + """Configuration for the Alpaca server""" + port: int = 11111 + device_number: int = 0 + device_name: str = "SimpleCloudDetect" + device_description: str = "ASCOM SafetyMonitor based on ML cloud detection" + driver_info: str = "ASCOM Alpaca SafetyMonitor v2.0 - Cloud Detection Driver" + driver_version: str = "2.0" + interface_version: int = 3 + detection_interval: int = 30 # seconds between ML detections (from detect.py) + update_interval: int = 30 # seconds between cloud detection updates + location: str = "AllSky Camera" + image_url: str = field(default_factory=lambda: os.environ.get('IMAGE_URL', '')) + unsafe_conditions: list = field(default_factory=lambda: ['Rain', 'Snow', 'Mostly Cloudy', 'Overcast']) + + # Confidence threshold settings + default_threshold: float = 50.0 # Default threshold for any class not explicitly configured + class_thresholds: Dict[str, float] = field(default_factory=dict) # Map class names to thresholds + + # Debounce settings (in seconds) + debounce_to_safe_sec: int = 60 # Wait time before switching from Unsafe → Safe + debounce_to_unsafe_sec: int = 0 # Wait time before switching from Safe → Unsafe (immediate) + + # NTP and timezone settings + ntp_server: str = field(default_factory=lambda: os.environ.get('NTP_SERVER', 'pool.ntp.org')) + timezone: str = field(default_factory=lambda: os.environ.get('TZ', 'UTC')) + + @classmethod + def get_config_path(cls) -> str: + """Get configuration file path from environment or default""" + return os.environ.get('CONFIG_FILE', 'alpaca_config.json') + + def save_to_file(self): + """Save configuration to JSON file""" + filepath = self.get_config_path() + try: + # Create directory if it doesn't exist (for /config volume usage) + dir_path = os.path.dirname(os.path.abspath(filepath)) + os.makedirs(dir_path, exist_ok=True) + + config_dict = asdict(self) + with open(filepath, 'w') as f: + json.dump(config_dict, f, indent=2) + logger.info(f"Configuration saved to {filepath}") + except PermissionError: + # Enhanced error logging for permission issues + try: + dir_path = os.path.dirname(os.path.abspath(filepath)) + stat_info = os.stat(dir_path) + logger.error(f"Permission denied saving to {filepath}. " + f"Directory '{dir_path}' is owned by UID {stat_info.st_uid} with mode {oct(stat_info.st_mode)[-3:]}. " + f"Container running as UID {os.getuid()}. " + f"Fix with: sudo chown {os.getuid()} {dir_path}") + except Exception: + logger.error(f"Failed to save configuration to {filepath}: [Errno 13] Permission denied") + except Exception as e: + logger.error(f"Failed to save configuration to {filepath}: {e}") + + @classmethod + def load_settings_from_file(cls) -> dict: + """Load configuration dictionary from JSON file, filtering for valid fields""" + filepath = cls.get_config_path() + try: + if os.path.exists(filepath): + with open(filepath, 'r') as f: + config_dict = json.load(f) + + # Filter dictionary to only include valid fields for this dataclass + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + return {k: v for k, v in config_dict.items() if k in valid_fields} + except Exception as e: + logger.error(f"Failed to load configuration from {filepath}: {e}") + return {} + + @classmethod + def load_from_file(cls): + """Load configuration from JSON file with backward compatibility""" + settings = cls.load_settings_from_file() + if settings: + logger.info(f"Configuration loaded from {cls.get_config_path()}") + return cls(**settings) + return None diff --git a/alpaca/device.py b/alpaca/device.py new file mode 100644 index 0000000..f98fa3c --- /dev/null +++ b/alpaca/device.py @@ -0,0 +1,369 @@ +""" +ASCOM Alpaca SafetyMonitor Device Implementation +""" +import threading +import logging +import io +import json +from collections import deque +from datetime import datetime +from typing import Optional, Dict, Any, Tuple +import paho.mqtt.client as mqtt +from PIL import Image + +# Import from sibling modules +from .config import AlpacaConfig, get_current_time +# Assuming detect.py is in the root path or installed as a package +from detect import CloudDetector, Config as DetectConfig, HADiscoveryManager + +logger = logging.getLogger(__name__) + + +class AlpacaSafetyMonitor: + """ASCOM Alpaca SafetyMonitor implementation with optimized image handling""" + + def __init__(self, alpaca_config: AlpacaConfig, detect_config: DetectConfig): + self.alpaca_config = alpaca_config + self.detect_config = detect_config + self.server_transaction_id = 0 + self.transaction_lock = threading.Lock() + + # Device state + self.connected_clients: Dict[Tuple[str, int], datetime] = {} # (IP, ClientID) -> ConnectionTime + self.disconnected_clients: Dict[Tuple[str, int], Tuple[datetime, datetime]] = {} # (IP, ClientID) -> (ConnectionTime, DisconnectionTime) + self.connection_lock = threading.Lock() + self.connecting = False + self.connected_at: Optional[datetime] = None + self.disconnected_at: Optional[datetime] = None + self.last_connected_at: Optional[datetime] = None + self.client_ip: Optional[str] = None + self.last_session_duration: Optional[float] = None + + # Cloud detection state + self.latest_detection: Optional[Dict[str, Any]] = { + 'class_name': 'Unknown', + 'confidence_score': 0.0, + 'Detection Time (Seconds)': 0.0, + 'timestamp': None + } + self._cached_is_safe = False + self._unsafe_conditions_set = set(alpaca_config.unsafe_conditions) + + # Debounce state tracking + self._stable_safe_state = False + self._pending_safe_state: Optional[bool] = None + self._state_change_start_time: Optional[datetime] = None + + # Safety state history (last 100 transitions) + self._safety_history = deque(maxlen=100) + + # OPTIMIZATION: Store raw bytes instead of base64 string + self.latest_image_bytes: Optional[bytes] = None + + self.detection_lock = threading.Lock() + self.detection_thread: Optional[threading.Thread] = None + self.stop_detection = threading.Event() + + # Setup MQTT client first + self.mqtt_client = self._setup_mqtt() + self.ha_discovery = None + + # Pre-load cloud detector at startup + logger.info("Pre-loading ML model...") + self.cloud_detector = CloudDetector(self.detect_config, mqtt_client=self.mqtt_client) + logger.info("ML model loaded successfully") + + # Setup HA Discovery if enabled + if self.mqtt_client and self.detect_config.mqtt_discovery_mode == 'homeassistant': + self.ha_discovery = HADiscoveryManager(self.detect_config, self.mqtt_client) + self.ha_discovery.publish_discovery_configs() + + # Blocking initial detection to ensure readiness + logger.info("Performing initial detection (blocking)...") + self._run_single_detection(initial=True) + logger.info(f"Initialized {self.alpaca_config.device_name}") + + def _create_thumbnail_bytes(self, img: Image.Image) -> Optional[bytes]: + """Create raw JPEG bytes for thumbnail (Optimized)""" + try: + img_copy = img.copy() + img_copy.thumbnail((200, 200), Image.Resampling.LANCZOS) + buffered = io.BytesIO() + img_copy.save(buffered, format="JPEG", quality=85) + return buffered.getvalue() # Return raw bytes + except Exception as e: + logger.warning(f"Thumbnail failed: {e}") + return None + + def _update_cached_safety(self, detection: Dict[str, Any]): + """Update cached safety status with debouncing logic (assumes lock is held)""" + # Step A: Determine Instantaneous Safety + class_name = detection.get('class_name', '') + confidence = detection.get('confidence_score', 0.0) + + # Get the specific threshold for this class, or use default + threshold = self.alpaca_config.class_thresholds.get( + class_name, + self.alpaca_config.default_threshold + ) + + # Check if current conditions indicate safe state + is_safe_now = ( + confidence >= threshold and + class_name != 'Unknown' and + class_name not in self._unsafe_conditions_set + ) + + # Step B: Apply Debouncing + if is_safe_now == self._stable_safe_state: + # State matches - reset any pending changes + self._pending_safe_state = None + self._state_change_start_time = None + self._cached_is_safe = self._stable_safe_state + else: + # State differs from stable state + if self._pending_safe_state != is_safe_now: + # New change detected - start debounce timer + self._pending_safe_state = is_safe_now + self._state_change_start_time = get_current_time(self.alpaca_config.timezone) + logger.info(f"State change detected: {'Safe' if is_safe_now else 'Unsafe'} " + f"(pending debounce verification)") + else: + # Change persisting - check if debounce period has elapsed + if self._state_change_start_time: + elapsed_time = (get_current_time(self.alpaca_config.timezone) - self._state_change_start_time).total_seconds() + + # Determine required duration based on transition direction + if is_safe_now: + required_duration = self.alpaca_config.debounce_to_safe_sec + else: + required_duration = self.alpaca_config.debounce_to_unsafe_sec + + if elapsed_time >= required_duration: + # Debounce period complete - commit state change + self._stable_safe_state = is_safe_now + self._cached_is_safe = is_safe_now + self._pending_safe_state = None + self._state_change_start_time = None + + # Add to safety history + self._safety_history.append({ + 'timestamp': get_current_time(self.alpaca_config.timezone), + 'is_safe': is_safe_now, + 'condition': class_name, + 'confidence': confidence + }) + + logger.warning(f"SAFETY STATE CHANGED: {'SAFE' if is_safe_now else 'UNSAFE'} " + f"(class={class_name}, confidence={confidence:.1f}%, " + f"threshold={threshold:.1f}%, debounce={elapsed_time:.1f}s)") + + def _setup_mqtt(self): + """Setup and return MQTT client based on detect_config""" + if not self.detect_config.broker: + logger.warning("MQTT broker not configured, MQTT publishing disabled") + return None + + client = mqtt.Client() + if self.detect_config.mqtt_username and self.detect_config.mqtt_password: + client.username_pw_set(self.detect_config.mqtt_username, self.detect_config.mqtt_password) + + # Setup Last Will Testament for HA discovery mode + if self.detect_config.mqtt_discovery_mode == 'homeassistant': + availability_topic = f"{self.detect_config.mqtt_discovery_prefix}/sensor/clouddetect_{self.detect_config.device_id}/availability" + client.will_set(availability_topic, "offline", retain=True) + + try: + client.connect(self.detect_config.broker, self.detect_config.port) + client.loop_start() + logger.info(f"Connected to MQTT broker at {self.detect_config.broker}:{self.detect_config.port}") + return client + except Exception as e: + logger.error(f"Failed to connect to MQTT broker: {e}") + return None + + def _run_single_detection(self, initial: bool = False): + """Run a single detection cycle with optimized image handling""" + try: + # Return image so we can process thumbnail + result = self.cloud_detector.detect(return_image=True) + result['timestamp'] = get_current_time(self.alpaca_config.timezone) + + # OPTIMIZATION: Convert to bytes immediately and drop the PIL Object + image_bytes = None + if 'image' in result: + image_bytes = self._create_thumbnail_bytes(result['image']) + del result['image'] + + with self.detection_lock: + self.latest_detection = result + self.latest_image_bytes = image_bytes + self._update_cached_safety(result) + + if initial: + self._safety_history.append({ + 'timestamp': result['timestamp'], + 'is_safe': self._stable_safe_state, + 'condition': result.get('class_name', 'Unknown'), + 'confidence': result.get('confidence_score', 0.0) + }) + + # MQTT Publish + if self.mqtt_client: + try: + mqtt_result = result.copy() + if 'timestamp' in mqtt_result and isinstance(mqtt_result['timestamp'], datetime): + mqtt_result['timestamp'] = mqtt_result['timestamp'].isoformat() + + if self.detect_config.mqtt_discovery_mode == 'homeassistant': + self.ha_discovery.publish_states(mqtt_result) + else: + self.mqtt_client.publish(self.detect_config.topic, json.dumps(mqtt_result)) + except Exception as e: + logger.error(f"MQTT publish failed: {e}") + + except Exception as e: + logger.error(f"Detection cycle failed: {e}") + + def _detection_loop(self): + """Background thread for continuous cloud detection""" + logger.info("Starting detection loop") + + while not self.stop_detection.is_set(): + self._run_single_detection() + logger.info(f"Cloud detection: {self.latest_detection['class_name']} " + f"({self.latest_detection['confidence_score']:.1f}%)") + + if self.stop_detection.wait(self.alpaca_config.update_interval): + break + + logger.info("Detection loop stopped") + + def get_next_transaction_id(self) -> int: + """Generate next server transaction ID (thread-safe, wraps at uint32 max)""" + with self.transaction_lock: + self.server_transaction_id = (self.server_transaction_id + 1) % 4294967296 + return self.server_transaction_id + + def create_response(self, value: Any = None, error_number: int = 0, + error_message: str = "", client_transaction_id: int = 0) -> Dict[str, Any]: + """Create standard ASCOM Alpaca response""" + response = { + "ClientTransactionID": client_transaction_id, + "ServerTransactionID": self.get_next_transaction_id(), + "ErrorNumber": error_number, + "ErrorMessage": error_message + } + + if value is not None or error_number == 0: + response["Value"] = value + + return response + + def get_client_params(self) -> tuple: + """Extract client ID and transaction ID from request with validation""" + from flask import request + client_id_raw = self._get_arg('ClientID', '0') + client_tx_raw = self._get_arg('ClientTransactionID', '0') + + client_id_raw = str(client_id_raw).strip() + client_tx_raw = str(client_tx_raw).strip() + + try: + client_id = int(client_id_raw) if client_id_raw else 0 + except (ValueError, TypeError): + logger.warning(f"Invalid ClientID received: {client_id_raw}") + client_id = 0 + + try: + client_transaction_id = int(client_tx_raw) if client_tx_raw else 0 + if client_transaction_id < 0 or client_transaction_id > 4294967295: + logger.warning(f"ClientTransactionID out of range: {client_transaction_id}") + client_transaction_id = 0 + except (ValueError, TypeError): + logger.warning(f"Invalid ClientTransactionID received: {client_tx_raw}") + client_transaction_id = 0 + + return client_id, client_transaction_id + + def _get_arg(self, key: str, default: Any = None) -> str: + """Case-insensitive argument retrieval from request values""" + from flask import request + key_lower = key.lower() + for k, v in request.values.items(): + if k.lower() == key_lower: + return v + return default + + @property + def is_connected(self) -> bool: + """Check if any clients are connected""" + with self.connection_lock: + return len(self.connected_clients) > 0 + + def connect(self, client_ip: str, client_id: int): + """Connect a client to the device""" + with self.connection_lock: + key = (client_ip, client_id) + self.connected_clients[key] = get_current_time(self.alpaca_config.timezone) + + # Remove from disconnected clients if reconnecting + if key in self.disconnected_clients: + del self.disconnected_clients[key] + + if len(self.connected_clients) == 1: + self.connected_at = self.connected_clients[key] + self.last_connected_at = self.connected_at + self.disconnected_at = None + + logger.info(f"Client connected: {client_ip} (ID: {client_id}). Total clients: {len(self.connected_clients)}") + + def disconnect(self, client_ip: str = None, client_id: int = None): + """Disconnect a client from the device""" + with self.connection_lock: + if client_ip is None or client_id is None: + # Disconnect all + for key in list(self.connected_clients.keys()): + conn_time = self.connected_clients[key] + disc_time = get_current_time(self.alpaca_config.timezone) + self.disconnected_clients[key] = (conn_time, disc_time) + self.connected_clients.clear() + self.disconnected_at = disc_time + if self.connected_at: + duration = (self.disconnected_at - self.connected_at).total_seconds() + self.last_session_duration = duration + self.connected_at = None + logger.info("All clients disconnected") + else: + key = (client_ip, client_id) + if key in self.connected_clients: + conn_time = self.connected_clients[key] + disc_time = get_current_time(self.alpaca_config.timezone) + self.disconnected_clients[key] = (conn_time, disc_time) + + del self.connected_clients[key] + logger.info(f"Client disconnected: {client_ip} (ID: {client_id}). Total clients: {len(self.connected_clients)}") + + if len(self.connected_clients) == 0: + self.disconnected_at = disc_time + if self.connected_at: + duration = (self.disconnected_at - self.connected_at).total_seconds() + self.last_session_duration = duration + self.connected_at = None + logger.info("All clients disconnected") + else: + logger.warning(f"Attempted to disconnect unknown client: {client_ip} (ID: {client_id})") + + def is_safe(self) -> bool: + """Determine if conditions are safe based on latest detection""" + # Safety Fail-safe: Always return False if not connected + if not self.is_connected: + return False + + with self.detection_lock: + return self._stable_safe_state + + def get_device_state(self) -> list: + """Get current operational state""" + is_safe_val = self.is_safe() + return [{"Name": "IsSafe", "Value": is_safe_val}] diff --git a/alpaca/discovery.py b/alpaca/discovery.py new file mode 100644 index 0000000..1c1fe36 --- /dev/null +++ b/alpaca/discovery.py @@ -0,0 +1,94 @@ +""" +ASCOM Alpaca UDP Discovery Protocol Handler +""" +import socket +import threading +import logging +import json + +logger = logging.getLogger(__name__) + + +class AlpacaDiscovery: + """ASCOM Alpaca UDP Discovery Protocol Handler""" + + DISCOVERY_PORT = 32227 + DISCOVERY_MESSAGE = b"alpacadiscovery1" + + def __init__(self, alpaca_port: int): + self.alpaca_port = alpaca_port + self.socket = None + self.running = False + self.thread = None + + def start(self): + """Start the discovery service""" + try: + # Create UDP socket + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Set timeout to allow checking self.running periodically + self.socket.settimeout(1.0) + + # Bind to all interfaces on discovery port + self.socket.bind(('', self.DISCOVERY_PORT)) + + self.running = True + self.thread = threading.Thread(target=self._discovery_loop, daemon=True) + self.thread.start() + + logger.info(f"Alpaca Discovery service started on UDP port {self.DISCOVERY_PORT}, advertising HTTP port {self.alpaca_port}") + except Exception as e: + logger.error(f"Failed to start discovery service: {e}") + logger.warning("Discovery will not be available, but HTTP API will still work") + + def stop(self): + """Stop the discovery service""" + self.running = False + if self.socket: + self.socket.close() + if self.thread: + self.thread.join(timeout=2) + logger.info("Alpaca Discovery service stopped") + + def _discovery_loop(self): + """Main discovery loop""" + logger.info("Discovery loop started, waiting for discovery requests...") + + while self.running: + try: + # Receive discovery request (with timeout to check self.running) + try: + data, addr = self.socket.recvfrom(1024) + except socket.timeout: + continue # Timeout allows us to check self.running + + logger.debug(f"Received UDP packet from {addr[0]}:{addr[1]}: {data}") + + if data == self.DISCOVERY_MESSAGE: + logger.info(f"Valid Alpaca discovery request from {addr[0]}:{addr[1]}") + self._send_discovery_response(addr) + else: + logger.debug(f"Ignored non-discovery message from {addr}: {data}") + + except Exception as e: + if self.running: # Only log if we're supposed to be running + logger.error(f"Error in discovery loop: {e}") + + def _send_discovery_response(self, addr): + """Send discovery response to client""" + try: + # Build discovery response per ASCOM Alpaca Discovery spec + # Must include AlpacaPort at minimum, ServerName is recommended + response = { + "AlpacaPort": self.alpaca_port, + "ServerName": "SimpleCloudDetect" + } + + response_json = json.dumps(response).encode('utf-8') + self.socket.sendto(response_json, addr) + logger.info(f"Sent discovery response to {addr[0]}:{addr[1]} - Port: {self.alpaca_port}, ServerName: SimpleCloudDetect") + + except Exception as e: + logger.error(f"Failed to send discovery response: {e}") diff --git a/alpaca/routes/__init__.py b/alpaca/routes/__init__.py new file mode 100644 index 0000000..8d73796 --- /dev/null +++ b/alpaca/routes/__init__.py @@ -0,0 +1 @@ +# Routes package initialization diff --git a/alpaca/routes/api.py b/alpaca/routes/api.py new file mode 100644 index 0000000..0d6eaca --- /dev/null +++ b/alpaca/routes/api.py @@ -0,0 +1,225 @@ +""" +ASCOM API Routes Blueprint +""" +from flask import Blueprint, jsonify, Response, request +import logging +from ..config import ERROR_SUCCESS, ERROR_INVALID_VALUE, ERROR_UNSPECIFIED +from ..device import AlpacaSafetyMonitor + +logger = logging.getLogger(__name__) + +# Create Blueprint +api_bp = Blueprint('api', __name__) + +# Global monitor instance (injected by app factory) +monitor: AlpacaSafetyMonitor = None + +def init_api(safety_monitor_instance): + """Initialize the API blueprint with the safety monitor instance""" + global monitor + monitor = safety_monitor_instance + +def validate_device_number(device_number: int): + """Validate device number and return error response if invalid""" + if device_number != monitor.alpaca_config.device_number: + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + error_number=ERROR_INVALID_VALUE, + error_message=f"Invalid device number: {device_number}", + client_transaction_id=client_tx_id + )), 400 + return None + +def create_simple_get_endpoint(attribute_getter): + """Factory for simple GET endpoints that return a config value""" + def endpoint(device_number: int): + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + value=attribute_getter(), + client_transaction_id=client_tx_id + )) + return endpoint + +@api_bp.route('/v1/safetymonitor//issafe', methods=['GET']) +def get_issafe(device_number: int): + """Get safety status""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + value=monitor.is_safe(), + client_transaction_id=client_tx_id + )) + +@api_bp.route('/v1/latest_image', methods=['GET']) +def get_latest_image(): + """Serve the latest detection image directly from memory bytes (OPTIMIZED)""" + with monitor.detection_lock: + img_bytes = monitor.latest_image_bytes + + if img_bytes: + # OPTIMIZATION: Serve bytes directly without base64 encoding/decoding + return Response(img_bytes, mimetype='image/jpeg') + return Response(status=404) + +@api_bp.route('/v1/safetymonitor//connected', methods=['GET']) +def get_connected(device_number: int): + """Get connection state""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + value=monitor.is_connected, + client_transaction_id=client_tx_id + )) + +@api_bp.route('/v1/safetymonitor//connected', methods=['PUT']) +def set_connected(device_number: int): + """Set connection state""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + + client_id, client_tx_id = monitor.get_client_params() + connected_str = monitor._get_arg('Connected', '').strip().lower() + + if connected_str == 'true': + target_state = True + elif connected_str == 'false': + target_state = False + else: + return jsonify(monitor.create_response( + error_number=ERROR_INVALID_VALUE, + error_message=f"Invalid boolean value for Connected: '{connected_str}'", + client_transaction_id=client_tx_id + )), 400 + + try: + client_ip = request.remote_addr + if target_state: + monitor.connect(client_ip=client_ip, client_id=client_id) + else: + monitor.disconnect(client_ip=client_ip, client_id=client_id) + + return jsonify(monitor.create_response(client_transaction_id=client_tx_id)) + except Exception as e: + logger.error(f"Failed to set connected state: {e}") + return jsonify(monitor.create_response( + error_number=ERROR_UNSPECIFIED, + error_message=str(e), + client_transaction_id=client_tx_id + )), 500 + +@api_bp.route('/v1/safetymonitor//connecting', methods=['GET']) +def get_connecting(device_number: int): + """Get connecting state""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + value=monitor.connecting, + client_transaction_id=client_tx_id + )) + +@api_bp.route('/v1/safetymonitor//connect', methods=['PUT']) +def connect_device(device_number: int): + """Connect to device""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + + client_id, client_tx_id = monitor.get_client_params() + try: + client_ip = request.remote_addr + monitor.connect(client_ip=client_ip, client_id=client_id) + return jsonify(monitor.create_response(client_transaction_id=client_tx_id)) + except Exception as e: + logger.error(f"Failed to connect: {e}") + return jsonify(monitor.create_response( + error_number=ERROR_UNSPECIFIED, + error_message=str(e), + client_transaction_id=client_tx_id + )), 500 + +@api_bp.route('/v1/safetymonitor//disconnect', methods=['PUT']) +def disconnect_device(device_number: int): + """Disconnect from device""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + + client_id, client_tx_id = monitor.get_client_params() + try: + client_ip = request.remote_addr + monitor.disconnect(client_ip=client_ip, client_id=client_id) + return jsonify(monitor.create_response(client_transaction_id=client_tx_id)) + except Exception as e: + logger.error(f"Error during disconnect: {e}") + return jsonify(monitor.create_response( + error_number=ERROR_UNSPECIFIED, + error_message=str(e), + client_transaction_id=client_tx_id + )), 500 + +@api_bp.route('/v1/safetymonitor//description', methods=['GET']) +def get_description(device_number: int): + return create_simple_get_endpoint(lambda: monitor.alpaca_config.device_description)(device_number) + +@api_bp.route('/v1/safetymonitor//devicestate', methods=['GET']) +def get_devicestate(device_number: int): + return create_simple_get_endpoint(monitor.get_device_state)(device_number) + +@api_bp.route('/v1/safetymonitor//driverinfo', methods=['GET']) +def get_driverinfo(device_number: int): + return create_simple_get_endpoint(lambda: monitor.alpaca_config.driver_info)(device_number) + +@api_bp.route('/v1/safetymonitor//driverversion', methods=['GET']) +def get_driverversion(device_number: int): + return create_simple_get_endpoint(lambda: monitor.alpaca_config.driver_version)(device_number) + +@api_bp.route('/v1/safetymonitor//interfaceversion', methods=['GET']) +def get_interfaceversion(device_number: int): + return create_simple_get_endpoint(lambda: monitor.alpaca_config.interface_version)(device_number) + +@api_bp.route('/v1/safetymonitor//name', methods=['GET']) +def get_name(device_number: int): + return create_simple_get_endpoint(lambda: monitor.alpaca_config.device_name)(device_number) + +@api_bp.route('/v1/safetymonitor//supportedactions', methods=['GET']) +def get_supportedactions(device_number: int): + return create_simple_get_endpoint(lambda: [])(device_number) + +@api_bp.route('/v1/safetymonitor//action', methods=['PUT']) +def put_action(device_number: int): + """Execute a device action""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + error_number=ERROR_INVALID_VALUE, + error_message="No actions are supported", + client_transaction_id=client_tx_id + )), 400 + +@api_bp.route('/v1/safetymonitor//commandblind', methods=['PUT']) +@api_bp.route('/v1/safetymonitor//commandbool', methods=['PUT']) +@api_bp.route('/v1/safetymonitor//commandstring', methods=['PUT']) +def not_implemented(device_number: int): + """Not implemented commands""" + error_response = validate_device_number(device_number) + if error_response: + return error_response + _, client_tx_id = monitor.get_client_params() + return jsonify(monitor.create_response( + error_number=ERROR_INVALID_VALUE, + error_message="Command not implemented", + client_transaction_id=client_tx_id + )), 400 + diff --git a/alpaca/routes/management.py b/alpaca/routes/management.py new file mode 100644 index 0000000..d3500f1 --- /dev/null +++ b/alpaca/routes/management.py @@ -0,0 +1,258 @@ +""" +Management/Setup Routes Blueprint +""" +import os +from flask import Blueprint, render_template, request, redirect, url_for +from datetime import datetime +from zoneinfo import ZoneInfo +import logging +from ..device import AlpacaSafetyMonitor +from ..config import ALL_CLOUD_CONDITIONS, get_current_time + +logger = logging.getLogger(__name__) + +mgmt_bp = Blueprint('management', __name__) +monitor: AlpacaSafetyMonitor = None +start_time = datetime.now() + +def init_mgmt(safety_monitor_instance): + """Initialize the management blueprint with the safety monitor instance""" + global monitor + monitor = safety_monitor_instance + +@mgmt_bp.route('/setup/v1/safetymonitor//setup', methods=['GET', 'POST']) +def setup_device(device_number: int): + """Setup page for device configuration""" + message = None + + if request.method == 'POST': + try: + # Update basic settings + monitor.alpaca_config.device_name = request.form.get('device_name', monitor.alpaca_config.device_name) + monitor.alpaca_config.location = request.form.get('location', monitor.alpaca_config.location) + monitor.alpaca_config.image_url = request.form.get('image_url', monitor.alpaca_config.image_url) + monitor.alpaca_config.ntp_server = request.form.get('ntp_server', monitor.alpaca_config.ntp_server) + monitor.alpaca_config.timezone = request.form.get('timezone', monitor.alpaca_config.timezone) + + # Update timing settings + monitor.alpaca_config.detection_interval = int(request.form.get('detection_interval', monitor.alpaca_config.detection_interval)) + monitor.alpaca_config.update_interval = int(request.form.get('update_interval', monitor.alpaca_config.update_interval)) + monitor.alpaca_config.debounce_to_safe_sec = int(request.form.get('debounce_safe', monitor.alpaca_config.debounce_to_safe_sec)) + monitor.alpaca_config.debounce_to_unsafe_sec = int(request.form.get('debounce_unsafe', monitor.alpaca_config.debounce_to_unsafe_sec)) + + # Update unsafe conditions based on radio buttons + new_unsafe = [] + for condition in ALL_CLOUD_CONDITIONS: + safety_value = request.form.get(f'safety_{condition}') + if safety_value == 'unsafe': + new_unsafe.append(condition) + monitor.alpaca_config.unsafe_conditions = new_unsafe + monitor._unsafe_conditions_set = set(new_unsafe) + + # Update thresholds + new_thresholds = {} + for condition in ALL_CLOUD_CONDITIONS: + threshold_value = request.form.get(f'threshold_{condition}') + if threshold_value: + new_thresholds[condition] = float(threshold_value) + monitor.alpaca_config.class_thresholds = new_thresholds + + # Save configuration + monitor.alpaca_config.save_to_file() + message = "Configuration saved successfully!" + + except Exception as e: + logger.error(f"Error saving configuration: {e}") + message = f"Error saving configuration: {e}" + + return redirect(url_for('management.setup_device', device_number=device_number)) + + # Prepare context for template rendering + with monitor.detection_lock: + det = monitor.latest_detection + current_condition = det.get('class_name', 'Unknown') + current_confidence = round(det.get('confidence_score', 0.0), 1) + detection_time = round(det.get('Detection Time (Seconds)', 0.0), 2) + timestamp = det.get('timestamp') + + # Calculate safe vs unsafe conditions + unsafe_cond = monitor.alpaca_config.unsafe_conditions + safe_cond = [c for c in ALL_CLOUD_CONDITIONS if c not in unsafe_cond] + + # ASCOM status + is_safe = monitor.is_safe() + ascom_safe_status = "SAFE" if is_safe else "UNSAFE" + ascom_safe_color = "rgb(52, 211, 153)" if is_safe else "rgb(248, 113, 113)" + + # Format timestamp + if timestamp: + last_update = timestamp.strftime("%H:%M:%S") + else: + last_update = "N/A" + + # Container uptime + uptime_seconds = (datetime.now() - start_time).total_seconds() + uptime_hours = int(uptime_seconds // 3600) + uptime_mins = int((uptime_seconds % 3600) // 60) + container_uptime = f"{uptime_hours}h {uptime_mins}m" + + # Connection status + ascom_status = "Connected" if monitor.is_connected else "Disconnected" + ascom_status_class = "status-connected" if monitor.is_connected else "status-disconnected" + client_count = len(monitor.connected_clients) + + # Build client list + client_list = [] + with monitor.connection_lock: + # Get set of currently connected IPs + connected_ips = {ip for (ip, client_id) in monitor.connected_clients.keys()} + + for (ip, client_id), conn_time in monitor.connected_clients.items(): + duration = (get_current_time(monitor.alpaca_config.timezone) - conn_time).total_seconds() + + try: + # Convert timestamp to current timezone + tz = ZoneInfo(monitor.alpaca_config.timezone) + local_conn_time = conn_time.astimezone(tz) + conn_time_str = local_conn_time.strftime("%H:%M:%S") + conn_ts = local_conn_time.timestamp() + except Exception: + # Fallback if timezone conversion fails + conn_time_str = conn_time.strftime("%H:%M:%S") + conn_ts = conn_time.timestamp() + + client_list.append({ + 'ip': ip, + 'status': 'connected', + 'duration': f"{int(duration)}s", + 'duration_seconds': duration, + 'connected_time': conn_time_str, + 'connected_ts': conn_ts, + 'disconnected_time': '-', + 'disconnected_ts': 0 + }) + + # Only show disconnected clients if their IP is not currently connected + for (ip, client_id), (conn_time, disc_time) in monitor.disconnected_clients.items(): + if ip in connected_ips: + continue # Skip disconnected entries for IPs that are currently connected + + duration = (disc_time - conn_time).total_seconds() + + try: + # Convert timestamps to current timezone + tz = ZoneInfo(monitor.alpaca_config.timezone) + local_conn_time = conn_time.astimezone(tz) + local_disc_time = disc_time.astimezone(tz) + conn_time_str = local_conn_time.strftime("%H:%M:%S") + disc_time_str = local_disc_time.strftime("%H:%M:%S") + conn_ts = local_conn_time.timestamp() + disc_ts = local_disc_time.timestamp() + except Exception: + # Fallback if timezone conversion fails + conn_time_str = conn_time.strftime("%H:%M:%S") + disc_time_str = disc_time.strftime("%H:%M:%S") + conn_ts = conn_time.timestamp() + disc_ts = disc_time.timestamp() + + client_list.append({ + 'ip': ip, + 'status': 'disconnected', + 'duration': f"{int(duration)}s", + 'duration_seconds': duration, + 'connected_time': conn_time_str, + 'connected_ts': conn_ts, + 'disconnected_time': disc_time_str, + 'disconnected_ts': disc_ts + }) + + # Safety history (newest first - reverse chronological) + safety_history = [] + with monitor.detection_lock: + for entry in reversed(list(monitor._safety_history)[-10:]): # Last 10 entries, newest first + try: + # Convert timestamp to current timezone + tz = ZoneInfo(monitor.alpaca_config.timezone) + converted_time = entry['timestamp'].astimezone(tz) + time_str = converted_time.strftime("%H:%M:%S") + except Exception: + # Fallback if timezone conversion fails + time_str = entry['timestamp'].strftime("%H:%M:%S") + + safety_history.append({ + 'is_safe': entry['is_safe'], + 'time': time_str, + 'condition': entry['condition'], + 'confidence': round(entry['confidence'], 1) + }) + + # Render template + return render_template( + 'setup.html', + message=message, + current_name=monitor.alpaca_config.device_name, + current_location=monitor.alpaca_config.location, + current_image_url=monitor.alpaca_config.image_url, + image_url_default=os.environ.get('IMAGE_URL', ''), + current_ntp_server=monitor.alpaca_config.ntp_server, + current_timezone=monitor.alpaca_config.timezone, + detection_interval=monitor.alpaca_config.detection_interval, + update_interval=monitor.alpaca_config.update_interval, + debounce_to_safe=monitor.alpaca_config.debounce_to_safe_sec, + debounce_to_unsafe=monitor.alpaca_config.debounce_to_unsafe_sec, + current_condition=current_condition, + current_confidence=current_confidence, + detection_time=detection_time, + last_update=last_update, + container_uptime=container_uptime, + ascom_safe_status=ascom_safe_status, + ascom_safe_color=ascom_safe_color, + ascom_status=ascom_status, + ascom_status_class=ascom_status_class, + client_count=client_count, + client_list=client_list, + safety_history=safety_history, + safe_conditions=safe_cond, + unsafe_conditions=unsafe_cond, + default_threshold=monitor.alpaca_config.default_threshold, + class_thresholds=monitor.alpaca_config.class_thresholds + ) + + +# ASCOM Alpaca Management API Endpoints (must be at root, not under /api prefix) +@mgmt_bp.route('/management/apiversions', methods=['GET']) +def get_apiversions(): + """Get supported API versions""" + return { + "Value": [1], + "ErrorNumber": 0, + "ErrorMessage": "" + } + +@mgmt_bp.route('/management/v1/description', methods=['GET']) +def get_management_description(): + """Get server description""" + return { + "Value": { + "ServerName": "SimpleCloudDetect", + "Manufacturer": "SimpleCloudDetect", + "ManufacturerVersion": "2.0", + "Location": monitor.alpaca_config.location + }, + "ErrorNumber": 0, + "ErrorMessage": "" + } + +@mgmt_bp.route('/management/v1/configureddevices', methods=['GET']) +def get_configureddevices(): + """Get list of configured devices""" + return { + "Value": [{ + "DeviceName": monitor.alpaca_config.device_name, + "DeviceType": "SafetyMonitor", + "DeviceNumber": monitor.alpaca_config.device_number, + "UniqueID": f"simpleclouddetect-safetymonitor-{monitor.alpaca_config.device_number}" + }], + "ErrorNumber": 0, + "ErrorMessage": "" + } diff --git a/alpaca_safety_monitor.py b/alpaca_safety_monitor.py.canbedeleted similarity index 90% rename from alpaca_safety_monitor.py rename to alpaca_safety_monitor.py.canbedeleted index 339cd8a..2480868 100644 --- a/alpaca_safety_monitor.py +++ b/alpaca_safety_monitor.py.canbedeleted @@ -17,7 +17,7 @@ from dataclasses import dataclass, asdict, field from datetime import datetime from zoneinfo import ZoneInfo -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Tuple import json import base64 import io @@ -35,6 +35,7 @@ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) +start_time = datetime.now() # Global timezone helper def get_current_time(timezone_str: str = 'UTC') -> datetime: @@ -134,12 +135,15 @@ def __init__(self, alpaca_config: AlpacaConfig, detect_config: DetectConfig): self.transaction_lock = threading.Lock() # Device state - self.connected = False + self.connected_clients: Dict[Tuple[str, int], datetime] = {} # (IP, ClientID) -> ConnectionTime + self.disconnected_clients: Dict[Tuple[str, int], Tuple[datetime, datetime]] = {} # (IP, ClientID) -> (ConnectionTime, DisconnectionTime) + self.connection_lock = threading.Lock() self.connecting = False self.connected_at: Optional[datetime] = None self.disconnected_at: Optional[datetime] = None self.last_connected_at: Optional[datetime] = None # Track last successful connection - self.client_ip: Optional[str] = None # Track connected client's IP address + self.client_ip: Optional[str] = None # Track connected client's IP address (deprecated in multi-client) + self.last_session_duration: Optional[float] = None # Cloud detection state - use single lock for all state self.latest_detection: Optional[Dict[str, Any]] = { @@ -430,38 +434,57 @@ def _detection_loop(self): logger.info("Detection loop stopped") - def connect(self, client_ip: Optional[str] = None): - """Connect to the device (ASCOM client connection)""" - if self.connected: - return - - try: - # Just mark as connected - detection loop is already running - self.connected = True - self.connected_at = get_current_time(self.alpaca_config.timezone) - self.last_connected_at = self.connected_at - self.client_ip = client_ip - logger.info(f"ASCOM client connected to safety monitor from {client_ip or 'unknown'}") - except Exception as e: - logger.error(f"Failed to connect: {e}") - raise - - def disconnect(self): - """Disconnect from the device (ASCOM client disconnection)""" - if not self.connected: - return - - try: - # Just mark as disconnected - detection loop keeps running for MQTT/web UI - self.connected = False - self.disconnected_at = get_current_time(self.alpaca_config.timezone) - self.connected_at = None - logger.info("ASCOM client disconnected from safety monitor") - except Exception as e: - logger.error(f"Error during disconnect: {e}") - + @property + def is_connected(self) -> bool: + """Check if any clients are connected""" + with self.connection_lock: + return len(self.connected_clients) > 0 + + def connect(self, client_ip: str, client_id: int): + """Connect a client to the device""" + with self.connection_lock: + # Add or update client session + key = (client_ip, client_id) + self.connected_clients[key] = get_current_time(self.alpaca_config.timezone) + + # If this is the first connection, set global connected_at + if len(self.connected_clients) == 1: + self.connected_at = self.connected_clients[key] + self.last_connected_at = self.connected_at + self.disconnected_at = None + + logger.info(f"Client connected: {client_ip} (ID: {client_id}). Total clients: {len(self.connected_clients)}") + + def disconnect(self, client_ip: str, client_id: int): + """Disconnect a client from the device""" + with self.connection_lock: + key = (client_ip, client_id) + if key in self.connected_clients: + # Move to disconnected history + conn_time = self.connected_clients[key] + disc_time = get_current_time(self.alpaca_config.timezone) + self.disconnected_clients[key] = (conn_time, disc_time) + + del self.connected_clients[key] + logger.info(f"Client disconnected: {client_ip} (ID: {client_id}). Total clients: {len(self.connected_clients)}") + + # If no clients left, update global disconnected state + if len(self.connected_clients) == 0: + self.disconnected_at = disc_time + if self.connected_at: + duration = (self.disconnected_at - self.connected_at).total_seconds() + self.last_session_duration = duration + self.connected_at = None + logger.info("All clients disconnected") + else: + logger.warning(f"Attempted to disconnect unknown client: {client_ip} (ID: {client_id})") + def is_safe(self) -> bool: """Determine if conditions are safe based on latest detection""" + # Safety Fail-safe: Always return False if not connected + if not self.is_connected: + return False + with self.detection_lock: return self._stable_safe_state @@ -469,8 +492,8 @@ def get_device_state(self) -> list: """Get current operational state""" # Per ASCOM spec: DeviceState should only include operational properties # For SafetyMonitor, only IsSafe is operational - with self.detection_lock: - return [{"Name": "IsSafe", "Value": self._stable_safe_state if self.connected else False}] + is_safe_val = self.is_safe() + return [{"Name": "IsSafe", "Value": is_safe_val}] def _get_arg(self, key: str, default: Any = None) -> str: """Case-insensitive argument retrieval from request values""" @@ -528,7 +551,8 @@ def get_issafe(device_number: int): # Per ASCOM spec: Always return a value, never an error # If disconnected, return False (unsafe) to protect equipment - is_safe = safety_monitor.is_safe() if safety_monitor.connected else False + # is_safe() now handles the connection check internally + is_safe = safety_monitor.is_safe() return jsonify(safety_monitor.create_response( value=is_safe, @@ -545,7 +569,7 @@ def get_connected(device_number: int): _, client_tx_id = safety_monitor.get_client_params() return jsonify(safety_monitor.create_response( - value=safety_monitor.connected, + value=safety_monitor.is_connected, client_transaction_id=client_tx_id )) @@ -557,7 +581,7 @@ def set_connected(device_number: int): if error_response: return error_response - _, client_tx_id = safety_monitor.get_client_params() + client_id, client_tx_id = safety_monitor.get_client_params() connected_str = safety_monitor._get_arg('Connected', '').strip().lower() if connected_str == 'true': @@ -572,12 +596,11 @@ def set_connected(device_number: int): )), 400 try: - if target_state != safety_monitor.connected: - if target_state: - client_ip = request.remote_addr - safety_monitor.connect(client_ip=client_ip) - else: - safety_monitor.disconnect() + client_ip = request.remote_addr + if target_state: + safety_monitor.connect(client_ip=client_ip, client_id=client_id) + else: + safety_monitor.disconnect(client_ip=client_ip, client_id=client_id) return jsonify(safety_monitor.create_response( client_transaction_id=client_tx_id @@ -612,11 +635,10 @@ def connect_device(device_number: int): if error_response: return error_response - _, client_tx_id = safety_monitor.get_client_params() + client_id, client_tx_id = safety_monitor.get_client_params() try: - if not safety_monitor.connected: - client_ip = request.remote_addr - safety_monitor.connect(client_ip=client_ip) + client_ip = request.remote_addr + safety_monitor.connect(client_ip=client_ip, client_id=client_id) return jsonify(safety_monitor.create_response(client_transaction_id=client_tx_id)) except Exception as e: logger.error(f"Failed to connect: {e}") @@ -634,10 +656,10 @@ def disconnect_device(device_number: int): if error_response: return error_response - _, client_tx_id = safety_monitor.get_client_params() + client_id, client_tx_id = safety_monitor.get_client_params() try: - if safety_monitor.connected: - safety_monitor.disconnect() + client_ip = request.remote_addr + safety_monitor.disconnect(client_ip=client_ip, client_id=client_id) return jsonify(safety_monitor.create_response(client_transaction_id=client_tx_id)) except Exception as e: logger.error(f"Error during disconnect: {e}") @@ -1678,9 +1700,14 @@ def setup_device(device_number: int):
-
- 📊 - System Status +
+
+ 🤖 + System Status +
+
+ {{ current_name }} | {{ current_location }} +
@@ -1702,8 +1729,7 @@ def setup_device(device_number: int):
-
Current Detection
-
+
Condition
{{ current_condition }}
@@ -1724,6 +1750,10 @@ def setup_device(device_number: int):
Last Updated
{{ last_update }}
+
+
Container Uptime
+
{{ container_uptime }}
+
@@ -1752,65 +1782,105 @@ def setup_device(device_number: int):
- -
- + +
+
-

- Status: {{ ascom_status }} -

- {% if connection_duration %} -

- Duration: {{ connection_duration }} -

- {% endif %} - {% if last_connected %} -

- Last Connected: {{ last_connected }} -

- {% endif %} - {% if client_ip %} -

- Client: {{ client_ip }} -

- {% endif %} - {% if last_disconnected %} -

- Last Disconnected: {{ last_disconnected }} -

+
+

+ Status: {{ ascom_status }} +

+

+ Active: {{ client_count }} +

+
+ + {% if client_list %} +
+ + + + + + + + + + + + {% for client in client_list %} + + + + + + + + {% endfor %} + +
IP ↕Status ↕Duration ↕Connected ↕Disconnected ↕
{{ client.ip }} + {{ '●' if client.status == 'connected' else '○' }} + {{ client.duration }}{{ client.connected_time }}{{ client.disconnected_time }}
+
{% endif %}
- - -
- -
-
-

- Name: {{ current_name }} -

-

- Location: {{ current_location }} -

-
-
-
+ + +
+
+ 🛠️ + Configuration Settings +
+ +
+ +
+ +
+
+
+ Image Fetch Interval + How often to download new images from AllSky camera and run AI analysis. Lower values = faster response but higher CPU usage. +
+
+
+ Recommended:
+ • Fast response: 15-30s
+ • Balanced: 30-60s
+ • Resource efficient: 60-120s +
+
+ +
+ ASCOM Update Interval + How often to re-check safety status. Should be ≥ Image Fetch Interval. +
+
+
+ Rule: Set equal to Image Fetch Interval for efficiency. +
+
+ +
+ Safe Wait Time + How long the sky must remain clear before the system reports "Safe". +
+
+
+ Why it matters:
+ Prevents opening the observatory roof during brief breaks in storms or passing clouds. + Set higher (e.g., 300s = 5 min) for unstable weather patterns.

+ Recommended:
+ • Stable climate: 60-120s
+ • Variable weather: 180-300s
+ • Storm-prone areas: 300-600s +
+
+ +
+ Unsafe Wait Time (Debounce to Unsafe) + How long bad weather must persist before the system reports "Unsafe". +
+
+
+ Why it matters:
+ Controls emergency response speed. Setting to 0 triggers immediate roof closure at first sign of danger. + Higher values (10-30s) can filter out brief sensor glitches.

+ Recommended:
+ • Automated roof: 0s (immediate)
+ • Manual operation: 5-10s
+ • Glitch filtering: 10-30s +
+
+ +
+ Confidence Threshold + Minimum AI confidence (%) required to trigger each weather condition. +
+
+
+ 📊 Key Concept:
+ Higher threshold = Less sensitive (AI must be more confident)
+ Lower threshold = More sensitive (AI triggers with less certainty)

+ + How it works:
+ If set to 80% for "Rain", the AI must be 80% confident it's raining to trigger. + Lower = more sensitive (fewer misses, more false alarms). Higher = less sensitive (more misses, fewer false alarms).

+ Tuning tips:
+ • Start at 50% (default)
+ • Lower for critical conditions (Rain: 40-50%)
+ • Higher for uncertain conditions (Overcast: 60-70%)
+ • Adjust based on false alarm rate +
+
+
+
+
+ + +
+ +
+
+
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + URL to fetch images for cloud detection. Leave empty to use environment variable default. + +
+
+ +
+
+ + + + Time server for accurate timestamps + +
+ +
+ + + + e.g., America/New_York, Europe/London, UTC + 📖 Reference + +
+
+
+
+
+
+ + +
+ +
+
+
+ + +
+

How Timing Works

+ +
+ +
+
+
📷
+
+
FETCH IMAGE
+
Every 60s
+
+
+
+ Downloads latest image from AllSky camera and runs AI analysis to detect weather conditions +
+
+ + +
+
+
🔄
+
+
ASCOM UPDATE
+
Every 30s
+
+
+
+ Re-checks safety status using latest detection result and applies debounce logic +
+
+ + +
+
+
+
+
SAFE WAIT TIME
+
60s
+
+
+
+ How long sky must stay clear before reporting "Safe" - prevents premature roof opening +
+
+
+ + +
+
+
+
+
UNSAFE WAIT TIME
+
Immediate
+
+
+
+ How long bad weather must persist before reporting "Unsafe" - 0 = instant roof closure +
+
+
+
+ + +
+
💡 EXAMPLE SCENARIO
+
+
+
+ +
+
+ + +

How often to fetch & analyze new images from AllSky camera

+
+ +
+ + +

How often to check safety status

+
+
+ +
+
+ + +

Delay before reporting safe conditions

+
+ +
+ + +

Delay before reporting unsafe conditions (0 = immediate)

+
+
+ + + + +
+
+
+
+ + +
+ +
+
+
+

+ Mark each weather condition as Safe or Unsafe for observatory operations. +

+ + +
+

Safe Conditions

+
+ {% for condition in safe_conditions %} +
+
+
{{ condition }}
+
+ + +
+
+
+ + + % + + +
+
+
+
+
{{ class_thresholds.get(condition, default_threshold) }}%
+
+
+
+ ✓ Triggers >{{ class_thresholds.get(condition, default_threshold) }}% +
+
+
+ {% endfor %} +
+
+ + +
+

Unsafe Conditions

+
+ {% for condition in unsafe_conditions %} +
+
+
{{ condition }}
+
+ + +
+
+
+ + + % + + +
+
+
+
+
{{ class_thresholds.get(condition, default_threshold) }}%
+
+
+
+ ✓ Triggers >{{ class_thresholds.get(condition, default_threshold) }}% +
+
+
+ {% endfor %} +
+
+ + + +
+
+
+
+ + +
+
+
+ + diff --git a/verify_config.py b/verify_config.py new file mode 100644 index 0000000..e69de29 diff --git a/verify_multi_client.py b/verify_multi_client.py new file mode 100644 index 0000000..d8b9917 --- /dev/null +++ b/verify_multi_client.py @@ -0,0 +1,164 @@ +import requests +import time +import sys +import threading +import subprocess +import os +import signal + +BASE_URL = "http://localhost:11111/api/v1/safetymonitor/0" + +def log(msg): + print(f"[TEST] {msg}") + +def check_connected(): + try: + resp = requests.get(f"{BASE_URL}/connected") + resp.raise_for_status() + return resp.json()["Value"] + except Exception as e: + log(f"Error checking connected status: {e}") + return None + +def check_issafe(): + try: + resp = requests.get(f"{BASE_URL}/issafe") + resp.raise_for_status() + return resp.json()["Value"] + except Exception as e: + log(f"Error checking safe status: {e}") + return None + +def connect(client_id, ip_spoof=None): + params = {'ClientID': client_id, 'Connected': 'True'} + headers = {} + if ip_spoof: + # Note: Werkzeug/Flask remote_addr isn't easily spoofed via headers unless behind proxy setup + # But for this test we might just rely on different ClientIDs. + # The refactored code uses (client_ip, client_id) as key. + # Running locally, IP will be 127.0.0.1 for all requests. + # So we really rely on ClientID to distinguish sessions if IP is same. + pass + + try: + resp = requests.put(f"{BASE_URL}/connected", data=params) + resp.raise_for_status() + return True + except Exception as e: + log(f"Error connecting client {client_id}: {e}") + return False + +def disconnect(client_id): + params = {'ClientID': client_id, 'Connected': 'False'} + try: + resp = requests.put(f"{BASE_URL}/connected", data=params) + resp.raise_for_status() + return True + except Exception as e: + log(f"Error disconnecting client {client_id}: {e}") + return False + +def run_tests(): + # Start Server + log("Starting server subprocess...") + server_process = subprocess.Popen([sys.executable, "alpaca_safety_monitor.py"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + try: + # Wait for server to be ready + log("Waiting for server...") + for _ in range(30): + try: + # We can check simple endpoint like /management/apiversions or even /connected + resp = requests.get(f"{BASE_URL}/connected") + if resp.status_code == 200: + break + except requests.exceptions.ConnectionError: + pass + time.sleep(1) + else: + log("Server not reachable") + sys.exit(1) + + log("Server ready. Starting tests.") + + # Scenario A: Single Client + log("--- Scenario A: Single Client ---") + + # Ensure initially disconnected + if check_connected(): + log("WARN: Initially connected? Disconnecting all potential clients...") + + log("Connecting Client A (ID 1)...") + connect(1) + + connected = check_connected() + log(f"Is Connected? {connected}") + if not connected: + log("FAIL: Client A should be connected") + sys.exit(1) + + log("Disconnecting Client A...") + disconnect(1) + + connected = check_connected() + log(f"Is Connected? {connected}") + if connected: + log("FAIL: Should be disconnected after Client A leaves") + sys.exit(1) + + log("Scenario A Passed") + + # Scenario B: Shared Session + log("--- Scenario B: Shared Session ---") + + log("Connecting Client A (ID 1)...") + connect(1) + + log("Connecting Client B (ID 2)...") + connect(2) + + log("Disconnecting Client A...") + disconnect(1) + + connected = check_connected() + log(f"Is Connected? {connected}") + if not connected: + log("FAIL: Should still be connected (Client B is active)") + sys.exit(1) + + log("Disconnecting Client B...") + disconnect(2) + + connected = check_connected() + log(f"Is Connected? {connected}") + if connected: + log("FAIL: Should be disconnected after Client B leaves") + sys.exit(1) + + log("Scenario B Passed") + + # Scenario C: Fail-safe + log("--- Scenario C: Fail-safe ---") + # Ensure fully disconnected + if check_connected(): + log("FAIL: System should be disconnected") + sys.exit(1) + + is_safe = check_issafe() + log(f"Is Safe (while disconnected)? {is_safe}") + if is_safe: + log("FAIL: Should report Unsafe (False) when disconnected") + sys.exit(1) + + log("Scenario C Passed") + log("ALL TESTS PASSED") + + finally: + log("Terminating server...") + server_process.terminate() + server_process.wait() + +if __name__ == "__main__": + run_tests()