diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml
index 36ad0a3..64c0cb6 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
@@ -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 006f208..e8384db 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
@@ -75,7 +75,7 @@ jobs:
merge:
name: Merge Multi-Arch Image
needs: build
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, Linux, X64]
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
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/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,
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 %}
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