Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -491,7 +497,7 @@ jobs:
const summary = summaryBody.join('\n').trim();

// Create enhanced PR body with AI summary
const marker = '<!-- pr-commit-summary-bot -->';
const marker = '';
const summarySection = `${marker}\n\n${summary}\n\n---\n<sub>📊 Analyzed **${commitCount}** commit(s) | 🕐 Updated: ${timestamp} | Generated by GitHub Actions</sub>\n\n---\n\n`;

// Get current PR details
Expand Down Expand Up @@ -541,5 +547,4 @@ jobs:
body: newBody
});

console.log(`✅ Updated PR #${context.issue.number}`);

console.log(`✅ Updated PR #${context.issue.number}`);
5 changes: 3 additions & 2 deletions .github/workflows/snd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
24 changes: 18 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand All @@ -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"]
58 changes: 58 additions & 0 deletions alpaca/__init__.py
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions alpaca/config.py
Original file line number Diff line number Diff line change
@@ -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
Loading