Skip to content

Commit f4ecada

Browse files
updated uart handling of soft reset and made it a fire and forget command, also added a communication stress test script for the transmitter
1 parent d9862a9 commit f4ecada

4 files changed

Lines changed: 400 additions & 13 deletions

File tree

notebooks/test_comms.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
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())

notebooks/test_tx_trigger.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import time
55

66
from openlifu.io.LIFUConfig import (
7+
TRIGGER_MODE_CONTINUOUS,
78
TRIGGER_MODE_SEQUENCE,
89
TRIGGER_MODE_SINGLE,
910
)
@@ -81,11 +82,11 @@ def main():
8182

8283
json_trigger_data = {
8384
"TriggerFrequencyHz": params["freq"],
84-
"TriggerPulseCount": 10,
85+
"TriggerPulseCount": 5,
8586
"TriggerPulseWidthUsec": params["pulse_width"],
86-
"TriggerPulseTrainInterval": 300000,
87-
"TriggerPulseTrainCount": 10,
88-
"TriggerMode": TRIGGER_MODE_SEQUENCE, # Change to TRIGGER_MODE_CONTINUOUS or TRIGGER_MODE_SEQUENCE or TRIGGER_MODE_SINGLE as needed
87+
"TriggerPulseTrainInterval": 600000,
88+
"TriggerPulseTrainCount": 3,
89+
"TriggerMode": TRIGGER_MODE_CONTINUOUS, # Change to TRIGGER_MODE_CONTINUOUS or TRIGGER_MODE_SEQUENCE or TRIGGER_MODE_SINGLE as needed
8990
"ProfileIndex": 0,
9091
"ProfileIncrement": 0
9192
}

src/openlifu/io/LIFUTXDevice.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -744,14 +744,9 @@ def soft_reset(self, module:int=1) -> bool:
744744
if not self.uart.is_connected():
745745
raise ValueError("TX Device not connected")
746746

747-
r = self.uart.send_packet(id=None, packetType=OW_CONTROLLER, command=OW_CMD_RESET, addr=module)
747+
self.uart.send_packet(id=None, packetType=OW_CONTROLLER, command=OW_CMD_RESET, addr=module)
748748
self.uart.clear_buffer()
749-
# r.print_packet()
750-
if r.packet_type == OW_ERROR:
751-
logger.error("Error resetting device")
752-
return False
753-
else:
754-
return True
749+
return True
755750
except ValueError as v:
756751
logger.error("ValueError: %s", v)
757752
raise # Re-raise the exception for the caller to handle

0 commit comments

Comments
 (0)