Skip to content

Commit a4c72e7

Browse files
committed
feat: Add external API routes for system status and client information, add dates to all timestamps
1 parent 7071a62 commit a4c72e7

File tree

7 files changed

+196
-15
lines changed

7 files changed

+196
-15
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: 40 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,30 @@ def is_safe(self) -> bool:
420431
with self.detection_lock:
421432
return self._stable_safe_state
422433

434+
def get_safety_history(self) -> List[Dict[str, Any]]:
435+
"""Get a thread-safe copy of the safety history"""
436+
with self.detection_lock:
437+
return list(self._safety_history)
438+
439+
def get_connected_clients_info(self) -> List[Dict[str, Any]]:
440+
"""Get detailed information about connected clients"""
441+
clients = []
442+
now = get_current_time(self.alpaca_config.timezone)
443+
with self.connection_lock:
444+
# Prune stale clients before returning list
445+
self._prune_stale_clients()
446+
447+
for (ip, client_id), conn_time in self.connected_clients.items():
448+
last_seen = self.client_last_seen.get((ip, client_id))
449+
clients.append({
450+
"ip": ip,
451+
"client_id": client_id,
452+
"connected_at": conn_time,
453+
"last_seen": last_seen,
454+
"duration_seconds": (now - conn_time).total_seconds()
455+
})
456+
return clients
457+
423458
def get_device_state(self) -> list:
424459
"""Get current operational state"""
425460
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: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def setup_device(device_number: int):
8686

8787
# Format timestamp
8888
if timestamp:
89-
last_update = timestamp.strftime("%H:%M:%S")
89+
last_update = timestamp.strftime("%Y-%m-%d %H:%M:%S")
9090
else:
9191
last_update = "N/A"
9292

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

104103
# Build client list - show unique clients by IP with most recent connection info
105104
client_list = []
106105
with monitor.connection_lock:
106+
# Prune stale clients before building list
107+
monitor._prune_stale_clients()
108+
109+
# Get client count inside the lock for consistency
110+
client_count = len(monitor.connected_clients)
107111
# Dictionary to track unique clients by IP
108112
unique_clients = {}
109113

@@ -200,10 +204,10 @@ def setup_device(device_number: int):
200204
# Convert timestamp to current timezone
201205
tz = ZoneInfo(monitor.alpaca_config.timezone)
202206
converted_time = entry['timestamp'].astimezone(tz)
203-
time_str = converted_time.strftime("%H:%M:%S")
207+
time_str = converted_time.strftime("%Y-%m-%d %H:%M:%S")
204208
except Exception:
205209
# Fallback if timezone conversion fails
206-
time_str = entry['timestamp'].strftime("%H:%M:%S")
210+
time_str = entry['timestamp'].strftime("%Y-%m-%d %H:%M:%S")
207211

208212
safety_history.append({
209213
'is_safe': entry['is_safe'],

keras_model.h5

0 Bytes
Binary file not shown.

labels.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
0 Clear
22
1 Mostly Cloudy
33
2 Overcast
4-
3 Rain
5-
4 Snow
6-
5 Wisps of clouds
4+
3 Partly Cloudy
5+
4 Rain
6+
5 Snow

0 commit comments

Comments
 (0)