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
3 changes: 3 additions & 0 deletions alpaca/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .device import AlpacaSafetyMonitor
from .routes.api import api_bp, init_api
from .routes.management import mgmt_bp, init_mgmt
from .routes.external_api import external_api_bp, init_external_api
from detect import Config as DetectConfig

def create_app():
Expand Down Expand Up @@ -47,10 +48,12 @@ def create_app():
# Initialize Routes with Monitor Instance
init_api(safety_monitor)
init_mgmt(safety_monitor)
init_external_api(safety_monitor)

# Register Blueprints
app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(mgmt_bp, url_prefix='')
app.register_blueprint(external_api_bp)

# Store monitor for access in main.py
app.safety_monitor = safety_monitor
Expand Down
69 changes: 64 additions & 5 deletions alpaca/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import json
from collections import deque
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, List
import paho.mqtt.client as mqtt
from PIL import Image

Expand Down Expand Up @@ -194,6 +194,9 @@ def _prune_stale_clients(self):
def register_heartbeat(self, client_ip: str, client_id: int):
"""Update the last seen timestamp for a connected client"""
with self.connection_lock:
# Prune stale clients during heartbeat (periodic cleanup)
self._prune_stale_clients()

key = (client_ip, client_id)
if key in self.connected_clients:
# Only update last_seen, preserve connected_clients (start time)
Expand Down Expand Up @@ -348,12 +351,20 @@ def _get_arg(self, key: str, default: Any = None) -> str:
def is_connected(self) -> bool:
"""Check if any clients are connected"""
with self.connection_lock:
self._prune_stale_clients()
return len(self.connected_clients) > 0

def is_client_connected(self, client_ip: str, client_id: int) -> bool:
"""Check if a specific client is connected"""
with self.connection_lock:
key = (client_ip, client_id)
return key in self.connected_clients

def connect(self, client_ip: str, client_id: int):
"""Connect a client to the device"""
with self.connection_lock:
# Prune stale clients BEFORE adding new connection
self._prune_stale_clients()

key = (client_ip, client_id)
current_time = get_current_time(self.alpaca_config.timezone)
self.connected_clients[key] = current_time
Expand All @@ -373,13 +384,13 @@ def connect(self, client_ip: str, client_id: int):
def disconnect(self, client_ip: str = None, client_id: int = None):
"""Disconnect a client from the device"""
with self.connection_lock:
self._prune_stale_clients()
if client_ip is None or client_id is None:
# Disconnect all
# Disconnect all - IMMEDIATE state change
disc_time = get_current_time(self.alpaca_config.timezone)
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.client_last_seen.clear()
self.disconnected_at = disc_time
Expand Down Expand Up @@ -420,6 +431,54 @@ def is_safe(self) -> bool:
with self.detection_lock:
return self._stable_safe_state

def get_pending_status(self) -> Dict[str, Any]:
"""Get information about any pending state changes"""
with self.detection_lock:
if self._pending_safe_state is None or self._state_change_start_time is None:
return {'is_pending': False}

now = get_current_time(self.alpaca_config.timezone)
elapsed = (now - self._state_change_start_time).total_seconds()

if self._pending_safe_state:
required = self.alpaca_config.debounce_to_safe_sec
else:
required = self.alpaca_config.debounce_to_unsafe_sec

remaining = max(0, required - elapsed)

return {
'is_pending': True,
'target_state': 'SAFE' if self._pending_safe_state else 'UNSAFE',
'target_color': 'rgb(52, 211, 153)' if self._pending_safe_state else 'rgb(248, 113, 113)',
'remaining_seconds': round(remaining, 1),
'total_duration': required
}

def get_safety_history(self) -> List[Dict[str, Any]]:
"""Get a thread-safe copy of the safety history"""
with self.detection_lock:
return list(self._safety_history)

def get_connected_clients_info(self) -> List[Dict[str, Any]]:
"""Get detailed information about connected clients"""
clients = []
now = get_current_time(self.alpaca_config.timezone)
with self.connection_lock:
# Prune stale clients before returning list
self._prune_stale_clients()

for (ip, client_id), conn_time in self.connected_clients.items():
last_seen = self.client_last_seen.get((ip, client_id))
clients.append({
"ip": ip,
"client_id": client_id,
"connected_at": conn_time,
"last_seen": last_seen,
"duration_seconds": (now - conn_time).total_seconds()
})
return clients

def get_device_state(self) -> list:
"""Get current operational state"""
is_safe_val = self.is_safe()
Expand Down
9 changes: 6 additions & 3 deletions alpaca/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ def get_latest_image():

@api_bp.route('/v1/safetymonitor/<int:device_number>/connected', methods=['GET'])
def get_connected(device_number: int):
"""Get connection state"""
"""Get connection state for this specific client"""
error_response = validate_device_number(device_number)
if error_response:
return error_response
_, client_tx_id = monitor.get_client_params()
client_id, client_tx_id = monitor.get_client_params()
client_ip = request.remote_addr
# Return per-client connection state, not global state
is_client_connected = monitor.is_client_connected(client_ip, client_id)
return jsonify(monitor.create_response(
value=monitor.is_connected,
value=is_client_connected,
client_transaction_id=client_tx_id
))

Expand Down
136 changes: 136 additions & 0 deletions alpaca/routes/external_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
External REST API Blueprint
Provides access to system status, configuration, and detection data for external integrations.
Separated from ASCOM Alpaca routes to maintain strict compliance there.
"""
import logging
from datetime import datetime
from flask import Blueprint, jsonify, Response
from ..device import AlpacaSafetyMonitor

logger = logging.getLogger(__name__)

external_api_bp = Blueprint('external_api', __name__)
monitor: AlpacaSafetyMonitor = None
start_time = datetime.now()

def init_external_api(safety_monitor_instance: AlpacaSafetyMonitor):
"""Initialize the external API blueprint with the safety monitor instance"""
global monitor
monitor = safety_monitor_instance

@external_api_bp.route('/api/ext/v1/system', methods=['GET'])
def get_system_info():
"""Get system information and uptime"""
uptime = (datetime.now() - start_time).total_seconds()
return jsonify({
"name": "SimpleCloudDetect",
"uptime_seconds": uptime,
"uptime_formatted": str(datetime.now() - start_time).split('.')[0],
"server_time": datetime.now().isoformat()
})

@external_api_bp.route('/api/ext/v1/status', methods=['GET'])
def get_status():
"""Get current safety status and latest detection"""
if not monitor:
return jsonify({"error": "System not initialized"}), 503

# Get safety status
is_safe = monitor.is_safe()

# Get latest detection safely
detection = {}
with monitor.detection_lock:
if monitor.latest_detection:
detection = monitor.latest_detection.copy()
# Serialize timestamp
if detection.get('timestamp'):
detection['timestamp'] = detection['timestamp'].isoformat()

return jsonify({
"is_safe": is_safe,
"safety_status": "Safe" if is_safe else "Unsafe",
"detection": detection
})

@external_api_bp.route('/api/ext/v1/config', methods=['GET'])
def get_config():
"""Get current configuration settings"""
if not monitor:
return jsonify({"error": "System not initialized"}), 503

cfg = monitor.alpaca_config

return jsonify({
"device": {
"name": cfg.device_name,
"location": cfg.location,
"id": cfg.device_number
},
"imaging": {
"url": cfg.image_url,
"interval": cfg.detection_interval
},
"safety": {
"unsafe_conditions": cfg.unsafe_conditions,
"thresholds": cfg.class_thresholds,
"default_threshold": cfg.default_threshold,
"debounce_safe_sec": cfg.debounce_to_safe_sec,
"debounce_unsafe_sec": cfg.debounce_to_unsafe_sec
},
"system": {
"timezone": cfg.timezone,
"ntp_server": cfg.ntp_server,
"update_interval": cfg.update_interval
}
})

@external_api_bp.route('/api/ext/v1/clients', methods=['GET'])
def get_clients():
"""Get connected ASCOM Alpaca clients"""
if not monitor:
return jsonify({"error": "System not initialized"}), 503

client_list = monitor.get_connected_clients_info()

# Serialize datetimes
for client in client_list:
if client.get('connected_at'):
client['connected_at'] = client['connected_at'].isoformat()
if client.get('last_seen'):
client['last_seen'] = client['last_seen'].isoformat()

return jsonify({
"connected_count": len(client_list),
"clients": client_list
})

@external_api_bp.route('/api/ext/v1/history', methods=['GET'])
def get_history():
"""Get safety state transition history"""
if not monitor:
return jsonify({"error": "System not initialized"}), 503

history = []
raw_history = monitor.get_safety_history()

for entry in raw_history:
item = entry.copy()
if item.get('timestamp'):
item['timestamp'] = item['timestamp'].isoformat()
history.append(item)

# Return in reverse chronological order (newest first)
return jsonify(list(reversed(history)))

@external_api_bp.route('/api/ext/v1/image', methods=['GET'])
def get_image():
"""Get the latest detection image (raw JPEG)"""
if not monitor:
return jsonify({"error": "System not initialized"}), 503

if monitor.latest_image_bytes:
return Response(monitor.latest_image_bytes, mimetype='image/jpeg')
else:
return jsonify({"error": "No image available"}), 404
18 changes: 13 additions & 5 deletions alpaca/routes/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ def setup_device(device_number: int):
ascom_safe_status = "SAFE" if is_safe else "UNSAFE"
ascom_safe_color = "rgb(52, 211, 153)" if is_safe else "rgb(248, 113, 113)"

# Get pending status
pending_status = monitor.get_pending_status()

# Format timestamp
if timestamp:
last_update = timestamp.strftime("%H:%M:%S")
last_update = timestamp.strftime("%Y-%m-%d %H:%M:%S")
else:
last_update = "N/A"

Expand All @@ -99,11 +102,15 @@ def setup_device(device_number: int):
# 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 - show unique clients by IP with most recent connection info
client_list = []
with monitor.connection_lock:
# Prune stale clients before building list
monitor._prune_stale_clients()

# Get client count inside the lock for consistency
client_count = len(monitor.connected_clients)
# Dictionary to track unique clients by IP
unique_clients = {}

Expand Down Expand Up @@ -200,10 +207,10 @@ def setup_device(device_number: int):
# 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")
time_str = converted_time.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
# Fallback if timezone conversion fails
time_str = entry['timestamp'].strftime("%H:%M:%S")
time_str = entry['timestamp'].strftime("%Y-%m-%d %H:%M:%S")

safety_history.append({
'is_safe': entry['is_safe'],
Expand Down Expand Up @@ -241,7 +248,8 @@ def setup_device(device_number: int):
safe_conditions=safe_cond,
unsafe_conditions=unsafe_cond,
default_threshold=monitor.alpaca_config.default_threshold,
class_thresholds=monitor.alpaca_config.class_thresholds
class_thresholds=monitor.alpaca_config.class_thresholds,
pending_status=pending_status
)


Expand Down
File renamed without changes.
Loading