From ea67b420102127bd5508e1af20c73fb556771f6f Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 20:01:29 -0600 Subject: [PATCH 1/6] feat: update self-hosted runner configurations for multi-architecture based on runner tags --- .github/workflows/build-and-release.yml | 6 +++--- .github/workflows/snd.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 36ad0a3..74b81cf 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -138,10 +138,10 @@ jobs: fail-fast: false matrix: include: - - runner: git01 + - runner: [self-hosted, Linux, X64] platform: linux/amd64 platform_tag: amd64 - - runner: gitpi01 + - runner: [self-hosted, Linux, ARM64] platform: linux/arm64 platform_tag: arm64 steps: @@ -166,7 +166,7 @@ jobs: - name: Cache Docker layers # Skip GitHub Actions cache for self-hosted runners (files persist locally) - if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }} + if: ${{ !contains(runner.labels, 'self-hosted') }} uses: actions/cache@v4 with: # CHANGE: Use a path in the home directory, not /tmp diff --git a/.github/workflows/snd.yml b/.github/workflows/snd.yml index 006f208..4b656a6 100644 --- a/.github/workflows/snd.yml +++ b/.github/workflows/snd.yml @@ -16,10 +16,10 @@ jobs: fail-fast: false matrix: include: - - runner: git01 + - runner: [self-hosted, Linux, X64] platform: linux/amd64 platform_tag: amd64 - - runner: gitpi01 + - runner: [self-hosted, Linux, ARM64] platform: linux/arm64 platform_tag: arm64 steps: @@ -44,7 +44,7 @@ jobs: - name: Cache Docker layers # Skip GitHub Actions cache for self-hosted runners (files persist locally) - if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }} + if: ${{ !contains(runner.labels, 'self-hosted') }} uses: actions/cache@v4 with: # CHANGE: Use a path in the home directory, not /tmp From 7c2c2fbb55f6225a86bbded5a81a11811859b1da Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 20:05:23 -0600 Subject: [PATCH 2/6] feat: update merge job to use self-hosted runners for multi-architecture support --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/snd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 74b81cf..64c0cb6 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -197,7 +197,7 @@ jobs: merge: name: Merge Multi-Arch Image needs: [prepare, build] - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux] steps: - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.github/workflows/snd.yml b/.github/workflows/snd.yml index 4b656a6..c9a4915 100644 --- a/.github/workflows/snd.yml +++ b/.github/workflows/snd.yml @@ -75,7 +75,7 @@ jobs: merge: name: Merge Multi-Arch Image needs: build - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux] steps: - name: Login to Docker Hub uses: docker/login-action@v3 From 65de3c4b57457090e948d848ba52554e789e7693 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 20:32:47 -0600 Subject: [PATCH 3/6] fix: update merge job to specify self-hosted runner architecture --- .github/workflows/snd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/snd.yml b/.github/workflows/snd.yml index c9a4915..e8384db 100644 --- a/.github/workflows/snd.yml +++ b/.github/workflows/snd.yml @@ -75,7 +75,7 @@ jobs: merge: name: Merge Multi-Arch Image needs: build - runs-on: [self-hosted, Linux] + runs-on: [self-hosted, Linux, X64] steps: - name: Login to Docker Hub uses: docker/login-action@v3 From f2198b7f50e3a9d7689a77c77472e1909b167b0f Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 20:59:55 -0600 Subject: [PATCH 4/6] feat: format connection duration in dd-hh-mm-ss for better readability --- alpaca/routes/management.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/alpaca/routes/management.py b/alpaca/routes/management.py index da850eb..c9efb20 100644 --- a/alpaca/routes/management.py +++ b/alpaca/routes/management.py @@ -112,21 +112,29 @@ def setup_device(device_number: int): if ip not in unique_clients or conn_time > unique_clients[ip]['conn_time']: duration = (get_current_time(monitor.alpaca_config.timezone) - conn_time).total_seconds() + # Format duration as dd-hh-mm-ss fixed width + dur_int = int(duration) + days = dur_int // 86400 + hours = (dur_int % 86400) // 3600 + minutes = (dur_int % 3600) // 60 + seconds = dur_int % 60 + duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s" + 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_time_str = local_conn_time.strftime("%Y-%m-%d %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_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S") conn_ts = conn_time.timestamp() unique_clients[ip] = { 'ip': ip, 'status': 'connected', - 'duration': f"{int(duration)}s", + 'duration': duration_str, 'duration_seconds': duration, 'connected_time': conn_time_str, 'connected_ts': conn_ts, @@ -145,26 +153,34 @@ def setup_device(device_number: int): if ip not in unique_clients or disc_time > unique_clients[ip].get('disc_time', datetime.min.replace(tzinfo=conn_time.tzinfo)): duration = (disc_time - conn_time).total_seconds() + # Format duration as dd-hh-mm-ss fixed width + dur_int = int(duration) + days = dur_int // 86400 + hours = (dur_int % 86400) // 3600 + minutes = (dur_int % 3600) // 60 + seconds = dur_int % 60 + duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s" + 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_time_str = local_conn_time.strftime("%Y-%m-%d %H:%M:%S") + disc_time_str = local_disc_time.strftime("%Y-%m-%d %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_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S") + disc_time_str = disc_time.strftime("%Y-%m-%d %H:%M:%S") conn_ts = conn_time.timestamp() disc_ts = disc_time.timestamp() unique_clients[ip] = { 'ip': ip, 'status': 'disconnected', - 'duration': f"{int(duration)}s", + 'duration': duration_str, 'duration_seconds': duration, 'connected_time': conn_time_str, 'connected_ts': conn_ts, From 703adefb35704ba25e2740a0d9a585de73519935 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 21:07:56 -0600 Subject: [PATCH 5/6] fix: align table column text to the right for better consistency --- templates/setup.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/setup.html b/templates/setup.html index 3e0b483..c3b5ba4 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -843,8 +843,8 @@

☁️ SimpleCloudDetect

Status ↕ IP ↕ Duration ↕ - Connected ↕ - Disconnected ↕ + Connected ↕ + Disconnected ↕ @@ -855,8 +855,8 @@

☁️ SimpleCloudDetect

{{ client.ip }} {{ client.duration }} - {{ client.connected_time }} - {{ client.disconnected_time }} + {{ client.connected_time }} + {{ client.disconnected_time }} {% endfor %} From 138880c81ddf489bb418c3ed136229039874abdd Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 4 Jan 2026 21:56:08 -0600 Subject: [PATCH 6/6] feat: implement client heartbeat registration and stale client pruning in SafetyMonitor --- alpaca/device.py | 32 +++- alpaca/routes/api.py | 11 +- tests/test_alpaca.py | 436 ++++++++++++++----------------------------- 3 files changed, 181 insertions(+), 298 deletions(-) diff --git a/alpaca/device.py b/alpaca/device.py index f98fa3c..bd2cee4 100644 --- a/alpaca/device.py +++ b/alpaca/device.py @@ -6,11 +6,14 @@ import io import json from collections import deque -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional, Dict, Any, Tuple import paho.mqtt.client as mqtt from PIL import Image +# Watchdog constants +CLIENT_TIMEOUT_SECONDS = 300 # 5 Minutes + # Import from sibling modules from .config import AlpacaConfig, get_current_time # Assuming detect.py is in the root path or installed as a package @@ -158,6 +161,31 @@ def _update_cached_safety(self, detection: Dict[str, Any]): f"(class={class_name}, confidence={confidence:.1f}%, " f"threshold={threshold:.1f}%, debounce={elapsed_time:.1f}s)") + def _prune_stale_clients(self): + """Remove clients that haven't been seen for CLIENT_TIMEOUT_SECONDS (assumes lock is held)""" + now = get_current_time(self.alpaca_config.timezone) + cutoff_time = now - timedelta(seconds=CLIENT_TIMEOUT_SECONDS) + + stale_clients = [] + for key, last_seen in list(self.connected_clients.items()): + if last_seen < cutoff_time: + stale_clients.append(key) + + for key in stale_clients: + client_ip, client_id = key + conn_time = self.connected_clients[key] + self.disconnected_clients[key] = (conn_time, now) + del self.connected_clients[key] + logger.warning(f"Watchdog: Pruned stale client {client_ip} (ID: {client_id}) - " + f"inactive for {(now - conn_time).total_seconds():.0f}s") + + def register_heartbeat(self, client_ip: str, client_id: int): + """Update the last seen timestamp for a connected client""" + with self.connection_lock: + key = (client_ip, client_id) + if key in self.connected_clients: + self.connected_clients[key] = get_current_time(self.alpaca_config.timezone) + def _setup_mqtt(self): """Setup and return MQTT client based on detect_config""" if not self.detect_config.broker: @@ -299,6 +327,7 @@ 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 connect(self, client_ip: str, client_id: int): @@ -321,6 +350,7 @@ 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 for key in list(self.connected_clients.keys()): diff --git a/alpaca/routes/api.py b/alpaca/routes/api.py index 0d6eaca..4a14b00 100644 --- a/alpaca/routes/api.py +++ b/alpaca/routes/api.py @@ -28,6 +28,16 @@ def validate_device_number(device_number: int): error_message=f"Invalid device number: {device_number}", client_transaction_id=client_tx_id )), 400 + + # Register heartbeat for watchdog (every API request keeps session alive) + try: + client_id, _ = monitor.get_client_params() + client_ip = request.remote_addr + monitor.register_heartbeat(client_ip, client_id) + except Exception as e: + # Don't fail the request if heartbeat fails + logger.debug(f"Heartbeat registration failed: {e}") + return None def create_simple_get_endpoint(attribute_getter): @@ -222,4 +232,3 @@ def not_implemented(device_number: int): error_message="Command not implemented", client_transaction_id=client_tx_id )), 400 - diff --git a/tests/test_alpaca.py b/tests/test_alpaca.py index e1c785a..2f25102 100644 --- a/tests/test_alpaca.py +++ b/tests/test_alpaca.py @@ -1,321 +1,165 @@ -#!/usr/bin/env python3 -""" -Test script for ASCOM Alpaca SafetyMonitor -Validates API compliance and basic functionality -""" - -import requests -import sys import time -from typing import Dict, Any +import sys +# Ensure the alpaca module is in the path if running from the root of the project +# sys.path.append("path/to/alpyca/folder") -BASE_URL = "http://localhost:11111" -DEVICE_NUM = 0 +try: + from alpaca.safetymonitor import SafetyMonitor + from alpaca.exceptions import * +except ImportError: + print("Error: Could not import 'alpaca'. Ensure the alpyca package is installed or in your Python path.") + sys.exit(1) +# ============================================================================== +# CONFIGURATION +# ============================================================================== +DEVICE_ADDRESS = "allskypi5.lan:11111" # Updated based on your log +DEVICE_NUMBER = 0 +PROTOCOL = "http" +# ============================================================================== -class AlpacaAPITester: - """Test ASCOM Alpaca SafetyMonitor API compliance""" - - def __init__(self, base_url: str = BASE_URL, device_num: int = DEVICE_NUM): - self.base_url = base_url - self.device_num = device_num - self.passed = 0 - self.failed = 0 - self.client_transaction_id = 1 - - def get_next_transaction_id(self) -> int: - """Get next client transaction ID""" - tx_id = self.client_transaction_id - self.client_transaction_id += 1 - return tx_id +def log(message, level="INFO"): + print(f"[{level}] {message}") + +def test_safety_monitor(): + log(f"Initializing SafetyMonitor at {PROTOCOL}://{DEVICE_ADDRESS} Device #{DEVICE_NUMBER}") - def test_get(self, endpoint: str, expected_type: type = None, - should_fail: bool = False) -> bool: - """Test GET endpoint""" - url = f"{self.base_url}/api/v1/safetymonitor/{self.device_num}/{endpoint}" - params = {"ClientTransactionID": self.get_next_transaction_id()} - + try: + # 1. Initialize the Device + # ---------------------------------------------------------------------- + safetymon = SafetyMonitor(DEVICE_ADDRESS, DEVICE_NUMBER, PROTOCOL) + log("Device object initialized.") + + # 2. Test Connection (Connect) + # ---------------------------------------------------------------------- + log("Attempting to connect...") try: - response = requests.get(url, params=params, timeout=5) - data = response.json() - - # Check response structure - if not all(key in data for key in ["ClientTransactionID", "ServerTransactionID", - "ErrorNumber", "ErrorMessage"]): - print(f" ❌ Missing required response fields") - return False - - # Check for expected failure - if should_fail: - if data["ErrorNumber"] == 0: - print(f" ❌ Expected error but got success") - return False - print(f" ✅ Correctly returned error: {data['ErrorMessage']}") - return True + safetymon.Connected = True - # Check for success - if data["ErrorNumber"] != 0: - print(f" ❌ Error: {data['ErrorMessage']} (Code: {data['ErrorNumber']})") - return False - - # Check value type if specified - if expected_type and "Value" in data: - if not isinstance(data["Value"], expected_type): - print(f" ❌ Expected {expected_type.__name__}, got {type(data['Value']).__name__}") - return False - - print(f" ✅ Success - Value: {data.get('Value', 'N/A')}") - return True + # Wait for connection to complete (handling async behavior) + attempts = 0 + while attempts < 10: + if safetymon.Connected: + break + if hasattr(safetymon, 'Connecting') and safetymon.Connecting: + log("Device is connecting...", "WAIT") + time.sleep(1) + attempts += 1 + if safetymon.Connected: + log("Successfully connected to device.") + else: + log("Failed to connect: Timed out.", "ERROR") + return + except Exception as e: - print(f" ❌ Exception: {e}") - return False - - def test_put(self, endpoint: str, data: Dict[str, Any], - should_fail: bool = False) -> bool: - """Test PUT endpoint""" - url = f"{self.base_url}/api/v1/safetymonitor/{self.device_num}/{endpoint}" - form_data = { - "ClientTransactionID": self.get_next_transaction_id(), - **data - } - + log(f"Exception during connection: {e}", "ERROR") + return + + # 3. Test Standard Device Properties (Metadata) + # ---------------------------------------------------------------------- + log("--- Querying Device Information ---") try: - response = requests.put(url, data=form_data, timeout=5) - resp_data = response.json() - - # Check response structure - if not all(key in resp_data for key in ["ClientTransactionID", "ServerTransactionID", - "ErrorNumber", "ErrorMessage"]): - print(f" ❌ Missing required response fields") - return False - - # Check for expected failure - if should_fail: - if resp_data["ErrorNumber"] == 0: - print(f" ❌ Expected error but got success") - return False - print(f" ✅ Correctly returned error: {resp_data['ErrorMessage']}") - return True - - # Check for success - if resp_data["ErrorNumber"] != 0: - print(f" ❌ Error: {resp_data['ErrorMessage']} (Code: {resp_data['ErrorNumber']})") - return False - - print(f" ✅ Success") - return True - + name = safetymon.Name + log(f"Name: {name}") except Exception as e: - print(f" ❌ Exception: {e}") - return False - - def run_test(self, name: str, test_func) -> None: - """Run a test and track results""" - print(f"\n{name}") - if test_func(): - self.passed += 1 - else: - self.failed += 1 - - def test_management_api(self) -> None: - """Test management API endpoints""" - print("\n" + "="*60) - print("MANAGEMENT API TESTS") - print("="*60) - - # Test API versions + log(f"Failed to read Name: {e}", "WARN") + try: - response = requests.get(f"{self.base_url}/management/apiversions", timeout=5) - versions = response.json() - if 1 in versions: - print("\n✅ API Versions - Supports v1") - self.passed += 1 - else: - print("\n❌ API Versions - Missing v1") - self.failed += 1 + desc = safetymon.Description + log(f"Description: {desc}") except Exception as e: - print(f"\n❌ API Versions - Exception: {e}") - self.failed += 1 - - # Test description + log(f"Failed to read Description: {e}", "WARN") + try: - response = requests.get(f"{self.base_url}/management/v1/description", timeout=5) - desc = response.json() - if "ServerName" in desc: - print(f"✅ Server Description - {desc['ServerName']}") - self.passed += 1 - else: - print("❌ Server Description - Missing ServerName") - self.failed += 1 + driver_info = safetymon.DriverInfo + log(f"Driver Info: {driver_info}") except Exception as e: - print(f"❌ Server Description - Exception: {e}") - self.failed += 1 - - # Test configured devices + log(f"Failed to read DriverInfo: {e}", "WARN") + try: - response = requests.get(f"{self.base_url}/management/v1/configureddevices", timeout=5) - devices = response.json() - if isinstance(devices, list) and len(devices) > 0: - print(f"✅ Configured Devices - Found {len(devices)} device(s)") - self.passed += 1 - else: - print("❌ Configured Devices - No devices found") - self.failed += 1 + driver_version = safetymon.DriverVersion + log(f"Driver Version: {driver_version}") except Exception as e: - print(f"❌ Configured Devices - Exception: {e}") - self.failed += 1 - - def test_common_endpoints(self) -> None: - """Test common device endpoints""" - print("\n" + "="*60) - print("COMMON DEVICE ENDPOINT TESTS") - print("="*60) - - self.run_test("Test: Name", - lambda: self.test_get("name", str)) - - self.run_test("Test: Description", - lambda: self.test_get("description", str)) - - self.run_test("Test: DriverInfo", - lambda: self.test_get("driverinfo", str)) - - self.run_test("Test: DriverVersion", - lambda: self.test_get("driverversion", str)) + log(f"Failed to read DriverVersion: {e}", "WARN") - self.run_test("Test: InterfaceVersion", - lambda: self.test_get("interfaceversion", int)) - - self.run_test("Test: SupportedActions", - lambda: self.test_get("supportedactions", list)) - - self.run_test("Test: Connected (GET)", - lambda: self.test_get("connected", bool)) - - self.run_test("Test: Connecting (GET)", - lambda: self.test_get("connecting", bool)) - - def test_connection_workflow(self) -> None: - """Test connection/disconnection workflow""" - print("\n" + "="*60) - print("CONNECTION WORKFLOW TESTS") - print("="*60) - - # Ensure disconnected first - self.run_test("Test: Disconnect", - lambda: self.test_put("connected", {"Connected": "false"})) - - time.sleep(1) - - # Connect - self.run_test("Test: Connect", - lambda: self.test_put("connected", {"Connected": "true"})) - - time.sleep(2) # Wait for initial detection - - # Verify connected - self.run_test("Test: Verify Connected", - lambda: self.test_get("connected", bool)) - - def test_safetymonitor_endpoints(self) -> None: - """Test SafetyMonitor-specific endpoints""" - print("\n" + "="*60) - print("SAFETYMONITOR SPECIFIC TESTS") - print("="*60) - - self.run_test("Test: IsSafe (while connected)", - lambda: self.test_get("issafe", bool)) - - self.run_test("Test: DeviceState", - lambda: self.test_get("devicestate", list)) - - def test_error_conditions(self) -> None: - """Test error handling""" - print("\n" + "="*60) - print("ERROR HANDLING TESTS") - print("="*60) - - # Test invalid device number - old_device = self.device_num - self.device_num = 99 - self.run_test("Test: Invalid Device Number", - lambda: self.test_get("name", should_fail=True)) - self.device_num = old_device - - # Test deprecated methods - self.run_test("Test: CommandBlind (deprecated)", - lambda: self.test_put("commandblind", {"Command": "test", "Raw": "false"}, - should_fail=True)) - - # Test unsupported action - self.run_test("Test: Unsupported Action", - lambda: self.test_put("action", {"Action": "UnsupportedAction", "Parameters": ""}, - should_fail=True)) - - # Disconnect and test IsSafe (should fail) - self.test_put("connected", {"Connected": "false"}) - time.sleep(1) - self.run_test("Test: IsSafe (while disconnected)", - lambda: self.test_get("issafe", should_fail=True)) - - def run_all_tests(self) -> None: - """Run all tests""" - print("="*60) - print("ASCOM ALPACA SAFETYMONITOR API COMPLIANCE TESTS") - print("="*60) - print(f"Testing: {self.base_url}") - print(f"Device Number: {self.device_num}") - - # Check if server is running try: - response = requests.get(f"{self.base_url}/management/apiversions", timeout=5) - print(f"✅ Server is responding") + interface_version = safetymon.InterfaceVersion + log(f"Interface Ver: {interface_version}") except Exception as e: - print(f"❌ Cannot connect to server: {e}") - print("\nPlease ensure the Alpaca server is running:") - print(" python alpaca_safety_monitor.py") - sys.exit(1) - - # Run test suites - self.test_management_api() - self.test_common_endpoints() - self.test_connection_workflow() - self.test_safetymonitor_endpoints() - self.test_error_conditions() - - # Print summary - print("\n" + "="*60) - print("TEST SUMMARY") - print("="*60) - total = self.passed + self.failed - print(f"Total Tests: {total}") - print(f"✅ Passed: {self.passed}") - print(f"❌ Failed: {self.failed}") - - if self.failed == 0: - print("\n🎉 All tests passed! ASCOM Alpaca API is compliant.") - sys.exit(0) - else: - print(f"\n⚠️ {self.failed} test(s) failed.") - sys.exit(1) + log(f"Failed to read InterfaceVersion: {e}", "WARN") + # 4. Test SafetyMonitor Specific Property: IsSafe + # ---------------------------------------------------------------------- + log("--- Testing Safety State ---") + try: + # Poll safety state a few times + for i in range(3): + is_safe = safetymon.IsSafe + status_str = "SAFE" if is_safe else "UNSAFE" + log(f"Check {i+1}: Environment is {status_str} (IsSafe={is_safe})") + time.sleep(1) + except NotConnectedException: + log("Error: Device reported Not Connected while reading IsSafe.", "ERROR") + except DriverException as e: + log(f"Driver Error reading IsSafe: {e}", "ERROR") + except Exception as e: + log(f"Unexpected error reading IsSafe: {e}", "ERROR") -def main(): - """Main entry point""" - import argparse - - parser = argparse.ArgumentParser(description="Test ASCOM Alpaca SafetyMonitor API") - parser.add_argument("--url", default=BASE_URL, - help=f"Base URL (default: {BASE_URL})") - parser.add_argument("--device", type=int, default=DEVICE_NUM, - help=f"Device number (default: {DEVICE_NUM})") - - args = parser.parse_args() - - tester = AlpacaAPITester(args.url, args.device) - tester.run_all_tests() + # 5. Test Supported Actions (Optional) + # ---------------------------------------------------------------------- + log("--- Querying Supported Actions ---") + try: + actions = safetymon.SupportedActions + if actions: + log(f"Supported Actions: {actions}") + else: + log("No custom actions supported.") + except Exception as e: + log(f"Failed to read SupportedActions: {e}", "WARN") + + # 6. Test Disconnection (Updated for Async) + # ---------------------------------------------------------------------- + log("--- Disconnecting ---") + try: + # Send disconnect request + safetymon.Connected = False + + # Wait for disconnection to complete + attempts = 0 + disconnected = False + + while attempts < 10: + # Check if device is done "Connecting" (which handles disconnects too) + if hasattr(safetymon, 'Connecting') and safetymon.Connecting: + log("Device is disconnecting...", "WAIT") + time.sleep(1) + elif not safetymon.Connected: + disconnected = True + break + else: + # Not connecting, but still reports Connected=True? + # Give it a moment to reflect state + time.sleep(1) + + attempts += 1 + + if disconnected: + log("Successfully disconnected.") + else: + log("Warning: Device still reports Connected=True after timeout.", "WARN") + + except Exception as e: + log(f"Exception during disconnection: {e}", "ERROR") + except AlpacaRequestException as e: + log(f"Communication Error (Alpaca Request Failed): {e}", "CRITICAL") + except KeyboardInterrupt: + log("Test interrupted by user.", "WARN") + except Exception as e: + log(f"An unexpected error occurred: {e}", "CRITICAL") + finally: + log("Test sequence completed.") if __name__ == "__main__": - main() + test_safety_monitor() \ No newline at end of file