Skip to content

Commit f0fc2b1

Browse files
authored
Merge pull request #112 from chvvkumar/dev
Introduce External API, enhance timestamps, and update documentation
2 parents c5ea282 + 9106c59 commit f0fc2b1

File tree

11 files changed

+727
-27
lines changed

11 files changed

+727
-27
lines changed

alpaca/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .device import AlpacaSafetyMonitor
99
from .routes.api import api_bp, init_api
1010
from .routes.management import mgmt_bp, init_mgmt
11+
from .routes.external_api import external_api_bp, init_external_api
1112
from detect import Config as DetectConfig
1213

1314
def create_app():
@@ -47,10 +48,12 @@ def create_app():
4748
# Initialize Routes with Monitor Instance
4849
init_api(safety_monitor)
4950
init_mgmt(safety_monitor)
51+
init_external_api(safety_monitor)
5052

5153
# Register Blueprints
5254
app.register_blueprint(api_bp, url_prefix='/api')
5355
app.register_blueprint(mgmt_bp, url_prefix='')
56+
app.register_blueprint(external_api_bp)
5457

5558
# Store monitor for access in main.py
5659
app.safety_monitor = safety_monitor

alpaca/device.py

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import json
88
from collections import deque
99
from datetime import datetime, timedelta
10-
from typing import Optional, Dict, Any, Tuple
10+
from typing import Optional, Dict, Any, Tuple, List
1111
import paho.mqtt.client as mqtt
1212
from PIL import Image
1313

@@ -194,6 +194,9 @@ def _prune_stale_clients(self):
194194
def register_heartbeat(self, client_ip: str, client_id: int):
195195
"""Update the last seen timestamp for a connected client"""
196196
with self.connection_lock:
197+
# Prune stale clients during heartbeat (periodic cleanup)
198+
self._prune_stale_clients()
199+
197200
key = (client_ip, client_id)
198201
if key in self.connected_clients:
199202
# Only update last_seen, preserve connected_clients (start time)
@@ -348,12 +351,20 @@ def _get_arg(self, key: str, default: Any = None) -> str:
348351
def is_connected(self) -> bool:
349352
"""Check if any clients are connected"""
350353
with self.connection_lock:
351-
self._prune_stale_clients()
352354
return len(self.connected_clients) > 0
355+
356+
def is_client_connected(self, client_ip: str, client_id: int) -> bool:
357+
"""Check if a specific client is connected"""
358+
with self.connection_lock:
359+
key = (client_ip, client_id)
360+
return key in self.connected_clients
353361

354362
def connect(self, client_ip: str, client_id: int):
355363
"""Connect a client to the device"""
356364
with self.connection_lock:
365+
# Prune stale clients BEFORE adding new connection
366+
self._prune_stale_clients()
367+
357368
key = (client_ip, client_id)
358369
current_time = get_current_time(self.alpaca_config.timezone)
359370
self.connected_clients[key] = current_time
@@ -373,13 +384,13 @@ def connect(self, client_ip: str, client_id: int):
373384
def disconnect(self, client_ip: str = None, client_id: int = None):
374385
"""Disconnect a client from the device"""
375386
with self.connection_lock:
376-
self._prune_stale_clients()
377387
if client_ip is None or client_id is None:
378-
# Disconnect all
388+
# Disconnect all - IMMEDIATE state change
389+
disc_time = get_current_time(self.alpaca_config.timezone)
379390
for key in list(self.connected_clients.keys()):
380391
conn_time = self.connected_clients[key]
381-
disc_time = get_current_time(self.alpaca_config.timezone)
382392
self.disconnected_clients[key] = (conn_time, disc_time)
393+
383394
self.connected_clients.clear()
384395
self.client_last_seen.clear()
385396
self.disconnected_at = disc_time
@@ -420,6 +431,54 @@ def is_safe(self) -> bool:
420431
with self.detection_lock:
421432
return self._stable_safe_state
422433

434+
def get_pending_status(self) -> Dict[str, Any]:
435+
"""Get information about any pending state changes"""
436+
with self.detection_lock:
437+
if self._pending_safe_state is None or self._state_change_start_time is None:
438+
return {'is_pending': False}
439+
440+
now = get_current_time(self.alpaca_config.timezone)
441+
elapsed = (now - self._state_change_start_time).total_seconds()
442+
443+
if self._pending_safe_state:
444+
required = self.alpaca_config.debounce_to_safe_sec
445+
else:
446+
required = self.alpaca_config.debounce_to_unsafe_sec
447+
448+
remaining = max(0, required - elapsed)
449+
450+
return {
451+
'is_pending': True,
452+
'target_state': 'SAFE' if self._pending_safe_state else 'UNSAFE',
453+
'target_color': 'rgb(52, 211, 153)' if self._pending_safe_state else 'rgb(248, 113, 113)',
454+
'remaining_seconds': round(remaining, 1),
455+
'total_duration': required
456+
}
457+
458+
def get_safety_history(self) -> List[Dict[str, Any]]:
459+
"""Get a thread-safe copy of the safety history"""
460+
with self.detection_lock:
461+
return list(self._safety_history)
462+
463+
def get_connected_clients_info(self) -> List[Dict[str, Any]]:
464+
"""Get detailed information about connected clients"""
465+
clients = []
466+
now = get_current_time(self.alpaca_config.timezone)
467+
with self.connection_lock:
468+
# Prune stale clients before returning list
469+
self._prune_stale_clients()
470+
471+
for (ip, client_id), conn_time in self.connected_clients.items():
472+
last_seen = self.client_last_seen.get((ip, client_id))
473+
clients.append({
474+
"ip": ip,
475+
"client_id": client_id,
476+
"connected_at": conn_time,
477+
"last_seen": last_seen,
478+
"duration_seconds": (now - conn_time).total_seconds()
479+
})
480+
return clients
481+
423482
def get_device_state(self) -> list:
424483
"""Get current operational state"""
425484
is_safe_val = self.is_safe()

alpaca/routes/api.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,16 @@ def get_latest_image():
7878

7979
@api_bp.route('/v1/safetymonitor/<int:device_number>/connected', methods=['GET'])
8080
def get_connected(device_number: int):
81-
"""Get connection state"""
81+
"""Get connection state for this specific client"""
8282
error_response = validate_device_number(device_number)
8383
if error_response:
8484
return error_response
85-
_, client_tx_id = monitor.get_client_params()
85+
client_id, client_tx_id = monitor.get_client_params()
86+
client_ip = request.remote_addr
87+
# Return per-client connection state, not global state
88+
is_client_connected = monitor.is_client_connected(client_ip, client_id)
8689
return jsonify(monitor.create_response(
87-
value=monitor.is_connected,
90+
value=is_client_connected,
8891
client_transaction_id=client_tx_id
8992
))
9093

alpaca/routes/external_api.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
External REST API Blueprint
3+
Provides access to system status, configuration, and detection data for external integrations.
4+
Separated from ASCOM Alpaca routes to maintain strict compliance there.
5+
"""
6+
import logging
7+
from datetime import datetime
8+
from flask import Blueprint, jsonify, Response
9+
from ..device import AlpacaSafetyMonitor
10+
11+
logger = logging.getLogger(__name__)
12+
13+
external_api_bp = Blueprint('external_api', __name__)
14+
monitor: AlpacaSafetyMonitor = None
15+
start_time = datetime.now()
16+
17+
def init_external_api(safety_monitor_instance: AlpacaSafetyMonitor):
18+
"""Initialize the external API blueprint with the safety monitor instance"""
19+
global monitor
20+
monitor = safety_monitor_instance
21+
22+
@external_api_bp.route('/api/ext/v1/system', methods=['GET'])
23+
def get_system_info():
24+
"""Get system information and uptime"""
25+
uptime = (datetime.now() - start_time).total_seconds()
26+
return jsonify({
27+
"name": "SimpleCloudDetect",
28+
"uptime_seconds": uptime,
29+
"uptime_formatted": str(datetime.now() - start_time).split('.')[0],
30+
"server_time": datetime.now().isoformat()
31+
})
32+
33+
@external_api_bp.route('/api/ext/v1/status', methods=['GET'])
34+
def get_status():
35+
"""Get current safety status and latest detection"""
36+
if not monitor:
37+
return jsonify({"error": "System not initialized"}), 503
38+
39+
# Get safety status
40+
is_safe = monitor.is_safe()
41+
42+
# Get latest detection safely
43+
detection = {}
44+
with monitor.detection_lock:
45+
if monitor.latest_detection:
46+
detection = monitor.latest_detection.copy()
47+
# Serialize timestamp
48+
if detection.get('timestamp'):
49+
detection['timestamp'] = detection['timestamp'].isoformat()
50+
51+
return jsonify({
52+
"is_safe": is_safe,
53+
"safety_status": "Safe" if is_safe else "Unsafe",
54+
"detection": detection
55+
})
56+
57+
@external_api_bp.route('/api/ext/v1/config', methods=['GET'])
58+
def get_config():
59+
"""Get current configuration settings"""
60+
if not monitor:
61+
return jsonify({"error": "System not initialized"}), 503
62+
63+
cfg = monitor.alpaca_config
64+
65+
return jsonify({
66+
"device": {
67+
"name": cfg.device_name,
68+
"location": cfg.location,
69+
"id": cfg.device_number
70+
},
71+
"imaging": {
72+
"url": cfg.image_url,
73+
"interval": cfg.detection_interval
74+
},
75+
"safety": {
76+
"unsafe_conditions": cfg.unsafe_conditions,
77+
"thresholds": cfg.class_thresholds,
78+
"default_threshold": cfg.default_threshold,
79+
"debounce_safe_sec": cfg.debounce_to_safe_sec,
80+
"debounce_unsafe_sec": cfg.debounce_to_unsafe_sec
81+
},
82+
"system": {
83+
"timezone": cfg.timezone,
84+
"ntp_server": cfg.ntp_server,
85+
"update_interval": cfg.update_interval
86+
}
87+
})
88+
89+
@external_api_bp.route('/api/ext/v1/clients', methods=['GET'])
90+
def get_clients():
91+
"""Get connected ASCOM Alpaca clients"""
92+
if not monitor:
93+
return jsonify({"error": "System not initialized"}), 503
94+
95+
client_list = monitor.get_connected_clients_info()
96+
97+
# Serialize datetimes
98+
for client in client_list:
99+
if client.get('connected_at'):
100+
client['connected_at'] = client['connected_at'].isoformat()
101+
if client.get('last_seen'):
102+
client['last_seen'] = client['last_seen'].isoformat()
103+
104+
return jsonify({
105+
"connected_count": len(client_list),
106+
"clients": client_list
107+
})
108+
109+
@external_api_bp.route('/api/ext/v1/history', methods=['GET'])
110+
def get_history():
111+
"""Get safety state transition history"""
112+
if not monitor:
113+
return jsonify({"error": "System not initialized"}), 503
114+
115+
history = []
116+
raw_history = monitor.get_safety_history()
117+
118+
for entry in raw_history:
119+
item = entry.copy()
120+
if item.get('timestamp'):
121+
item['timestamp'] = item['timestamp'].isoformat()
122+
history.append(item)
123+
124+
# Return in reverse chronological order (newest first)
125+
return jsonify(list(reversed(history)))
126+
127+
@external_api_bp.route('/api/ext/v1/image', methods=['GET'])
128+
def get_image():
129+
"""Get the latest detection image (raw JPEG)"""
130+
if not monitor:
131+
return jsonify({"error": "System not initialized"}), 503
132+
133+
if monitor.latest_image_bytes:
134+
return Response(monitor.latest_image_bytes, mimetype='image/jpeg')
135+
else:
136+
return jsonify({"error": "No image available"}), 404

alpaca/routes/management.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,12 @@ def setup_device(device_number: int):
8484
ascom_safe_status = "SAFE" if is_safe else "UNSAFE"
8585
ascom_safe_color = "rgb(52, 211, 153)" if is_safe else "rgb(248, 113, 113)"
8686

87+
# Get pending status
88+
pending_status = monitor.get_pending_status()
89+
8790
# Format timestamp
8891
if timestamp:
89-
last_update = timestamp.strftime("%H:%M:%S")
92+
last_update = timestamp.strftime("%Y-%m-%d %H:%M:%S")
9093
else:
9194
last_update = "N/A"
9295

@@ -99,11 +102,15 @@ def setup_device(device_number: int):
99102
# Connection status
100103
ascom_status = "Connected" if monitor.is_connected else "Disconnected"
101104
ascom_status_class = "status-connected" if monitor.is_connected else "status-disconnected"
102-
client_count = len(monitor.connected_clients)
103105

104106
# Build client list - show unique clients by IP with most recent connection info
105107
client_list = []
106108
with monitor.connection_lock:
109+
# Prune stale clients before building list
110+
monitor._prune_stale_clients()
111+
112+
# Get client count inside the lock for consistency
113+
client_count = len(monitor.connected_clients)
107114
# Dictionary to track unique clients by IP
108115
unique_clients = {}
109116

@@ -200,10 +207,10 @@ def setup_device(device_number: int):
200207
# Convert timestamp to current timezone
201208
tz = ZoneInfo(monitor.alpaca_config.timezone)
202209
converted_time = entry['timestamp'].astimezone(tz)
203-
time_str = converted_time.strftime("%H:%M:%S")
210+
time_str = converted_time.strftime("%Y-%m-%d %H:%M:%S")
204211
except Exception:
205212
# Fallback if timezone conversion fails
206-
time_str = entry['timestamp'].strftime("%H:%M:%S")
213+
time_str = entry['timestamp'].strftime("%Y-%m-%d %H:%M:%S")
207214

208215
safety_history.append({
209216
'is_safe': entry['is_safe'],
@@ -241,7 +248,8 @@ def setup_device(device_number: int):
241248
safe_conditions=safe_cond,
242249
unsafe_conditions=unsafe_cond,
243250
default_threshold=monitor.alpaca_config.default_threshold,
244-
class_thresholds=monitor.alpaca_config.class_thresholds
251+
class_thresholds=monitor.alpaca_config.class_thresholds,
252+
pending_status=pending_status
245253
)
246254

247255

File renamed without changes.

0 commit comments

Comments
 (0)