|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import argparse |
| 4 | +import json |
| 5 | +import logging |
| 6 | +import os |
| 7 | +import random |
| 8 | +import signal |
| 9 | +import sys |
| 10 | +import time |
| 11 | +from contextlib import suppress |
| 12 | +from typing import Any, Dict |
| 13 | + |
| 14 | +# set PYTHONPATH=%cd%\src;%PYTHONPATH% |
| 15 | +# python notebooks/test_comms.py |
| 16 | +# Import LIFU modules |
| 17 | +from openlifu.io.LIFUInterface import LIFUInterface |
| 18 | + |
| 19 | +# Set up logging with timestamps |
| 20 | +logging.basicConfig( |
| 21 | + level=logging.INFO, |
| 22 | + format='%(asctime)s - %(levelname)s - %(message)s' |
| 23 | +) |
| 24 | + |
| 25 | +# ---- Globals ---- |
| 26 | +CURRENT_IFC: dict[str, LIFUInterface | None] = {"iface": None} |
| 27 | + |
| 28 | +# Signal handling for graceful exit |
| 29 | +def signal_handler(_signum: int, _frame: Any) -> None: |
| 30 | + print("\n\n🛑 Stopping test gracefully...") |
| 31 | + try: |
| 32 | + if CURRENT_IFC["iface"] is not None: |
| 33 | + CURRENT_IFC["iface"].close() |
| 34 | + logging.info("Interface safely closed.") |
| 35 | + except Exception as e: |
| 36 | + logging.warning(f"Error closing interface: {e}") |
| 37 | + finally: |
| 38 | + CURRENT_IFC["iface"] = None |
| 39 | + sys.exit(0) |
| 40 | + |
| 41 | +signal.signal(signal.SIGINT, signal_handler) |
| 42 | + |
| 43 | +# Constants for test validation |
| 44 | +EXPECTED_VERSION = ["v2.0.1", "v1.0.14"] |
| 45 | +EXPECTED_TX7332_COUNT = 2 |
| 46 | +TEMP_MIN = 20.0 |
| 47 | +TEMP_MAX = 50.0 |
| 48 | +FREQ_MIN = 10 |
| 49 | +FREQ_MAX = 40 |
| 50 | +PULSE_WIDTH_MIN = 500 |
| 51 | +PULSE_WIDTH_MAX = 5000 |
| 52 | +PULSE_INTERVAL_MIN = 125000 |
| 53 | +PULSE_INTERVAL_MAX = 500000 |
| 54 | +PULSE_COUNT_MIN = 1 |
| 55 | +PULSE_COUNT_MAX = 10 |
| 56 | +PULSE_TRAIN_COUNT_MIN = 1 |
| 57 | +PULSE_TRAIN_COUNT_MAX = 10 |
| 58 | + |
| 59 | +# Trigger modes (use actual values from your code) |
| 60 | +TRIGGER_MODE_SEQUENCE = 1 |
| 61 | +TRIGGER_MODE_CONTINUOUS = 2 |
| 62 | +TRIGGER_MODE_SINGLE = 3 |
| 63 | + |
| 64 | +# Test parameters |
| 65 | +TEST_ITERATIONS = int(os.getenv("TEST_ITERATIONS", "5")) |
| 66 | +TIMEOUT = int(os.getenv("TEST_TIMEOUT", "3")) |
| 67 | + |
| 68 | +# Helper: Safe execution (no concurrency). Logs duration and errors, returns None on failure. |
| 69 | +def safe_execute(func, description: str, *args, **kwargs) -> Any: |
| 70 | + start = time.time() |
| 71 | + try: |
| 72 | + result = func(*args, **kwargs) |
| 73 | + elapsed = time.time() - start |
| 74 | + if elapsed > TIMEOUT: |
| 75 | + logging.warning(f"⚠ '{description}' took {elapsed:.2f}s (> {TIMEOUT}s)") |
| 76 | + else: |
| 77 | + logging.info(f"✅ '{description}' completed in {elapsed:.2f}s") |
| 78 | + return result |
| 79 | + except Exception as e: |
| 80 | + logging.error(f"❌ Failed to {description}: {e}") |
| 81 | + return None |
| 82 | + |
| 83 | +# Helper: Check if value is in valid range |
| 84 | +def is_in_range(value: float, min_val: float, max_val: float) -> bool: |
| 85 | + return min_val <= value <= max_val |
| 86 | + |
| 87 | +# Helper: Generate random valid trigger parameters |
| 88 | +def generate_random_trigger_params() -> Dict[str, Any]: |
| 89 | + return { |
| 90 | + "TriggerFrequencyHz": random.randint(FREQ_MIN, FREQ_MAX), |
| 91 | + "TriggerPulseCount": random.randint(PULSE_COUNT_MIN, PULSE_COUNT_MAX), |
| 92 | + "TriggerPulseWidthUsec": random.randint(PULSE_WIDTH_MIN, PULSE_WIDTH_MAX), |
| 93 | + "TriggerPulseTrainInterval": random.randint(PULSE_INTERVAL_MIN, PULSE_INTERVAL_MAX), |
| 94 | + "TriggerPulseTrainCount": random.randint(PULSE_TRAIN_COUNT_MIN, PULSE_TRAIN_COUNT_MAX), |
| 95 | + "TriggerMode": random.choice([TRIGGER_MODE_SEQUENCE, TRIGGER_MODE_CONTINUOUS, TRIGGER_MODE_SINGLE]), |
| 96 | + "ProfileIndex": 0, |
| 97 | + "ProfileIncrement": 0 |
| 98 | + } |
| 99 | + |
| 100 | +# Helper: Compare two dicts (allowing small float differences) |
| 101 | +def dicts_equal_with_tolerance(d1: Dict, d2: Dict, tolerance: float = 1e-6) -> bool: |
| 102 | + if set(d1.keys()) != set(d2.keys()): |
| 103 | + return False |
| 104 | + for k, v1 in d1.items(): |
| 105 | + v2 = d2.get(k) |
| 106 | + if isinstance(v1, float) and isinstance(v2, float): |
| 107 | + if abs(v1 - v2) > tolerance: |
| 108 | + return False |
| 109 | + elif v1 != v2: |
| 110 | + return False |
| 111 | + return True |
| 112 | +def _as_int(x): |
| 113 | + try: |
| 114 | + return int(x) |
| 115 | + except Exception: |
| 116 | + return x |
| 117 | + |
| 118 | +def _as_float(x): |
| 119 | + try: |
| 120 | + return float(x) |
| 121 | + except Exception: |
| 122 | + return x |
| 123 | + |
| 124 | +# Compare only the fields we set, allow device-side coercions and small float diffs |
| 125 | +def trigger_dict_matches_set(set_params: Dict[str, Any], |
| 126 | + reply: Dict[str, Any], |
| 127 | + tol: float = 1e-6) -> bool: |
| 128 | + if not isinstance(reply, dict): |
| 129 | + return False |
| 130 | + keys_to_check = [ |
| 131 | + "TriggerFrequencyHz", |
| 132 | + "TriggerPulseCount", |
| 133 | + "TriggerPulseWidthUsec", |
| 134 | + "TriggerPulseTrainInterval", |
| 135 | + "TriggerPulseTrainCount", |
| 136 | + "TriggerMode", |
| 137 | + "ProfileIndex", |
| 138 | + "ProfileIncrement", |
| 139 | + ] |
| 140 | + for k in keys_to_check: |
| 141 | + v_set = set_params.get(k) |
| 142 | + v_rep = reply.get(k) |
| 143 | + # allow numeric type coercions |
| 144 | + if isinstance(v_set, float) or isinstance(v_rep, float): |
| 145 | + if abs(_as_float(v_set) - _as_float(v_rep)) > tol: |
| 146 | + return False |
| 147 | + elif _as_int(v_set) != _as_int(v_rep): |
| 148 | + return False |
| 149 | + return True |
| 150 | + |
| 151 | +# Main test function |
| 152 | +def run_tx_test() -> int: |
| 153 | + print("🚀 Starting LIFU Transmitter Module Test Script...") |
| 154 | + test_results = {"total": 0, "passed": 0, "failed": 0} |
| 155 | + |
| 156 | + try: |
| 157 | + # Initialize interface |
| 158 | + CURRENT_IFC["iface"] = LIFUInterface() |
| 159 | + tx_connected, hv_connected = CURRENT_IFC["iface"].is_device_connected() |
| 160 | + if not tx_connected and hv_connected: |
| 161 | + print("❌ LIFU Device not fully connected. Attempting to power 12V...") |
| 162 | + with suppress(Exception): |
| 163 | + CURRENT_IFC["iface"].hvcontroller.turn_12v_on() |
| 164 | + time.sleep(2) |
| 165 | + |
| 166 | + # If there's monitoring that interferes with reconnection, stop it (ignore if missing) |
| 167 | + with suppress(Exception): |
| 168 | + CURRENT_IFC["iface"].stop_monitoring() |
| 169 | + |
| 170 | + # Re-init |
| 171 | + with suppress(Exception): |
| 172 | + CURRENT_IFC["iface"].close() |
| 173 | + CURRENT_IFC["iface"] = None |
| 174 | + time.sleep(3) |
| 175 | + print("🔄 Reinitializing after 12V power-on...") |
| 176 | + CURRENT_IFC["iface"] = LIFUInterface() |
| 177 | + tx_connected, hv_connected = CURRENT_IFC["iface"].is_device_connected() |
| 178 | + if not tx_connected or not hv_connected: |
| 179 | + print(f"❌ Still not connected. TX: {tx_connected}, HV: {hv_connected}") |
| 180 | + return 1 |
| 181 | + |
| 182 | + if not tx_connected: |
| 183 | + print("❌ TX device not connected. Cannot proceed with tests.") |
| 184 | + return 1 |
| 185 | + |
| 186 | + print("✅ LIFU Transmitter Device connected.") |
| 187 | + |
| 188 | + # Basic connectivity tests |
| 189 | + print(f"\n🔍 Testing: Transmitter (iterating {TEST_ITERATIONS} times)...") |
| 190 | + for i in range(TEST_ITERATIONS): |
| 191 | + print(f" ➤ Iteration {i+1}/{TEST_ITERATIONS}...") |
| 192 | + params = generate_random_trigger_params() |
| 193 | + |
| 194 | + # Test 1: Ping |
| 195 | + print("\n🔍 Testing: Ping...") |
| 196 | + ping_result = safe_execute(CURRENT_IFC["iface"].txdevice.ping, "ping") |
| 197 | + if ping_result is True: |
| 198 | + print("✅ Ping successful (expected: always True)") |
| 199 | + test_results["passed"] += 1 |
| 200 | + else: |
| 201 | + print("❌ Ping failed (should always return True)") |
| 202 | + test_results["failed"] += 1 |
| 203 | + test_results["total"] += 1 |
| 204 | + |
| 205 | + # Test 2: Get Version |
| 206 | + print("\n🔍 Testing: Get Version...") |
| 207 | + version = safe_execute(CURRENT_IFC["iface"].txdevice.get_version, "get version") |
| 208 | + if version in EXPECTED_VERSION: |
| 209 | + print(f"✅ Version correct: {version}") |
| 210 | + test_results["passed"] += 1 |
| 211 | + else: |
| 212 | + print(f"❌ Version mismatch: expected '{EXPECTED_VERSION}', got '{version}'") |
| 213 | + test_results["failed"] += 1 |
| 214 | + test_results["total"] += 1 |
| 215 | + |
| 216 | + # Test 3: Enumerate TX7332 Devices |
| 217 | + print("\n🔍 Testing: Enumerate TX7332 Devices...") |
| 218 | + num_devices = safe_execute(CURRENT_IFC["iface"].txdevice.enum_tx7332_devices, "enum devices") |
| 219 | + if num_devices == EXPECTED_TX7332_COUNT: |
| 220 | + print(f"✅ Found exactly {num_devices} TX7332 devices (expected: {EXPECTED_TX7332_COUNT})") |
| 221 | + test_results["passed"] += 1 |
| 222 | + else: |
| 223 | + print(f"❌ Wrong number of devices: expected {EXPECTED_TX7332_COUNT}, got {num_devices}") |
| 224 | + test_results["failed"] += 1 |
| 225 | + test_results["total"] += 1 |
| 226 | + |
| 227 | + # Test 4: Temperature |
| 228 | + print("\n🔍 Testing: Temperature...") |
| 229 | + temp = safe_execute(CURRENT_IFC["iface"].txdevice.get_temperature, "get temperature") |
| 230 | + if temp is not None and is_in_range(temp, TEMP_MIN, TEMP_MAX): |
| 231 | + print(f"✅ Temperature: {temp:.1f}°C (in valid range: {TEMP_MIN}-{TEMP_MAX}°C)") |
| 232 | + test_results["passed"] += 1 |
| 233 | + else: |
| 234 | + print(f"❌ Temperature out of range: {temp}°C (expected: {TEMP_MIN}-{TEMP_MAX}°C)") |
| 235 | + test_results["failed"] += 1 |
| 236 | + test_results["total"] += 1 |
| 237 | + |
| 238 | + # Test 5: Set & Get Trigger (loop over iterations) |
| 239 | + print(f" Setting trigger: {json.dumps(params, indent=2)}") |
| 240 | + try: |
| 241 | + set_reply = safe_execute( |
| 242 | + CURRENT_IFC["iface"].txdevice.set_trigger_json, |
| 243 | + "set trigger", |
| 244 | + data=params |
| 245 | + ) |
| 246 | + except TypeError: |
| 247 | + # If API expects a positional dict |
| 248 | + set_reply = safe_execute(lambda p=params: CURRENT_IFC["iface"].txdevice.set_trigger_json(p), "set trigger") |
| 249 | + |
| 250 | + # Interpret success: dict reply means success; True also okay (legacy) |
| 251 | + if isinstance(set_reply, dict) or set_reply is True: |
| 252 | + if isinstance(set_reply, dict): |
| 253 | + if trigger_dict_matches_set(params, set_reply): |
| 254 | + print(" ✅ Trigger set successfully (echo validated).") |
| 255 | + else: |
| 256 | + print(" ⚠ Trigger set returned but values differ after device coercion:") |
| 257 | + print(" ", json.dumps(set_reply, indent=2)) |
| 258 | + else: |
| 259 | + print(" ✅ Trigger set successfully.") |
| 260 | + else: |
| 261 | + print(f" ❌ Failed to set trigger: {set_reply}") |
| 262 | + test_results["failed"] += 1 |
| 263 | + test_results["total"] += 1 |
| 264 | + continue # skip read-back on failure |
| 265 | + |
| 266 | + |
| 267 | + # Read back trigger |
| 268 | + get_result = safe_execute(CURRENT_IFC["iface"].txdevice.get_trigger_json, "get trigger") |
| 269 | + if get_result is None: |
| 270 | + print(" ❌ Failed to read trigger after set.") |
| 271 | + test_results["failed"] += 1 |
| 272 | + else: |
| 273 | + print(f" Read back: {json.dumps(get_result, indent=2)}") |
| 274 | + |
| 275 | + # Validate all fields |
| 276 | + passed = True |
| 277 | + for key in params: |
| 278 | + if key == "TriggerMode": |
| 279 | + if get_result.get(key) != params[key]: |
| 280 | + print(f" ❌ TriggerMode mismatch: expected {params[key]}, got {get_result.get(key)}") |
| 281 | + passed = False |
| 282 | + elif key in [ |
| 283 | + "TriggerFrequencyHz", "TriggerPulseCount", "TriggerPulseWidthUsec", |
| 284 | + "TriggerPulseTrainInterval", "TriggerPulseTrainCount" |
| 285 | + ]: |
| 286 | + val1 = params[key] |
| 287 | + val2 = get_result.get(key) |
| 288 | + if val2 is None or abs(float(val1) - float(val2)) > 1e-6: |
| 289 | + print(f" ❌ {key} mismatch: expected {val1}, got {val2}") |
| 290 | + passed = False |
| 291 | + else: |
| 292 | + # Skip profile fields for now |
| 293 | + continue |
| 294 | + |
| 295 | + if passed: |
| 296 | + print(" ✅ All trigger values match!") |
| 297 | + test_results["passed"] += 1 |
| 298 | + else: |
| 299 | + test_results["failed"] += 1 |
| 300 | + test_results["total"] += 1 |
| 301 | + |
| 302 | + # Final Summary |
| 303 | + print("\n" + "="*70) |
| 304 | + print("📊 TEST SUMMARY") |
| 305 | + print(f"Total Tests: {test_results['total']}") |
| 306 | + print(f"Passed: {test_results['passed']}") |
| 307 | + print(f"Failed: {test_results['failed']}") |
| 308 | + print("="*70) |
| 309 | + |
| 310 | + if test_results["failed"] == 0: |
| 311 | + print("🎉 ALL TESTS PASSED!") |
| 312 | + return 0 |
| 313 | + else: |
| 314 | + print("❌ Some tests failed.") |
| 315 | + return 1 |
| 316 | + |
| 317 | + except Exception as e: |
| 318 | + logging.critical(f"Unexpected error during test: {e}") |
| 319 | + return 1 |
| 320 | + |
| 321 | + finally: |
| 322 | + if CURRENT_IFC["iface"] is not None: |
| 323 | + with suppress(Exception): |
| 324 | + CURRENT_IFC["iface"].close() |
| 325 | + logging.info("Interface safely closed.") |
| 326 | + CURRENT_IFC["iface"] = None |
| 327 | + |
| 328 | +if __name__ == "__main__": |
| 329 | + # Argument parsing |
| 330 | + parser = argparse.ArgumentParser(description="Test LIFU Transmitter Module") |
| 331 | + parser.add_argument('--iterations', type=int, default=5, help='Number of trigger test iterations') |
| 332 | + parser.add_argument('--timeout', type=int, default=3, help='Soft timeout (seconds) for warning logs') |
| 333 | + args = parser.parse_args() |
| 334 | + |
| 335 | + # Override defaults from environment or args |
| 336 | + TEST_ITERATIONS = args.iterations |
| 337 | + TIMEOUT = args.timeout |
| 338 | + |
| 339 | + # Run test |
| 340 | + sys.exit(run_tx_test()) |
0 commit comments