From e915a220890c4431cf0f7d8702db223ee00dc21c Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 14:02:16 -0700 Subject: [PATCH 01/15] UTW testing logic updated in bue_main.py --- bue_main.py | 458 +++++++++++++++++++++-------------------- setup/message_dict.txt | 22 +- 2 files changed, 246 insertions(+), 234 deletions(-) diff --git a/bue_main.py b/bue_main.py index c2ac287..a6fd1ed 100644 --- a/bue_main.py +++ b/bue_main.py @@ -32,7 +32,10 @@ class State(Enum): INIT = auto() CONNECT_OTA = auto() IDLE = auto() + WAIT_FOR_START = auto() UTW_TEST = auto() + TEST_CLEANUP = auto() + class bUE_Main: @@ -95,8 +98,17 @@ def __init__(self, yaml_str = "bue_config.yaml"): # Network information self.ota_base_station_id = None self.ota_test_params = None - - + + # Variables to handle test subprocess + self.test_command = None + self.test_start_time = None + self.test_process = None + self.test_stdout_queue = queue.Queue() + self.test_stdout_thread = None + + # Flags to show if test subprocess ended succesfully or not + self.test_ended_successfully = False + self.test_ended_unsuccessfully = False # Build the state machine - flags self.counter_ota_timeout = 0 @@ -109,9 +121,10 @@ def __init__(self, yaml_str = "bue_config.yaml"): # TODO - delete/modify these once test functionality gets added # Buffer to hold outputs from the UTW script (like helloworld) - self.test_output_buffer = [] - self.test_output_lock = threading.RLock() - # TODO - modify these once test functionality gets added + # self.test_output_buffer = [] + # self.test_output_lock = threading.RLock() + # TODO - modify these once test functionality gets added. + # UPDATE - I think I fixed it. See ota_send_update(). This is Ty : ) @@ -274,21 +287,6 @@ def ota_idle_ping(self): self.status_ota_connected = False - def ota_send_update(self): - lat, long = self.gps_handler() - logger.info(f"Sent UPD to {self.ota_base_station_id}") - - with self.test_output_lock: - if self.test_output_buffer: - for line in self.test_output_buffer: - self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},{line}")) - logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {line}") - time.sleep(0.4) # Sleep so UART does not get overwhelmed - self.test_output_buffer.clear() - - else: # If there is no message send it blank - self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},")) - logger.info(f"Sent UPD to {self.ota_base_station_id} with no console output") def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=10): start_time = time.time() @@ -334,183 +332,7 @@ def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runt return "", "" - # This function handles operations that happen while a bUE is in the TESTING state - def test_handler(self, input): # Input Format: TEST,,, - if not ";" in input: ## TODO: Perform other checks - try: - self.ota.send_ota_message(self.ota_base_station_id, "PREPR") - - parts = input.split(",", maxsplit=3) - print(parts) - if len(parts) < 4: - raise ValueError(f"Invalid input format: {input}") - file = parts[1] - start_time = int(parts[2]) - parameters = parts[3].split(" ") - - self.is_testing = True - self.cancel_test = False - self.test_output_buffer = [] - - current_time = int(time.time()) - if start_time > current_time: - wait_duration = start_time - current_time - logger.info(f"Waiting {wait_duration} seconds until start time {start_time}") - - # Wait in small increments to allow for cancellation - while int(time.time()) < start_time and not self.cancel_test: - time.sleep(0.001) - - if self.cancel_test: - logger.info("Test cancelled during wait period") - self.ota.send_ota_message(self.ota_base_station_id, "CANCD") - return - - logger.info(f"Starting test at scheduled time: {start_time}") - - with self.test_output_lock: - self.test_output_buffer.clear() - - print(["python3", f"{file}.py"] + parameters) - - if parameters == [""]: - parameters = None - - if parameters: - process = subprocess.Popen( - ["python3", f"{file}.py"] + parameters, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE, # Needed to send keystrokes - bufsize=1, # Line-buffered - universal_newlines=True, # Text mode, also enables line buffering - text=True, # decode bytes to str - encoding="utf-8", - errors="replace", - ) - else: - process = subprocess.Popen( - ["python3", f"{file}.py"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE, # Needed to send keystrokes - bufsize=1, # Line-buffered - universal_newlines=True, # Text mode, also enables line buffering - text=True, # decode bytes to str - encoding="utf-8", - errors="replace", - ) - - logger.info(f"Started test script: {file}.py with parameters {parameters}") - - def reader_thread(pipe, output_queue): - for line in iter(pipe.readline, ""): - output_queue.put(line) - pipe.close() - - stdout_queue = queue.Queue() - stderr_queue = queue.Queue() - - # Threads that will be looking in the stdout and stderr termainals for messages while - # the TEST process runs - threading.Thread( - target=reader_thread, - args=(process.stdout, stdout_queue), - daemon=True, - ).start() - threading.Thread( - target=reader_thread, - args=(process.stderr, stderr_queue), - daemon=True, - ).start() - - """ - # Process will go to completion unless unless the system receives a CANC message from the base station. - # The CANC message is received and processed by the utw thread - - # We also collect all the terminal outputs from the script so we can send them back to the base station - """ - while process.poll() is None: # poll() returns None if process hasn't terminated - # Get all normal terminal outputs - try: - stdout_line = stdout_queue.get_nowait() - clean_line = f"[{file}.py STDOUT] {stdout_line.strip()}" - logger.info(clean_line) - with self.test_output_lock: - if " rx_" in clean_line: - self.test_output_buffer.append(f"STDOUT: {clean_line}") - # elif "CRC invalid" in clean_line: - # self.test_output_buffer.append(f"STDOUT: {clean_line}") - except queue.Empty: - pass - - try: - stderr_line = stderr_queue.get_nowait() - clean_line = f"[{file}.py STDERR] {stderr_line.strip()}" - logger.error(clean_line) - with self.test_output_lock: - self.test_output_buffer.append(f"STDERR: {clean_line}") - except queue.Empty: - pass - - if self.cancel_test: - print("TRYING TO CANCEL") - print("TRYING TO CANCEL") - print("TRYING TO CANCEL") - logger.info(f"Sending termination to: {file}.py") - try: - process.send_signal(signal.SIGINT) - except Exception as e: - logger.error(f"Failed to terminate: {e}") - break - time.sleep(0.1) - - """ - Leave this code in! If we want all the output messages from the bUE uncomment it. Otherwise, - we will only get the output messages before it ends/receives a CANC - """ - # while not stdout_queue.empty(): - # line = stdout_queue.get() - # clean_line = f"[{file}.py STDOUT] {line.strip()}" - # logger.info(clean_line) - # with self.test_output_lock: - # if "rx msg:" in clean_line: - # self.test_output_buffer.append(f"STDOUT: {clean_line}") - - while not stderr_queue.empty(): - line = stderr_queue.get() - clean_line = f"[{file}.py STDERR] {line.strip()}" - logger.error(clean_line) - with self.test_output_lock: - self.test_output_buffer.append(f"STDERR: {clean_line}") - - try: - exit_code = process.wait() - except Exception as e: - logger.error(f"Error waiting for subprocess {file}.py: {e}") - exit_code = -1 - - # If a test is canceled, a CANCD message is sent in responses letting the base station know we have successfully termianted the test - if self.cancel_test: - self.ota.send_ota_message(self.ota_base_station_id, "CANCD") - elif exit_code == 0: - # Any extra messages that have not already been sent to the base station are sent - with self.test_output_lock: - self.ota_send_upd() - - logger.info(f"{file}.py completed successfully.") - self.ota.send_ota_message(self.ota_base_station_id, "DONE") - else: - logger.error(f"{file}.py exited with code {exit_code}") - self.ota.send_ota_message(self.ota_base_station_id, "FAIL") - - except Exception as e: - logger.info(f"TEST could not be run: {e}") - self.ota.send_ota_message(self.ota_base_station_id, "FAIL") - finally: - self.is_testing = False - self.cancel_test = False - + ### UTW MODULE METHODS ### def utw_task_queue_handler(self): @@ -523,26 +345,151 @@ def utw_task_queue_handler(self): pass # Instead of PINGs, bUEs send UPDs while in UTW_TEST state so the base still knows there is a connection - def ota_send_upd(self): + def ota_send_update(self): lat, long = self.gps_handler() - # self.ota.send_ota_message(self.ota_base_station_id, f"UPD:{lat},{long}") logger.info(f"Sent UPD to {self.ota_base_station_id}") - with self.test_output_lock: - if self.test_output_buffer: - for line in self.test_output_buffer: - self.ota.send_ota_message(self.ota_base_station_id, f"UPD:,{lat},{long},{line}") - logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {line}") - time.sleep(0.4) # Sleep so UART does not get overwhelmed - self.test_output_buffer.clear() + output_lines = [] + + try: + while True: + line = self.test_stdout_queue.get_nowait() + if line.strip(): # Only add non-empty lines + output_lines.append(line.strip()) + except queue.Empty: + pass # Queue is now empty + + if output_lines: + # Send each line as a separate UPD message + for line in output_lines: + self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},{line}")) + logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {line}") + time.sleep(0.4) # Sleep so UART does not get overwhelmed. TODO: Is this the right value? + else: + # No console output available + self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},")) + logger.info(f"Sent UPD to {self.ota_base_station_id} with no console output") + + + """ + Checks to see if the ota system a valid TEST message from the base station + It is important that we check this before running that actual test subprocess + to prevent needless errors. + + self.ota_test_params format: ,, + parameters are separated by spaces + """ + def test_has_valid_params(self) -> bool: + params_parts: list[str] = self.ota_test_params.split(",", maxsplit=2) + + if len(params_parts) < 3: + logger.warning(f"Invalid parameters used to initalize test: {params_parts}") + ## TODO: Do I need to do something with a flag here? + return False + + file: str = params_parts[0] + self.test_start_time = int(params_parts[1]) + parameters: list[str] = params_parts[2].split(" ") + + # If parameters is blank, it could come with an empty space in an array which we need to check for + parameters = [param for param in parameters if param.strip()] + + self.test_command = ["python3", f"{file}.py"] + (parameters if parameters else []) + + logger.info(f"Test prepared: {file}.py with parameters: {parameters if parameters else 'none'}") + logger.info(f"Test scheduled for: {self.test_start_time}") + + return True + + + """ + A helper function that will monitor the test subprocess stdout and write new complete + lines to the test_stdout_queue + """ + def reader_thread(self, pipe, output_queue): + for line in iter(pipe.readline, ""): + output_queue.put(line) + pipe.close() + + """ + Once a utw test is ready to start, we create the subprocess and pipe all of the stdout + content into the test_stdout_queue to be sent out with messages. + self.status_test_running flag set accordingly + """ + def create_test_process(self): + self.test_process = subprocess.Popen( + self.test_command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + bufsize=1, # Line-buffered + universal_newlines=True, # Text mode, also enables line buffering + text=True, # decode bytes to str + encoding="utf-8", + errors="replace", + ) + + + # Thread to manage reading from stdout + self.test_stdout_thread = threading.Thread( + target=self.reader_thread, + args=(self.test_process.stdout, self.test_stdout_queue), + daemon=True, + ) + + self.test_stdout_thread.start() + + self.status_test_running = True + - else: # If there is no message send it blank - self.ota.send_ota_message(self.ota_base_station_id, f"UPD:,{lat},{long},") - logger.info(f"Sent UPD to {self.ota_base_station_id} with no console output") """ - # This function checks for incoming messages while in the system is the UTW_TEST state. - # The messages received in this state should only be CANC. + Checks on the test subprocess to see if it is still running. + subprocess returns None if still running + subprocess return 0 if it ended successfully + subprocess returns -2 if it was ended by a signal.SIGINT (better none as a CANC) + subprocess returns something else otherwise, meaning it ended unexpectedly + self.status_test_running flag updated accordingly + """ + def check_on_test(self): + if self.test_process is None: + logger.error("Process is none but we are trying to check on it") + return + + exit_code = self.test_process.poll() + + if exit_code is None: + return # The test is still going + + # Test ended successfully + elif exit_code == 0: + self.test_ended_successfully = True + + # Test was terminated with a CANC. When a subprocess is terminated with a signal.SIGINT, + # it returns -2 + elif exit_code == -2: + self.test_ended_successfully = True + + else: + self.test_ended_unsuccessfully = True + + self.status_test_running = False + + def clean_up_test(self): + self.ota_send_update() + + if self.test_stdout_thread and self.test_stdout_thread.is_alive(): + self.test_stdout_thread.join() + # TODO: Need to reset flags here? Or does that occur when we switch to WAIT_FOR_START transition? + + self.test_process = None + self.test_stdout_thread = None + self.status_test_running = False + + + """ + This function checks for incoming messages while in the system is the UTW_TEST state. + The messages received in this state should only be CANC. """ def check_for_cancel(self): @@ -568,9 +515,8 @@ def check_for_cancel(self): logger.error(f"Received unexpected message while in UTW_TEST state: {message}") """ - Restarts the service entirely + """ - def check_for_test_interrupt(self): """ Check periodically to see if the test if CANCELLED, if the service needs to RELOAD, @@ -703,7 +649,11 @@ def bue_tick(self, loop_dur=0.01): self.flag_ota_start_testing.clear() self.nxt_st = State.IDLE + + # If the bUE ever loses connected to the base station, return to CONNECTED_OTA state # + # If the bUE gets a TEST from the base station and that TEST contained valid parameters, + # enter the WAIT_FOR_START state elif self.cur_st == State.IDLE: # If we lost connection we will go back to the connecting state if not self.status_ota_connected: @@ -714,31 +664,77 @@ def bue_tick(self, loop_dur=0.01): self.nxt_st = State.CONNECT_OTA # If we receivied a TEST message from the base station, we switch to UTW_TEST state - elif self.is_testing: + elif self.flag_ota_start_testing.is_set(): counter_idle_ping = 0 # Reset the flags used in testing + self.flag_ota_start_testing.clear() self.flag_ota_cancel_test.clear() self.flag_ota_reload.clear() self.flag_ota_restart.clear() - + self.test_ended_successfully = False + self.test_ended_unsuccessfully = False + # TODO reset other falgs? + if self.test_has_valid_params(): + self.nxt_st = State.WAIT_FOR_START + else: + self.nxt_st = State.IDLE + # TODO: SEND A BAD PARAMETERS MESSAGE? + # + # + # In the WAIT_FOR START state, the bUE is waiting for a certain time to arrive. Once it has, it + # will enter into the UTW_TEST state + # + # If while waiting the bUE receives a CANC message, it will stop waiting and go straight to the + # TEST_CLEANUP state so flags can be reset approriately + # """ + elif self.cur_st == State.WAIT_FOR_START: + current_time: int = int(time.time()) + if(self.flag_ota_cancel_test.is_set()): + self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) + self.test_ended_successfully = True + self.nxt_st = State.TEST_CLEANUP + + elif current_time < self.test_start_time: + self.nxt_st = State.WAIT_FOR_START + + elif current_time >= self.test_start_time: #TODO BRYSON self.nxt_st = State.UTW_TEST + self.create_test_process() + else: + logger.warning("Got to last transition in WAIT_FOR_START. Shouldn't be possible") # + # If the bUE ever receives a CANC while testing, it should response with a CANCD + # message and enter the TEST_CLEANUP state + # + # If the test subprocess is no longer running, the bUE will report how the + # test subprocessed ended and enter the TEST_CLEANUP state + # + # Otherwise, stay in the UTW_TEST state + # elif self.cur_st == State.UTW_TEST: - # If we lost connection we will go straight to the connecting state - if not self.status_ota_connected: - counter_connect_ota = 0 - - # Reset the flags that are used in the connect state - self.flag_ota_connected.clear() + + if self.flag_ota_cancel_test.is_set(): + self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) + self.nxt_st = State.TEST_CLEANUP + + elif not self.status_test_running: + if self.test_ended_successfully: + self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) + self.nxt_st = State.TEST_CLEANUP + elif self.test_ended_unsuccessfully: + self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) + self.nxt_st = State.TEST_CLEANUP + + elif self.status_test_running: + self.nxt_st = State.UTW_TEST - self.nxt_st = State.CONNECT_OTA - # TODO: end the test in this instance - # Once test is complete/terminated, return to the IDLE state - elif not self.is_testing: - counter_idle_ping = 0 - self.nxt_st = State.IDLE + else: + pass # + elif self.cur_st == State.TEST_CLEANUP: + self.nxt_st = State.IDLE + else: logger.error(f"tick: Invalid state transition {self.cur_st.name}") sys.exit(1) @@ -761,6 +757,10 @@ def bue_tick(self, loop_dur=0.01): # Second out a message pinging the base station every IDLE_PING_OTA_INTERVAL seconds if counter_idle_ping % interval_idle_ping == 0: self.ota_task_queue.put(self.ota_idle_ping) + + # + elif self.cur_st == State.WAIT_FOR_START: + pass # elif self.cur_st == State.UTW_TEST: counter_uta_update += 1 @@ -769,6 +769,10 @@ def bue_tick(self, loop_dur=0.01): if counter_uta_update % interval_uta_update == 0: self.ota_task_queue.put(self.ota_send_update) self.ota_task_queue.put(self.check_for_test_interrupt) + self.ota_task_queue.put(self.check_on_test) + # + elif self.cur_st == State.TEST_CLEANUP: + self.ota_task_queue.put(self.ota_send_update) # else: logger.error(f"tick: Invalid state action {self.cur_st.name}") diff --git a/setup/message_dict.txt b/setup/message_dict.txt index 4215aae..4268543 100644 --- a/setup/message_dict.txt +++ b/setup/message_dict.txt @@ -38,7 +38,7 @@ PING: Direction: bUE -> base Meaning: The bUE periodically pings the base station Body: None - Example: (bue_main.py) self.ota.send_ota_message(10, "PING") + Example: (bue_main.py) self.ota.send_ota_message(10, "PING,,") <- NOTICE NO COLON Response: The base station will respond with a PINGR. If too much time passes between PINGR's, the bUE knows it has become disconnected from the network. PINGR: @@ -51,8 +51,8 @@ PINGR: TEST: Direction: base -> bUE Meaning: The base station sends a UTW test configuration, a file, a start time, and parameters. - Body: .. - Example: (bue_main.py) self.ota.send_ota_message(5, TEST,,,) + Body: ,, + Example: (bue_main.py) self.ota.send_ota_message(5, TEST:,,) Response: The bUE will respond with a TESTR, confirming that it has received the test FAIL: @@ -78,7 +78,7 @@ PREPR: Example: (bue_main.py) self.ota.send_ota_message(10, "TESTR:1745004290") Response: None -BEGIN: +BEGIN: (UNUSED) Direction: bUE -> base Meaning: The bUE lets the base station know that it has begun its UTW test Body: None @@ -87,9 +87,17 @@ BEGIN: UPD: Direction: bUE -> base - Meaning: The bUE sends an update on the UTW test; TBD (could be any time a UTW message is received, or just a periodic update) - Body: TBD - Example: (bue_main.py) self.ota.send_ota_message(10, "UPD:") + Meaning: The bUE sends an update on the UTW test; includes current GPS coords and any message that might be in stdout + Body: ,, + Example: (bue_main.py) self.ota.send_ota_message(10, "UPD:,,,") <- NOTICE EXTRA COMMA + Response: None + +MSG: + Direction: bUE -> base + Meaning: The bUE received some sort of output while running an utw test. This output could be anything from a received LoRa message to an error. + This output must be sent back to the base station, line by line + Body: + Example: (bue_main.py) self.ota.send_ota_message(10, "MSG:") Response: None DONE: From 53fafa603b34312d9e73739d661025796d050e8d Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 14:05:07 -0700 Subject: [PATCH 02/15] Syntax in bue_main.py fixed --- bue_main.py | 128 ++++++++++++++++++++++------------------------------ 1 file changed, 55 insertions(+), 73 deletions(-) diff --git a/bue_main.py b/bue_main.py index a6fd1ed..c4b90fb 100644 --- a/bue_main.py +++ b/bue_main.py @@ -28,6 +28,7 @@ TIMEOUT = 6 BROADCAST_OTA_ID = 0 + class State(Enum): INIT = auto() CONNECT_OTA = auto() @@ -37,9 +38,8 @@ class State(Enum): TEST_CLEANUP = auto() - class bUE_Main: - def __init__(self, yaml_str = "bue_config.yaml"): + def __init__(self, yaml_str="bue_config.yaml"): self.yaml_data = {} # Load the yaml file @@ -56,10 +56,7 @@ def __init__(self, yaml_str = "bue_config.yaml"): # Initialize the OTA and UTW objects while True: try: - self.ota = Ota( - self.yaml_data["OTA_PORT"], - self.yaml_data["OTA_BAUDRATE"] - ) + self.ota = Ota(self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"]) break except Exception as e: logger.error(f"Failed to initialize OTA module: {e}") @@ -109,7 +106,7 @@ def __init__(self, yaml_str = "bue_config.yaml"): # Flags to show if test subprocess ended succesfully or not self.test_ended_successfully = False self.test_ended_unsuccessfully = False - + # Build the state machine - flags self.counter_ota_timeout = 0 self.MAX_ota_timeout = TIMEOUT @@ -126,8 +123,6 @@ def __init__(self, yaml_str = "bue_config.yaml"): # TODO - modify these once test functionality gets added. # UPDATE - I think I fixed it. See ota_send_update(). This is Ty : ) - - # Set up the ota threads self.ota_incoming_queue = queue.Queue() self.ota_outgoing_queue = queue.Queue() @@ -248,22 +243,21 @@ def ota_connect_req(self): if self.status_ota_connected: logger.warning(f"connect_ota_req: OTA device is already connected to base station {self.ota_base_station_id}") return - + # Start by checking the flag if self.flag_ota_connected.is_set(): # Our connection request was received, set the status and send an ACK self.status_ota_connected = True - self.flag_ota_connected.clear() = False + self.flag_ota_connected.clear() logger.info(f"ota_connect_req: OTA device is connected to network with base station {self.ota_base_station_id}") # Send the ACK self.ota_outgoing_queue.put((self.ota_base_station_id, "ACK")) return - + # If flag not set, send another REQ message self.ota_outgoing_queue.put((BROADCAST_OTA_ID, f"REQ:{self.hostname},{self.reyax_id}")) - - + def ota_idle_ping(self): if not self.status_ota_connected: logger.warning("ota_idle_ping: OTA device is not connected to base station") @@ -286,8 +280,6 @@ def ota_idle_ping(self): logger.info(f"We have not heard from {self.ota_base_station_id} in too long. Disconnecting...") self.status_ota_connected = False - - def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=10): start_time = time.time() try: @@ -332,7 +324,6 @@ def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runt return "", "" - ### UTW MODULE METHODS ### def utw_task_queue_handler(self): @@ -350,7 +341,7 @@ def ota_send_update(self): logger.info(f"Sent UPD to {self.ota_base_station_id}") output_lines = [] - + try: while True: line = self.test_stdout_queue.get_nowait() @@ -370,7 +361,6 @@ def ota_send_update(self): self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},")) logger.info(f"Sent UPD to {self.ota_base_station_id} with no console output") - """ Checks to see if the ota system a valid TEST message from the base station It is important that we check this before running that actual test subprocess @@ -379,6 +369,7 @@ def ota_send_update(self): self.ota_test_params format: ,, parameters are separated by spaces """ + def test_has_valid_params(self) -> bool: params_parts: list[str] = self.ota_test_params.split(",", maxsplit=2) @@ -386,7 +377,7 @@ def test_has_valid_params(self) -> bool: logger.warning(f"Invalid parameters used to initalize test: {params_parts}") ## TODO: Do I need to do something with a flag here? return False - + file: str = params_parts[0] self.test_start_time = int(params_parts[1]) parameters: list[str] = params_parts[2].split(" ") @@ -401,11 +392,11 @@ def test_has_valid_params(self) -> bool: return True - """ A helper function that will monitor the test subprocess stdout and write new complete lines to the test_stdout_queue """ + def reader_thread(self, pipe, output_queue): for line in iter(pipe.readline, ""): output_queue.put(line) @@ -416,32 +407,30 @@ def reader_thread(self, pipe, output_queue): content into the test_stdout_queue to be sent out with messages. self.status_test_running flag set accordingly """ + def create_test_process(self): self.test_process = subprocess.Popen( - self.test_command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, - bufsize=1, # Line-buffered - universal_newlines=True, # Text mode, also enables line buffering - text=True, # decode bytes to str - encoding="utf-8", - errors="replace", - ) - - + self.test_command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + bufsize=1, # Line-buffered + universal_newlines=True, # Text mode, also enables line buffering + text=True, # decode bytes to str + encoding="utf-8", + errors="replace", + ) + # Thread to manage reading from stdout self.test_stdout_thread = threading.Thread( - target=self.reader_thread, - args=(self.test_process.stdout, self.test_stdout_queue), - daemon=True, - ) - - self.test_stdout_thread.start() - - self.status_test_running = True + target=self.reader_thread, + args=(self.test_process.stdout, self.test_stdout_queue), + daemon=True, + ) + self.test_stdout_thread.start() + self.status_test_running = True """ Checks on the test subprocess to see if it is still running. @@ -451,13 +440,14 @@ def create_test_process(self): subprocess returns something else otherwise, meaning it ended unexpectedly self.status_test_running flag updated accordingly """ - def check_on_test(self): + + def check_on_test(self): if self.test_process is None: logger.error("Process is none but we are trying to check on it") return - exit_code = self.test_process.poll() - + exit_code = self.test_process.poll() + if exit_code is None: return # The test is still going @@ -466,15 +456,15 @@ def check_on_test(self): self.test_ended_successfully = True # Test was terminated with a CANC. When a subprocess is terminated with a signal.SIGINT, - # it returns -2 - elif exit_code == -2: + # it returns -2 + elif exit_code == -2: self.test_ended_successfully = True - + else: self.test_ended_unsuccessfully = True self.status_test_running = False - + def clean_up_test(self): self.ota_send_update() @@ -486,7 +476,6 @@ def clean_up_test(self): self.test_stdout_thread = None self.status_test_running = False - """ This function checks for incoming messages while in the system is the UTW_TEST state. The messages received in this state should only be CANC. @@ -517,9 +506,10 @@ def check_for_cancel(self): """ """ + def check_for_test_interrupt(self): """ - Check periodically to see if the test if CANCELLED, if the service needs to RELOAD, + Check periodically to see if the test if CANCELLED, if the service needs to RELOAD, or if the system needs to RESTART """ if self.flag_ota_cancel_test.is_set(): @@ -537,7 +527,6 @@ def check_for_test_interrupt(self): self.flag_ota_restart.clear() self.restart_system() - def reload_service(self): """ Reloads the service without restarting the system entirely @@ -547,7 +536,6 @@ def reload_service(self): except Exception as e: print(f"Error restarting bue.service': {e}") - def restart_system(self): """ Restarts the entire system using sudo reboot @@ -558,8 +546,6 @@ def restart_system(self): except Exception as e: logger.error(f"Error restarting system: {e}") - - '''def synchronize_time(self, base_timestamp): """ Synchronize bUE time with base station time. @@ -601,7 +587,6 @@ def state_change_logger(self): logger.info(f"state_change_logger: State changed from {self.prv_st.name} to {self.cur_st.name}") self.prv_st = self.cur_st - def bue_tick(self, loop_dur=0.01): # Interconnect flags @@ -624,7 +609,7 @@ def bue_tick(self, loop_dur=0.01): while not self.EXIT: if not self.tick_enabled: - time.sleep(loop_dur) # avoid busy spinning when disabled + time.sleep(loop_dur) # avoid busy spinning when disabled continue loop_start = time.time() @@ -649,10 +634,10 @@ def bue_tick(self, loop_dur=0.01): self.flag_ota_start_testing.clear() self.nxt_st = State.IDLE - + # If the bUE ever loses connected to the base station, return to CONNECTED_OTA state # - # If the bUE gets a TEST from the base station and that TEST contained valid parameters, + # If the bUE gets a TEST from the base station and that TEST contained valid parameters, # enter the WAIT_FOR_START state elif self.cur_st == State.IDLE: # If we lost connection we will go back to the connecting state @@ -681,8 +666,8 @@ def bue_tick(self, loop_dur=0.01): self.nxt_st = State.IDLE # TODO: SEND A BAD PARAMETERS MESSAGE? # - # - # In the WAIT_FOR START state, the bUE is waiting for a certain time to arrive. Once it has, it + # + # In the WAIT_FOR START state, the bUE is waiting for a certain time to arrive. Once it has, it # will enter into the UTW_TEST state # # If while waiting the bUE receives a CANC message, it will stop waiting and go straight to the @@ -690,15 +675,15 @@ def bue_tick(self, loop_dur=0.01): # """ elif self.cur_st == State.WAIT_FOR_START: current_time: int = int(time.time()) - if(self.flag_ota_cancel_test.is_set()): + if self.flag_ota_cancel_test.is_set(): self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) self.test_ended_successfully = True self.nxt_st = State.TEST_CLEANUP elif current_time < self.test_start_time: self.nxt_st = State.WAIT_FOR_START - - elif current_time >= self.test_start_time: #TODO BRYSON + + elif current_time >= self.test_start_time: # TODO BRYSON self.nxt_st = State.UTW_TEST self.create_test_process() else: @@ -707,17 +692,17 @@ def bue_tick(self, loop_dur=0.01): # If the bUE ever receives a CANC while testing, it should response with a CANCD # message and enter the TEST_CLEANUP state # - # If the test subprocess is no longer running, the bUE will report how the + # If the test subprocess is no longer running, the bUE will report how the # test subprocessed ended and enter the TEST_CLEANUP state # # Otherwise, stay in the UTW_TEST state - # + # elif self.cur_st == State.UTW_TEST: - + if self.flag_ota_cancel_test.is_set(): self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) self.nxt_st = State.TEST_CLEANUP - + elif not self.status_test_running: if self.test_ended_successfully: self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) @@ -725,7 +710,7 @@ def bue_tick(self, loop_dur=0.01): elif self.test_ended_unsuccessfully: self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) self.nxt_st = State.TEST_CLEANUP - + elif self.status_test_running: self.nxt_st = State.UTW_TEST @@ -739,7 +724,6 @@ def bue_tick(self, loop_dur=0.01): logger.error(f"tick: Invalid state transition {self.cur_st.name}") sys.exit(1) - ## ACTION STATE MACHINE if self.cur_st == State.INIT: pass @@ -778,7 +762,6 @@ def bue_tick(self, loop_dur=0.01): logger.error(f"tick: Invalid state action {self.cur_st.name}") sys.exit(1) - # Update the current state self.cur_st = self.nxt_st @@ -790,7 +773,6 @@ def bue_tick(self, loop_dur=0.01): if remaining > 0: time.sleep(remaining) - def __del__(self): try: self.EXIT = True @@ -827,7 +809,7 @@ def __del__(self): bue = bUE_Main(yaml_str="bue_config.yaml") # Any other setup code can go here - time.sleep(2) # Allow some time for threads to initialize + time.sleep(2) # Allow some time for threads to initialize bue.tick_enabled = True @@ -847,4 +829,4 @@ def __del__(self): bue.EXIT = True time.sleep(0.5) bue.__del__() - sys.exit(1) \ No newline at end of file + sys.exit(1) From 93410b8105cd712dfdc0df12735a39c72c168c16 Mon Sep 17 00:00:00 2001 From: schielb Date: Wed, 5 Nov 2025 14:15:13 -0700 Subject: [PATCH 03/15] Update fix for grabbing reyax id --- bue_main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bue_main.py b/bue_main.py index c2ac287..2a35a2a 100644 --- a/bue_main.py +++ b/bue_main.py @@ -63,8 +63,10 @@ def __init__(self, yaml_str = "bue_config.yaml"): time.sleep(2) # Fetch the Reyax ID from the OTA module - time.sleep(0.1) - self.reyax_id = self.ota.fetch_id() + self.reyax_id = None + while self.reyax_id is None: + time.sleep(0.2) + self.reyax_id = self.ota.fetch_id() logger.info(f"__init__: OTA module initialized with Reyax ID {self.reyax_id}") # Fetch the device hostname From ad3424d15c4795c7eff8ec4c4f101d25cb412f92 Mon Sep 17 00:00:00 2001 From: schielb Date: Wed, 5 Nov 2025 14:36:45 -0700 Subject: [PATCH 04/15] Fix reyax id issues --- bue_main.py | 2 +- ota.py | 44 +++++++++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/bue_main.py b/bue_main.py index b1f091f..9720dbc 100644 --- a/bue_main.py +++ b/bue_main.py @@ -14,7 +14,7 @@ # For gps from pynmeagps import NMEAReader # type:ignore -import gps +# import gps logger.add("logs/bue.log", rotation="10 MB") # Example: Add a file sink for all logs diff --git a/ota.py b/ota.py index ced0210..806aac2 100644 --- a/ota.py +++ b/ota.py @@ -38,6 +38,9 @@ def __init__(self, port, baudrate, stdout_history=None): # Received messages buffer self.recv_msgs = queue.Queue() + # Internal Reyax messages buffer + self.internal_msgs = queue.Queue() + # Reading thread self.thread = threading.Thread(target=self.read_from_port, daemon=True) self.thread.start() # keeping the program from exiting @@ -56,12 +59,19 @@ def read_from_port(self): """ while not self.exit_event.is_set(): try: - message_with_crc = self.ser.readline().decode("utf-8", errors="ignore").strip() - parts = message_with_crc.split(",") + message = self.ser.readline().decode("utf-8", errors="ignore").strip() + + if message == "" or message == "OK": + continue - if message_with_crc == "" or message_with_crc == "OK": + if not message.startswith("+RCV="): + self.internal_msgs.put(message) continue + # else, we have a RVC message, needs to do reverse crc + message_with_crc = message + parts = message_with_crc.split(",") + # print(f"Message with CRC: {message_with_crc}") if len(parts) < 5: @@ -159,15 +169,27 @@ def fetch_id(self): Fetch the device ID from the Reyax module. """ try: - self.ser.write(b'AT+ADDRESS=?\r\n') + addr_req = f'AT+ADDRESS=?\r\n' + self.ser.write(addr_req.encode("utf-8")) time.sleep(0.1) # Wait for response - response = self.ser.readlines() - for line in response: - decoded_line = line.decode('utf-8').strip() - if decoded_line.startswith('+ADDRESS='): - addr = decoded_line.split('=')[1] - self.id = int(addr) - return self.id + try: + while True: + response = self.internal_msgs.get_nowait() + # response may be bytes or str; handle both and also handle multiple lines + if isinstance(response, bytes): + lines = [response.decode('utf-8', errors='ignore').strip()] + else: + lines = [ln.strip() for ln in str(response).splitlines() if ln.strip()] + + for line in lines: + if line.startswith('+ADDRESS='): + addr = line.split('=', 1)[1] + self.id = int(addr) + return self.id + except queue.Empty: + # No more lines in the queue + pass + except Exception as e: print(f"Failed to fetch ID: {e}") return None From 1b1947a0d67abd3e5efd491b601d5eb1fd0b85ed Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 14:43:24 -0700 Subject: [PATCH 05/15] Updated bue_main to include timing from the base station --- base_station_main.py | 9 +++++---- bue_main.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/base_station_main.py b/base_station_main.py index 15525c9..6e653e0 100644 --- a/base_station_main.py +++ b/base_station_main.py @@ -62,9 +62,7 @@ def __init__(self, yaml_str): # Hold all UPD messages so they can be displayed in the UI self.stdout_history = deque() - self.ota = Ota( - self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"], self.stdout_history - ) + self.ota = Ota(self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"], self.stdout_history) # Fetch the Reyax ID from the OTA module time.sleep(0.1) @@ -188,10 +186,13 @@ def message_listener(self): bue_name, bue_id_check = payload.split(",", 1) # Split hostname and bUE_id if int(bue_id_check) != bue_id: - logger.error(f"message_listener: Mismatched bUE ID in REQ message. Expected {bue_id}, got {bue_id_check}") + logger.error( + f"message_listener: Mismatched bUE ID in REQ message. Expected {bue_id}, got {bue_id_check}" + ) continue # Skip to the next message self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}:{current_timestamp}") + # self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}") self.bue_timeout_tracker[bue_name] = TIMEOUT if not bue_id in self.connected_bues: logger.bind(bue_id=bue_id).info(f"Received a request signal from {bue_id}:{bue_name}") diff --git a/bue_main.py b/bue_main.py index 9720dbc..257ba01 100644 --- a/bue_main.py +++ b/bue_main.py @@ -14,6 +14,7 @@ # For gps from pynmeagps import NMEAReader # type:ignore + # import gps logger.add("logs/bue.log", rotation="10 MB") # Example: Add a file sink for all logs @@ -193,7 +194,7 @@ def ota_message_handler(self): src_id, msg = message.split(",", 1) if ":" in msg: - msg_type, msg_body = msg.split(":", 1) + msg_type, msg_body, _ = msg.split(":", 2) else: msg_type, msg_body = msg, None From 110678c41a1299024b85662fc371fbe220e5a2c5 Mon Sep 17 00:00:00 2001 From: schielb Date: Wed, 5 Nov 2025 14:46:56 -0700 Subject: [PATCH 06/15] Add back gps --- bue_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bue_main.py b/bue_main.py index 257ba01..0951e64 100644 --- a/bue_main.py +++ b/bue_main.py @@ -15,7 +15,7 @@ # For gps from pynmeagps import NMEAReader # type:ignore -# import gps +import gps logger.add("logs/bue.log", rotation="10 MB") # Example: Add a file sink for all logs From 661e4b91f362dddc2a8a57b49426ee1368ab3129 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 15:12:02 -0700 Subject: [PATCH 07/15] Updates to base station to comply with new bue code. Change bue_main to have maxsplit instead of an expected amount to prevent errors --- base_station_gui.py | 7 +++++-- base_station_main.py | 4 ++-- bue_main.py | 2 +- constants.py | 11 ++--------- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/base_station_gui.py b/base_station_gui.py index 4e55035..418f839 100644 --- a/base_station_gui.py +++ b/base_station_gui.py @@ -1299,10 +1299,13 @@ def run_tests(self): # Send test commands for bue_id, config in self.bue_configs.items(): + selected_file = config["file"] # ← FIX: Get file from config, not from outside loop + if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - command = f"TEST,{config['file']},{unix_timestamp},-s {config['sf']} -m {config["msg"]} -c {config['freq']} -b {config["bw"]} -p {config["period"]}" + command = f"TEST:{config['file']},{unix_timestamp},-s {config['sf']} -m {config['msg']} -c {config['freq']} -b {config['bw']} -p {config['period']}" else: - command = f"TEST,{config['file']},{unix_timestamp}," + command = f"TEST:{config['file']},{unix_timestamp}," + self.base_station.ota.send_ota_message(bue_id, command) time.sleep(0.1) logger.info(f"Sent test command to bUE {bue_id}: {command}") diff --git a/base_station_main.py b/base_station_main.py index 6e653e0..a5d0e8f 100644 --- a/base_station_main.py +++ b/base_station_main.py @@ -193,8 +193,8 @@ def message_listener(self): self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}:{current_timestamp}") # self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}") - self.bue_timeout_tracker[bue_name] = TIMEOUT - if not bue_id in self.connected_bues: + self.bue_timeout_tracker[bue_id] = TIMEOUT + if not bue_name in self.connected_bues: logger.bind(bue_id=bue_id).info(f"Received a request signal from {bue_id}:{bue_name}") self.connected_bues[bue_id] = bue_name else: diff --git a/bue_main.py b/bue_main.py index 257ba01..1b1bd55 100644 --- a/bue_main.py +++ b/bue_main.py @@ -194,7 +194,7 @@ def ota_message_handler(self): src_id, msg = message.split(",", 1) if ":" in msg: - msg_type, msg_body, _ = msg.split(":", 2) + msg_type, msg_body, _ = msg.split(":", maxsplit=2) else: msg_type, msg_body = msg, None diff --git a/constants.py b/constants.py index 0412d9d..7c57d76 100644 --- a/constants.py +++ b/constants.py @@ -1,17 +1,10 @@ """Simple Dictionary to map Reyex names to bUE names""" -bUEs = { - "10": "Doof", - "70": "Candace", - "30": "Major", - "40": "Buford", - "50": "Carl", - "60": "Perry", -} +bUEs = {"10": "Doof", "70": "Vanessa", "30": "Major", "40": "Buford", "50": "Carl", "60": "Monty", "20": "Monty"} bUEs_inverted = { "Doof": "10", - "Candace": "70", + "Vanessa": "70", "Major": "30", "Buford": "40", "Carl": "50", From b7cab3d7f4353da53d3a95ce4b644471e5f84319 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 15:18:35 -0700 Subject: [PATCH 08/15] Added padding --- bue_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bue_main.py b/bue_main.py index a0e8d3f..77d9cde 100644 --- a/bue_main.py +++ b/bue_main.py @@ -194,7 +194,7 @@ def ota_message_handler(self): src_id, msg = message.split(",", 1) if ":" in msg: - msg_type, msg_body, _ = msg.split(":", maxsplit=2) + msg_type, msg_body, *_ = msg.split(":", maxsplit=2) else: msg_type, msg_body = msg, None From a771089713c1f2f2212fc3fb12856dbb62888c76 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 5 Nov 2025 15:30:26 -0700 Subject: [PATCH 09/15] Added some more logging --- bue_main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bue_main.py b/bue_main.py index 77d9cde..3f77ef2 100644 --- a/bue_main.py +++ b/bue_main.py @@ -283,7 +283,7 @@ def ota_idle_ping(self): logger.info(f"We have not heard from {self.ota_base_station_id} in too long. Disconnecting...") self.status_ota_connected = False - def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=10): + def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=2): # TODO: what should max_runtime be? I had it as 10 historically start_time = time.time() try: session = gps.gps(mode=gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE) @@ -433,6 +433,8 @@ def create_test_process(self): self.test_stdout_thread.start() + logger.info("Test process and stdout thread have started") + self.status_test_running = True """ From 93ad49389588d6dbef9edbc21b6855c00f63bc57 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 12 Nov 2025 15:47:23 -0700 Subject: [PATCH 10/15] Updated ping and update handling --- bue_main.py | 193 +++++++++++++++++++---------------------- setup/message_dict.txt | 19 +--- 2 files changed, 90 insertions(+), 122 deletions(-) diff --git a/bue_main.py b/bue_main.py index 3f77ef2..2476f7f 100644 --- a/bue_main.py +++ b/bue_main.py @@ -39,6 +39,13 @@ class State(Enum): TEST_CLEANUP = auto() +class Test_State(Enum): + IDLE = auto() + RUNNING = auto() + PASS = auto() + FAIL = auto() + + class bUE_Main: def __init__(self, yaml_str="bue_config.yaml"): self.yaml_data = {} @@ -92,7 +99,6 @@ def __init__(self, yaml_str="bue_config.yaml"): # State machine - statuses # These are the main internal signals used by the state machine - self.status_test_running = False self.status_ota_connected = False # Network information @@ -106,9 +112,8 @@ def __init__(self, yaml_str="bue_config.yaml"): self.test_stdout_queue = queue.Queue() self.test_stdout_thread = None - # Flags to show if test subprocess ended succesfully or not - self.test_ended_successfully = False - self.test_ended_unsuccessfully = False + # Holds what state the test currently is in + self.test_state = Test_State.IDLE # Build the state machine - flags self.counter_ota_timeout = 0 @@ -120,12 +125,6 @@ def __init__(self, yaml_str="bue_config.yaml"): self.cancel_test = False # TODO - delete/modify these once test functionality gets added - # Buffer to hold outputs from the UTW script (like helloworld) - # self.test_output_buffer = [] - # self.test_output_lock = threading.RLock() - # TODO - modify these once test functionality gets added. - # UPDATE - I think I fixed it. See ota_send_update(). This is Ty : ) - # Set up the ota threads self.ota_incoming_queue = queue.Queue() self.ota_outgoing_queue = queue.Queue() @@ -261,29 +260,15 @@ def ota_connect_req(self): # If flag not set, send another REQ message self.ota_outgoing_queue.put((BROADCAST_OTA_ID, f"REQ:{self.hostname},{self.reyax_id}")) - def ota_idle_ping(self): - if not self.status_ota_connected: - logger.warning("ota_idle_ping: OTA device is not connected to base station") - return - + def ota_ping(self): lat, long = self.gps_handler() - self.ota_outgoing_queue.put((self.ota_base_station_id, f"PING,{lat},{long}")) # test ping for now - logger.info(f"Sent a PING with position lat: {lat} lon: {long}") + self.ota_outgoing_queue.put((self.ota_base_station_id, f"PING:{self.cur_st.value},{lat},{long}")) + logger.info(f"ota_ping: Sent ping to {self.ota_base_station_id}") - # Check to see if we are getting ping responses - if self.flag_ota_pingr.is_set(): - self.counter_ota_timeout = 0 - self.flag_ota_pingr.clear() - else: - self.counter_ota_timeout += 1 - if self.counter_ota_timeout == self.MAX_ota_timeout / 2: - logger.info(f"We haven't heard from {self.ota_base_station_id} in a while....") - elif self.counter_ota_timeout >= self.MAX_ota_timeout: - logger.info(f"We have not heard from {self.ota_base_station_id} in too long. Disconnecting...") - self.status_ota_connected = False - - def gps_handler(self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=2): # TODO: what should max_runtime be? I had it as 10 historically + def gps_handler( + self, max_attempts=50, min_fixes=3, hdop_threshold=2.0, max_runtime=2 + ): # TODO: what should max_runtime be? I had it as 10 historically start_time = time.time() try: session = gps.gps(mode=gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE) @@ -338,31 +323,18 @@ def utw_task_queue_handler(self): except queue.Empty: pass - # Instead of PINGs, bUEs send UPDs while in UTW_TEST state so the base still knows there is a connection + # Sends the first message in the self.test_stdout_queue back to the base station def ota_send_update(self): - lat, long = self.gps_handler() - logger.info(f"Sent UPD to {self.ota_base_station_id}") - - output_lines = [] - try: - while True: - line = self.test_stdout_queue.get_nowait() - if line.strip(): # Only add non-empty lines - output_lines.append(line.strip()) - except queue.Empty: - pass # Queue is now empty - - if output_lines: - # Send each line as a separate UPD message - for line in output_lines: - self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},{line}")) - logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {line}") - time.sleep(0.4) # Sleep so UART does not get overwhelmed. TODO: Is this the right value? - else: - # No console output available - self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:,{lat},{long},")) - logger.info(f"Sent UPD to {self.ota_base_station_id} with no console output") + stdout = self.test_stdout_queue.get_nowait().strip() + if len(stdout) > 0: # See if the message is empty + return + except: + logger.error("ota_send_update: test_stdout_queue is empty") + return + + self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:{stdout}")) # TODO: Change UPD to TOUT + logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {stdout}") """ Checks to see if the ota system a valid TEST message from the base station @@ -408,7 +380,6 @@ def reader_thread(self, pipe, output_queue): """ Once a utw test is ready to start, we create the subprocess and pipe all of the stdout content into the test_stdout_queue to be sent out with messages. - self.status_test_running flag set accordingly """ def create_test_process(self): @@ -435,15 +406,12 @@ def create_test_process(self): logger.info("Test process and stdout thread have started") - self.status_test_running = True - """ Checks on the test subprocess to see if it is still running. subprocess returns None if still running subprocess return 0 if it ended successfully subprocess returns -2 if it was ended by a signal.SIGINT (better none as a CANC) subprocess returns something else otherwise, meaning it ended unexpectedly - self.status_test_running flag updated accordingly """ def check_on_test(self): @@ -458,28 +426,24 @@ def check_on_test(self): # Test ended successfully elif exit_code == 0: - self.test_ended_successfully = True + self.test_state = Test_State.PASS # Test was terminated with a CANC. When a subprocess is terminated with a signal.SIGINT, # it returns -2 elif exit_code == -2: - self.test_ended_successfully = True + self.test_state = Test_State.PASS else: - self.test_ended_unsuccessfully = True - - self.status_test_running = False + self.test_state = Test_State.FAIL def clean_up_test(self): - self.ota_send_update() - if self.test_stdout_thread and self.test_stdout_thread.is_alive(): self.test_stdout_thread.join() # TODO: Need to reset flags here? Or does that occur when we switch to WAIT_FOR_START transition? self.test_process = None self.test_stdout_thread = None - self.status_test_running = False + self.test_state = Test_State.IDLE """ This function checks for incoming messages while in the system is the UTW_TEST state. @@ -520,7 +484,7 @@ def check_for_test_interrupt(self): if self.flag_ota_cancel_test.is_set(): logger.info("check_for_cancel: Test CANCELLED by base station") self.flag_ota_cancel_test.clear() - self.status_test_running = False + self.test_process.send_signal(signal.SIGINT) elif self.flag_ota_reload.is_set(): logger.info("check_for_cancel: Received a RELOAD message") @@ -602,15 +566,10 @@ def bue_tick(self, loop_dur=0.01): counter_connect_ota = 0 interval_connect_ota = round(CONNECT_OTA_REQ_INTERVAL / loop_dur) - # How often to ping (once in idle state) IDLE_PING_OTA_INTERVAL seconds - IDLE_PING_OTA_INTERVAL = 10 - counter_idle_ping = 0 - interval_idle_ping = round(IDLE_PING_OTA_INTERVAL / loop_dur) - - # How often to send UPDs UTW_UPD_OTA_INTERVAL seconds - UTW_UPD_OTA_INTERVAL = 10 - counter_uta_update = 0 - interval_uta_update = round(UTW_UPD_OTA_INTERVAL / loop_dur) + # How often to ping (once in idle state) PING_OTA_INTERVAL seconds + PING_OTA_INTERVAL = 10 + counter_ping = 0 + interval_ping = round(PING_OTA_INTERVAL / loop_dur) while not self.EXIT: if not self.tick_enabled: @@ -632,13 +591,15 @@ def bue_tick(self, loop_dur=0.01): elif self.cur_st == State.CONNECT_OTA: # Wait until the OTA device is connected to the OTA network if self.status_ota_connected: - counter_idle_ping = 0 # Reset the flags used in idle self.flag_ota_pingr.clear() self.flag_ota_start_testing.clear() + counter_ping = 0 self.nxt_st = State.IDLE + else: + self.nxt_st = State.CONNECT_OTA # If the bUE ever loses connected to the base station, return to CONNECTED_OTA state # @@ -655,15 +616,13 @@ def bue_tick(self, loop_dur=0.01): self.nxt_st = State.CONNECT_OTA # If we receivied a TEST message from the base station, we switch to UTW_TEST state elif self.flag_ota_start_testing.is_set(): - counter_idle_ping = 0 - # Reset the flags used in testing self.flag_ota_start_testing.clear() self.flag_ota_cancel_test.clear() self.flag_ota_reload.clear() self.flag_ota_restart.clear() - self.test_ended_successfully = False - self.test_ended_unsuccessfully = False + self.test_state = Test_State.RUNNING + # TODO reset other falgs? if self.test_has_valid_params(): self.nxt_st = State.WAIT_FOR_START @@ -671,7 +630,6 @@ def bue_tick(self, loop_dur=0.01): self.nxt_st = State.IDLE # TODO: SEND A BAD PARAMETERS MESSAGE? # - # # In the WAIT_FOR START state, the bUE is waiting for a certain time to arrive. Once it has, it # will enter into the UTW_TEST state # @@ -682,15 +640,14 @@ def bue_tick(self, loop_dur=0.01): current_time: int = int(time.time()) if self.flag_ota_cancel_test.is_set(): self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) - self.test_ended_successfully = True self.nxt_st = State.TEST_CLEANUP elif current_time < self.test_start_time: self.nxt_st = State.WAIT_FOR_START - elif current_time >= self.test_start_time: # TODO BRYSON + elif current_time >= self.test_start_time: self.nxt_st = State.UTW_TEST - self.create_test_process() + self.create_test_process() # TODO: Call this early and have a "start" call. See notion else: logger.warning("Got to last transition in WAIT_FOR_START. Shouldn't be possible") # @@ -708,22 +665,27 @@ def bue_tick(self, loop_dur=0.01): self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) self.nxt_st = State.TEST_CLEANUP - elif not self.status_test_running: - if self.test_ended_successfully: - self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) - self.nxt_st = State.TEST_CLEANUP - elif self.test_ended_unsuccessfully: - self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) - self.nxt_st = State.TEST_CLEANUP + elif self.test_state == Test_State.PASS: + self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) + self.nxt_st = State.TEST_CLEANUP - elif self.status_test_running: + elif self.test_state == Test_State.FAIL: + self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) + self.nxt_st = State.TEST_CLEANUP + + elif self.test_state == Test_State.RUNNING: self.nxt_st = State.UTW_TEST else: pass # + # Once all the stdout queue messages have been sent, return to the IDLE state + # elif self.cur_st == State.TEST_CLEANUP: - self.nxt_st = State.IDLE + if self.test_stdout_queue.empty(): + self.nxt_st = State.IDLE + else: + self.nxt_st = State.TEST_CLEANUP else: logger.error(f"tick: Invalid state transition {self.cur_st.name}") @@ -736,32 +698,53 @@ def bue_tick(self, loop_dur=0.01): elif self.cur_st == State.CONNECT_OTA: counter_connect_ota += 1 - # Send out a message looking for a base station every CONNECT_OTA_REQ_INTERVAL seconds + # Send out a REQ every CONNECT_OTA_REQ_INTERVAL seconds if counter_connect_ota % interval_connect_ota == 0: self.ota_task_queue.put(self.ota_connect_req) # elif self.cur_st == State.IDLE: - counter_idle_ping += 1 + counter_ping += 1 - # Second out a message pinging the base station every IDLE_PING_OTA_INTERVAL seconds - if counter_idle_ping % interval_idle_ping == 0: - self.ota_task_queue.put(self.ota_idle_ping) + # Send a PING every PING_OTA_INTERVAL seconds + if counter_ping % interval_ping == 0: + self.ota_task_queue.put(self.ota_ping) + counter_ping = 0 # elif self.cur_st == State.WAIT_FOR_START: - pass + counter_ping += 1 + + # Send a PING every PING_OTA_INTERVAL seconds + if counter_ping % interval_ping == 0: + self.ota_task_queue.put(self.ota_ping) + counter_ping = 0 # elif self.cur_st == State.UTW_TEST: - counter_uta_update += 1 + counter_ping += 1 + + # Send a PING every PING_OTA_INTERVAL seconds + if counter_ping % interval_ping == 0: + self.ota_task_queue.put(self.ota_ping) + counter_ping = 0 + + self.check_on_test() + self.check_for_test_interrupt() - # Do necessary tasks while running a test every UTW_UPD_OTA_INTERVAL seconds - if counter_uta_update % interval_uta_update == 0: + if not self.test_stdout_queue.empty(): self.ota_task_queue.put(self.ota_send_update) - self.ota_task_queue.put(self.check_for_test_interrupt) - self.ota_task_queue.put(self.check_on_test) # elif self.cur_st == State.TEST_CLEANUP: - self.ota_task_queue.put(self.ota_send_update) + counter_ping += 1 + + # Send a PING every PING_OTA_INTERVAL seconds + if counter_ping % interval_ping == 0: + self.ota_task_queue.put(self.ota_ping) + counter_ping = 0 + + if not self.test_stdout_queue.empty(): + self.ota_task_queue.put(self.ota_send_update) + else: + self.clean_up_test() # else: logger.error(f"tick: Invalid state action {self.cur_st.name}") diff --git a/setup/message_dict.txt b/setup/message_dict.txt index 4268543..bf94e73 100644 --- a/setup/message_dict.txt +++ b/setup/message_dict.txt @@ -38,7 +38,7 @@ PING: Direction: bUE -> base Meaning: The bUE periodically pings the base station Body: None - Example: (bue_main.py) self.ota.send_ota_message(10, "PING,,") <- NOTICE NO COLON + Example: (bue_main.py) self.ota.send_ota_message(10, "PING:,") <- NOTICE NO COLON Response: The base station will respond with a PINGR. If too much time passes between PINGR's, the bUE knows it has become disconnected from the network. PINGR: @@ -78,26 +78,11 @@ PREPR: Example: (bue_main.py) self.ota.send_ota_message(10, "TESTR:1745004290") Response: None -BEGIN: (UNUSED) - Direction: bUE -> base - Meaning: The bUE lets the base station know that it has begun its UTW test - Body: None - Example: (bue_main.py) self.ota.send_ota_message(10, "BEGIN") - Response: None - UPD: Direction: bUE -> base Meaning: The bUE sends an update on the UTW test; includes current GPS coords and any message that might be in stdout Body: ,, - Example: (bue_main.py) self.ota.send_ota_message(10, "UPD:,,,") <- NOTICE EXTRA COMMA - Response: None - -MSG: - Direction: bUE -> base - Meaning: The bUE received some sort of output while running an utw test. This output could be anything from a received LoRa message to an error. - This output must be sent back to the base station, line by line - Body: - Example: (bue_main.py) self.ota.send_ota_message(10, "MSG:") + Example: (bue_main.py) self.ota.send_ota_message(10, "UPD:") Response: None DONE: From 35d6052222e76e6c48895a400d90e84a335fb4e1 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 12 Nov 2025 15:57:09 -0700 Subject: [PATCH 11/15] One last change to pings and upds --- bue_main.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/bue_main.py b/bue_main.py index 2476f7f..3a2b6c2 100644 --- a/bue_main.py +++ b/bue_main.py @@ -427,14 +427,17 @@ def check_on_test(self): # Test ended successfully elif exit_code == 0: self.test_state = Test_State.PASS + self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) # Test was terminated with a CANC. When a subprocess is terminated with a signal.SIGINT, # it returns -2 elif exit_code == -2: self.test_state = Test_State.PASS + self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) else: self.test_state = Test_State.FAIL + self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) def clean_up_test(self): if self.test_stdout_thread and self.test_stdout_thread.is_alive(): @@ -578,7 +581,8 @@ def bue_tick(self, loop_dur=0.01): loop_start = time.time() - # TRANSITIONS STATE MACHINE + ### TRANSITIONS STATE MACHINE ### + if self.cur_st == State.INIT: # Setup should all be complete, immediately move to the CONNECT_OTA state counter_connect_ota = 0 @@ -635,7 +639,7 @@ def bue_tick(self, loop_dur=0.01): # # If while waiting the bUE receives a CANC message, it will stop waiting and go straight to the # TEST_CLEANUP state so flags can be reset approriately - # """ + # elif self.cur_st == State.WAIT_FOR_START: current_time: int = int(time.time()) if self.flag_ota_cancel_test.is_set(): @@ -649,7 +653,7 @@ def bue_tick(self, loop_dur=0.01): self.nxt_st = State.UTW_TEST self.create_test_process() # TODO: Call this early and have a "start" call. See notion else: - logger.warning("Got to last transition in WAIT_FOR_START. Shouldn't be possible") + logger.warning("Got to last transition in WAIT_FOR_START. This should not be possible") # # If the bUE ever receives a CANC while testing, it should response with a CANCD # message and enter the TEST_CLEANUP state @@ -660,24 +664,17 @@ def bue_tick(self, loop_dur=0.01): # Otherwise, stay in the UTW_TEST state # elif self.cur_st == State.UTW_TEST: - - if self.flag_ota_cancel_test.is_set(): - self.ota_outgoing_queue.put((self.ota_base_station_id, "CANCD")) - self.nxt_st = State.TEST_CLEANUP - - elif self.test_state == Test_State.PASS: - self.ota_outgoing_queue.put((self.ota_base_station_id, "DONE")) + if self.test_state == Test_State.PASS: self.nxt_st = State.TEST_CLEANUP elif self.test_state == Test_State.FAIL: - self.ota_outgoing_queue.put((self.ota_base_station_id, "FAIL")) self.nxt_st = State.TEST_CLEANUP elif self.test_state == Test_State.RUNNING: self.nxt_st = State.UTW_TEST else: - pass + logger.error(f"bue_tick: bUE in unexpected test_state while in UTW_TEST: {self.test_state}") # # Once all the stdout queue messages have been sent, return to the IDLE state # @@ -691,7 +688,8 @@ def bue_tick(self, loop_dur=0.01): logger.error(f"tick: Invalid state transition {self.cur_st.name}") sys.exit(1) - ## ACTION STATE MACHINE + ### ACTION STATE MACHINE ### + if self.cur_st == State.INIT: pass # From 9d29d9e7fe5082ec6f13eb36a2b0dbe68ad01d13 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Wed, 7 Jan 2026 13:41:49 -0700 Subject: [PATCH 12/15] Rebuilt base station and fixed base station gui accordingly --- base_station_gui.py | 61 +- base_station_gui_old.py | 1900 ++++++++++++++++++++++++++++++++++++++ base_station_main.py | 467 ++++------ base_station_main_old.py | 378 ++++++++ bue_main.py | 15 +- constants.py | 22 +- setup/message_dict.txt | 10 +- 7 files changed, 2512 insertions(+), 341 deletions(-) create mode 100644 base_station_gui_old.py create mode 100644 base_station_main_old.py diff --git a/base_station_gui.py b/base_station_gui.py index 418f839..0227f81 100644 --- a/base_station_gui.py +++ b/base_station_gui.py @@ -145,8 +145,8 @@ def setup_left_panel(self, parent): # bUE Treeview self.bue_tree = ttk.Treeview(bue_frame, columns=("status", "ping"), show="tree headings") self.bue_tree.heading("#0", text="bUE ID") - self.bue_tree.heading("status", text="Status") - self.bue_tree.heading("ping", text="Ping Status") + self.bue_tree.heading("status", text="State") + self.bue_tree.heading("ping", text="Missed Pings") self.bue_tree.column("#0", width=100) self.bue_tree.column("status", width=100) @@ -382,22 +382,25 @@ def update_bue_list(self): # Add connected bUEs for bue_id in self.base_station.connected_bues: - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname[bue_id] - # Determine status - if bue_id in getattr(self.base_station, "testing_bues", []): - status = "🧪 Testing" - else: - status = "💤 Idle" + # # Determine status + # if bue_id in getattr(self.base_station, "testing_bues", []): + # status = "🧪 Testing" + # else: + # status = "💤 Idle" + status = self.base_station.bue_id_to_state[bue_id] # Determine ping status - timeout_val = self.base_station.bue_timeout_tracker.get(bue_id, 0) - if timeout_val >= TIMEOUT / 2: - ping_status = "🟢 Good" - elif timeout_val > 0: - ping_status = "🟡 Warning" - else: - ping_status = "🔴 Lost" + timeout_val = self.base_station.bue_missed_ping_counter.get(bue_id, 0) + # if timeout_val >= TIMEOUT / 2: + # ping_status = "🟢 Good" + # elif timeout_val > 0: + # ping_status = "🟡 Warning" + # else: + # ping_status = "🔴 Lost" + + ping_status = timeout_val self.bue_tree.insert("", "end", iid=bue_id, text=bue_name, values=(status, ping_status)) @@ -425,7 +428,7 @@ def update_interactive_map(self): pass self.map_markers.clear() - if not self.base_station or not self.base_station.bue_coordinates: + if not self.base_station or not self.base_station.bue_id_to_coords: return # Check if bUE positions have changed significantly @@ -438,7 +441,7 @@ def update_interactive_map(self): lons = [] # Get bUE coordinates and track changes - for bue_id, coords in self.base_station.bue_coordinates.items(): + for bue_id, coords in self.base_station.bue_id_to_coords.items(): try: lat, lon = float(coords[0]), float(coords[1]) lats.append(lat) @@ -508,7 +511,7 @@ def update_interactive_map(self): self.last_bue_positions = current_positions.copy() # Add bUE markers - for bue_id, coords in self.base_station.bue_coordinates.items(): + for bue_id, coords in self.base_station.bue_id_to_coords.items(): try: lat, lon = float(coords[0]), float(coords[1]) bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") @@ -589,7 +592,7 @@ def update_canvas_map(self): # Clear canvas self.map_widget.delete("all") - if not self.base_station or not self.base_station.bue_coordinates: + if not self.base_station or not self.base_station.bue_id_to_coords: self.map_widget.create_text( 300, 200, @@ -604,7 +607,7 @@ def update_canvas_map(self): lons = [] # Get bUE coordinates - for coords in self.base_station.bue_coordinates.values(): + for coords in self.base_station.bue_id_to_coords.values(): try: lat, lon = float(coords[0]), float(coords[1]) lats.append(lat) @@ -652,7 +655,7 @@ def lon_to_x(lon): return ((lon - min_lon) / (max_lon - min_lon)) * canvas_width # Draw bUEs - for bue_id, coords in self.base_station.bue_coordinates.items(): + for bue_id, coords in self.base_station.bue_id_to_coords.items(): try: lat, lon = float(coords[0]), float(coords[1]) x, y = lon_to_x(lon), lat_to_y(lat) @@ -730,7 +733,7 @@ def update_tables(self): self.coord_tree.delete(item) if self.base_station: - for bue_id, coords in self.base_station.bue_coordinates.items(): + for bue_id, coords in self.base_station.bue_id_to_coords.items(): bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") try: lat, lon = coords[0], coords[1] @@ -748,8 +751,8 @@ def update_tables(self): for bue2 in self.base_station.connected_bues: if ( bue1 != bue2 - and bue1 in self.base_station.bue_coordinates - and bue2 in self.base_station.bue_coordinates + and bue1 in self.base_station.bue_id_to_coords + and bue2 in self.base_station.bue_id_to_coords and (bue1, bue2) not in processed_pairs and (bue2, bue1) not in processed_pairs ): @@ -814,12 +817,12 @@ def disconnect_bue(self, bue_id): ): try: self.base_station.connected_bues.remove(bue_id) - if bue_id in self.base_station.bue_coordinates: - del self.base_station.bue_coordinates[bue_id] + if bue_id in self.base_station.bue_id_to_coords: + del self.base_station.bue_id_to_coords[bue_id] if bue_id in getattr(self.base_station, "testing_bues", []): self.base_station.testing_bues.remove(bue_id) - if bue_id in self.base_station.bue_timeout_tracker: - del self.base_station.bue_timeout_tracker[bue_id] + if bue_id in self.base_station.bue_missed_ping_counter: + del self.base_station.bue_missed_ping_counter[bue_id] logger.info(f"Disconnected from bUE {bue_id}") except Exception as e: messagebox.showerror("Error", f"Failed to disconnect: {e}") @@ -1303,6 +1306,8 @@ def run_tests(self): if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): command = f"TEST:{config['file']},{unix_timestamp},-s {config['sf']} -m {config['msg']} -c {config['freq']} -b {config['bw']} -p {config['period']}" + elif selected_file.startswith("Old"): + command = f"TEST:{config['file']},{unix_timestamp},{config['msg']}" else: command = f"TEST:{config['file']},{unix_timestamp}," diff --git a/base_station_gui_old.py b/base_station_gui_old.py new file mode 100644 index 0000000..418f839 --- /dev/null +++ b/base_station_gui_old.py @@ -0,0 +1,1900 @@ +""" +base_station_gui.py +Ty Young + +A comprehensive GUI for the base station using tkinter. +This GUI provides all the functionality of main_ui.py but with a graphical interface. + +Features: +- Connected bUEs menu with status indicators +- Right-click context menu for bUE operations (disconnect, reload, restart, open logs) +- Interactive map showing bUE locations +- Custom markers that can be paired with bUEs +- Color-coded proximity indicators (changes when bUEs are within 10-20m of markers) +- Coordinates table +- Distance table between bUEs +- Received messages table +- Base station log file viewer +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, scrolledtext +import threading +import time +import os +import subprocess +import platform +from datetime import datetime, date, timedelta +from loguru import logger +import math + +# Try to import TkinterMapView, fallback to canvas if not available +try: + import tkintermapview + from PIL import Image, ImageDraw, ImageTk + + MAP_VIEW_AVAILABLE = True + print("TkinterMapView is available - using interactive map") +except ImportError: + MAP_VIEW_AVAILABLE = False + print("TkinterMapView not available - using fallback canvas map") + +from base_station_main import Base_Station_Main +from constants import bUEs, TIMEOUT + + +class BaseStationGUI: + def __init__(self, root): + self.root = root + self.root.title("Base Station Control Panel") + self.root.geometry("1400x900") + + # Initialize base station + self.base_station = None + self.update_thread = None + self.running = False + + # Custom markers for the map + self.custom_markers = {} # {marker_id: {'name': str, 'lat': float, 'lon': float, 'paired_bue': int}} + self.marker_counter = 0 + + # Map configuration + self.use_interactive_map = MAP_VIEW_AVAILABLE + self.map_widget = None # Will hold TkinterMapView or canvas + self.map_markers = {} # Track markers on the interactive map + self.last_bue_positions = {} # Track last known bUE positions to detect changes + self.map_auto_positioned = False # Track if we've done initial positioning + + # Setup GUI + self.setup_gui() + + # Start base station + self.start_base_station() + + def create_circle_marker_icon(self, color, size=20): + """Create a custom circular marker icon similar to canvas map""" + if not MAP_VIEW_AVAILABLE: + return None + + try: + # Create image with transparent background + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Draw outer circle (border) + border_color = "darkblue" if color in ["blue", "green"] else "darkred" + draw.ellipse([0, 0, size - 1, size - 1], fill=color, outline=border_color, width=2) + + # Convert to PhotoImage for tkinter + return ImageTk.PhotoImage(img) + except Exception as e: + logger.error(f"Error creating circle marker icon: {e}") + return None + + # Handle window close + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + + def setup_gui(self): + """Setup the main GUI layout with all panels always visible""" + # Increase window size to accommodate all panels + self.root.geometry("1600x1000") + + # Create main container with grid layout + main_frame = ttk.Frame(self.root) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Configure grid weights for responsive resizing + main_frame.grid_columnconfigure(0, weight=1) # Left column + main_frame.grid_columnconfigure(1, weight=2) # Middle column (map) + main_frame.grid_columnconfigure(2, weight=1) # Right column + main_frame.grid_rowconfigure(0, weight=1) # Top row + main_frame.grid_rowconfigure(1, weight=1) # Bottom row + + # Left panel - bUE list and controls + left_frame = ttk.Frame(main_frame) + left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, 2)) + self.setup_left_panel(left_frame) + + # Middle top panel - Map + map_frame = ttk.LabelFrame(main_frame, text="bUE Location Map") + map_frame.grid(row=0, column=1, sticky="nsew", padx=2) + self.setup_map_view(map_frame) + + # Middle bottom panel - Messages + messages_frame = ttk.LabelFrame(main_frame, text="Messages") + messages_frame.grid(row=1, column=1, sticky="nsew", padx=2, pady=(2, 0)) + self.setup_messages_view(messages_frame) + + # Right panel - Data tables + tables_frame = ttk.Frame(main_frame) + tables_frame.grid(row=0, column=2, rowspan=2, sticky="nsew", padx=(2, 0)) + self.setup_tables_view(tables_frame) + + # Status bar + self.status_var = tk.StringVar() + self.status_var.set("Initializing...") + status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN) + status_bar.pack(side=tk.BOTTOM, fill=tk.X) + + def setup_left_panel(self, parent): + """Setup the left panel with bUE list and controls""" + # bUE List Frame + bue_frame = ttk.LabelFrame(parent, text="Connected bUEs") + bue_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # bUE Treeview + self.bue_tree = ttk.Treeview(bue_frame, columns=("status", "ping"), show="tree headings") + self.bue_tree.heading("#0", text="bUE ID") + self.bue_tree.heading("status", text="Status") + self.bue_tree.heading("ping", text="Ping Status") + + self.bue_tree.column("#0", width=100) + self.bue_tree.column("status", width=100) + self.bue_tree.column("ping", width=100) + + # Scrollbar for treeview + bue_scrollbar = ttk.Scrollbar(bue_frame, orient=tk.VERTICAL, command=self.bue_tree.yview) + self.bue_tree.configure(yscrollcommand=bue_scrollbar.set) + + self.bue_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + bue_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Bind right-click context menu (cross-platform) + if platform.system() == "Darwin": # macOS + self.bue_tree.bind("", self.show_bue_context_menu) # macOS right-click + self.bue_tree.bind("", self.show_bue_context_menu) # macOS Ctrl+click alternative + else: # Linux/Windows + self.bue_tree.bind("", self.show_bue_context_menu) # Standard right-click + + # Control buttons frame + control_frame = ttk.LabelFrame(parent, text="Controls") + control_frame.pack(fill=tk.X, padx=5, pady=5) + + # Test button + ttk.Button(control_frame, text="Run Test", command=self.run_test).pack(fill=tk.X, pady=2) + + # Cancel test button + ttk.Button(control_frame, text="Cancel Tests", command=self.cancel_tests).pack(fill=tk.X, pady=2) + + # Open base station log + ttk.Button(control_frame, text="Open Base Station Log", command=self.open_base_log).pack(fill=tk.X, pady=2) + + # Map controls frame + map_control_frame = ttk.LabelFrame(parent, text="Map Controls") + map_control_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Button(map_control_frame, text="Add Custom Marker", command=self.add_custom_marker).pack(fill=tk.X, pady=2) + ttk.Button(map_control_frame, text="Manage Markers", command=self.manage_markers).pack(fill=tk.X, pady=2) + + # Map type toggle (only show if both options are available) + if MAP_VIEW_AVAILABLE: + self.map_toggle_btn = ttk.Button(map_control_frame, text="Switch to Simple Map", command=self.toggle_map_type) + self.map_toggle_btn.pack(fill=tk.X, pady=2) + + def setup_map_view(self, parent): + """Setup the map view with bUE locations and custom markers""" + # Create container for map + self.map_container = parent + + # Set up the appropriate map type + if self.use_interactive_map and MAP_VIEW_AVAILABLE: + self.setup_interactive_map() + else: + self.setup_canvas_map() + + # Map info frame (always present) + map_info_frame = ttk.Frame(parent) + map_info_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Label(map_info_frame, text="Legend:").pack(side=tk.LEFT) + ttk.Label(map_info_frame, text="🔵 bUE", foreground="blue").pack(side=tk.LEFT, padx=5) + ttk.Label(map_info_frame, text="📍 Marker", foreground="red").pack(side=tk.LEFT, padx=5) + ttk.Label(map_info_frame, text="🟢 Close", foreground="green").pack(side=tk.LEFT, padx=5) + + if self.use_interactive_map and MAP_VIEW_AVAILABLE: + ttk.Label(map_info_frame, text="| Interactive Map Active", foreground="green").pack(side=tk.LEFT, padx=5) + else: + ttk.Label(map_info_frame, text="| Simple Map Active", foreground="orange").pack(side=tk.LEFT, padx=5) + + def setup_interactive_map(self): + """Setup TkinterMapView interactive map""" + try: + # Create the map widget + self.map_widget = tkintermapview.TkinterMapView(self.map_container, width=600, height=400, corner_radius=0) + self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Set a default position (you can change this to your area) + self.map_widget.set_position(40.2518, -111.6493) # Provo, Utah + self.map_widget.set_zoom(10) + + # Clear any existing markers + self.map_markers = {} + + print("Interactive map initialized successfully") + + except Exception as e: + print(f"Failed to setup interactive map: {e}") + logger.error(f"Failed to setup interactive map: {e}") + # Fallback to canvas map + self.use_interactive_map = False + self.setup_canvas_map() + + def setup_canvas_map(self): + """Setup fallback canvas-based map""" + # Create canvas map (original implementation) + self.map_widget = tk.Canvas(self.map_container, bg="lightblue", width=600, height=400) + self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Bind canvas events + self.map_widget.bind("", self.on_map_click) + self.map_widget.bind("", self.on_map_hover) + + def toggle_map_type(self): + """Toggle between interactive map and simple canvas map""" + if not MAP_VIEW_AVAILABLE: + messagebox.showinfo("Map Toggle", "TkinterMapView is not available. Cannot switch map types.") + return + + try: + # Store current state + old_use_interactive = self.use_interactive_map + + # Toggle map type + self.use_interactive_map = not self.use_interactive_map + + # Clear the current map widget + if hasattr(self, "map_widget") and self.map_widget: + self.map_widget.destroy() + + # Create new map + if self.use_interactive_map: + self.setup_interactive_map() + if hasattr(self, "map_toggle_btn"): + self.map_toggle_btn.config(text="Switch to Simple Map") + else: + self.setup_canvas_map() + if hasattr(self, "map_toggle_btn"): + self.map_toggle_btn.config(text="Switch to Interactive Map") + + # Update the map with current data + self.update_map() + + # Update info text + for widget in self.map_container.winfo_children(): + if isinstance(widget, ttk.Frame): + for child in widget.winfo_children(): + if isinstance(child, ttk.Label) and "Map Active" in child.cget("text"): + if self.use_interactive_map: + child.config(text="| Interactive Map Active", foreground="green") + else: + child.config(text="| Simple Map Active", foreground="orange") + + logger.info( + f"Switched from {'Interactive' if old_use_interactive else 'Simple'} to {'Interactive' if self.use_interactive_map else 'Simple'} map" + ) + + except Exception as e: + messagebox.showerror("Map Error", f"Failed to switch map type: {e}") + logger.error(f"Failed to toggle map type: {e}") + + def setup_tables_view(self, parent): + """Setup the tables view with coordinates and distances""" + # Create paned window for tables + tables_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL) + tables_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Coordinates table + coord_frame = ttk.LabelFrame(tables_paned, text="bUE Coordinates") + tables_paned.add(coord_frame, weight=1) + + self.coord_tree = ttk.Treeview(coord_frame, columns=("latitude", "longitude"), show="tree headings") + self.coord_tree.heading("#0", text="bUE ID") + self.coord_tree.heading("latitude", text="Latitude") + self.coord_tree.heading("longitude", text="Longitude") + self.coord_tree.pack(fill=tk.BOTH, expand=True) + + # Distance table + dist_frame = ttk.LabelFrame(tables_paned, text="bUE Distances") + tables_paned.add(dist_frame, weight=1) + + self.dist_tree = ttk.Treeview(dist_frame, columns=("distance",), show="tree headings") + self.dist_tree.heading("#0", text="bUE Pair") + self.dist_tree.heading("distance", text="Distance (m)") + self.dist_tree.pack(fill=tk.BOTH, expand=True) + + def setup_messages_view(self, parent): + """Setup the messages view""" + # Messages text area - adjust height for horizontal layout + self.messages_text = scrolledtext.ScrolledText(parent, height=12, wrap=tk.WORD) + self.messages_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Control frame for buttons + control_frame = ttk.Frame(parent) + control_frame.pack(fill=tk.X, padx=5, pady=5) + + # Clear messages button + ttk.Button(control_frame, text="Clear Messages", command=self.clear_messages).pack(side=tk.LEFT) + + def start_base_station(self): + """Initialize and start the base station""" + try: + self.base_station = Base_Station_Main("config_base.yaml") + self.base_station.tick_enabled = True + self.running = True + + # Start update thread + self.update_thread = threading.Thread(target=self.update_loop, daemon=True) + self.update_thread.start() + + self.status_var.set("Base Station Running") + logger.info("Base Station GUI started successfully") + + except Exception as e: + messagebox.showerror("Error", f"Failed to start base station: {e}") + logger.error(f"Failed to start base station: {e}") + + def update_loop(self): + """Main update loop for GUI refresh""" + while self.running: + try: + if self.base_station: + self.root.after(0, self.update_display) + time.sleep(1) # Update every second + except Exception as e: + logger.error(f"Error in update loop: {e}") + + def update_display(self): + """Update all GUI elements with current data""" + if not self.base_station: + return + + self.update_bue_list() + self.update_map() + self.update_tables() + self.update_messages() + self.update_status() + + def update_bue_list(self): + """Update the bUE list with current connections and status""" + # Clear existing items + for item in self.bue_tree.get_children(): + self.bue_tree.delete(item) + + # Add connected bUEs + for bue_id in self.base_station.connected_bues: + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + + # Determine status + if bue_id in getattr(self.base_station, "testing_bues", []): + status = "🧪 Testing" + else: + status = "💤 Idle" + + # Determine ping status + timeout_val = self.base_station.bue_timeout_tracker.get(bue_id, 0) + if timeout_val >= TIMEOUT / 2: + ping_status = "🟢 Good" + elif timeout_val > 0: + ping_status = "🟡 Warning" + else: + ping_status = "🔴 Lost" + + self.bue_tree.insert("", "end", iid=bue_id, text=bue_name, values=(status, ping_status)) + + def update_map(self): + """Update the map with bUE locations and markers""" + if not hasattr(self, "map_widget") or not self.map_widget: + return + + if self.use_interactive_map and MAP_VIEW_AVAILABLE: + self.update_interactive_map() + else: + self.update_canvas_map() + + def update_interactive_map(self): + """Update TkinterMapView with current data""" + if not hasattr(self, "map_widget") or not self.map_widget: + return + + try: + # Clear existing markers + for marker_id, marker_obj in self.map_markers.items(): + try: + marker_obj.delete() + except: + pass + self.map_markers.clear() + + if not self.base_station or not self.base_station.bue_coordinates: + return + + # Check if bUE positions have changed significantly + current_positions = {} + position_changed = False + new_bues_detected = False + + # Calculate center point for the map + lats = [] + lons = [] + + # Get bUE coordinates and track changes + for bue_id, coords in self.base_station.bue_coordinates.items(): + try: + lat, lon = float(coords[0]), float(coords[1]) + lats.append(lat) + lons.append(lon) + + current_positions[bue_id] = (lat, lon) + + # Check for new bUEs + if bue_id not in self.last_bue_positions: + new_bues_detected = True + # Check for significant position changes (more than ~100 meters) + elif bue_id in self.last_bue_positions: + old_lat, old_lon = self.last_bue_positions[bue_id] + distance_moved = self.calculate_distance(lat, lon, old_lat, old_lon) + if distance_moved > 100: # 100 meters threshold + position_changed = True + + except (ValueError, IndexError): + continue + + # Add custom marker coordinates + for marker in self.custom_markers.values(): + lats.append(marker["lat"]) + lons.append(marker["lon"]) + + # Only auto-center/zoom if: + # 1. This is the first time setting up the map, OR + # 2. New bUEs have been detected, OR + # 3. Existing bUEs have moved significantly + should_auto_position = not self.map_auto_positioned or new_bues_detected or position_changed + + if should_auto_position and lats and lons: + # Set map center to the average of all coordinates + center_lat = sum(lats) / len(lats) + center_lon = sum(lons) / len(lons) + self.map_widget.set_position(center_lat, center_lon) + + # Auto-zoom to fit all markers with extra context + lat_range = max(lats) - min(lats) + lon_range = max(lons) - min(lons) + max_range = max(lat_range, lon_range) + + # Add padding to ensure markers aren't at the edge (25% extra space) + padded_range = max_range * 1.25 + + # Determine zoom level based on coordinate range (less aggressive zooming) + if padded_range > 1: + zoom = 7 # Reduced from 8 - very wide area view + elif padded_range > 0.1: + zoom = 10 # Reduced from 12 - city-level view + elif padded_range > 0.01: + zoom = 12 # Reduced from 15 - neighborhood view + elif padded_range > 0.001: + zoom = 14 # New level - street level with good context + else: + zoom = 15 # Reduced from 17 - close but not too tight + + self.map_widget.set_zoom(zoom) + self.map_auto_positioned = True + + if new_bues_detected: + logger.info("Auto-centered map due to new bUEs") + elif position_changed: + logger.info("Auto-centered map due to significant bUE movement") + + # Update position tracking + self.last_bue_positions = current_positions.copy() + + # Add bUE markers + for bue_id, coords in self.base_station.bue_coordinates.items(): + try: + lat, lon = float(coords[0]), float(coords[1]) + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + + # Check proximity to custom markers + is_close = False + for marker in self.custom_markers.values(): + if marker.get("paired_bue") == bue_id: + distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) + if distance <= 20: # 20 meters proximity + is_close = True + break + + # Choose marker color based on proximity + marker_color = "green" if is_close else "blue" + + # Create custom circle icon matching canvas map style + circle_icon = self.create_circle_marker_icon(marker_color) + + # Create marker with custom circular icon + if circle_icon: + marker = self.map_widget.set_marker( + lat, lon, text=bue_name, icon=circle_icon, font=("Arial", 10, "bold"), text_color="white" + ) + else: + # Fallback to default marker if icon creation failed + marker = self.map_widget.set_marker( + lat, + lon, + text=bue_name, + marker_color_circle=marker_color, + marker_color_outside="darkblue", + font=("Arial", 10, "bold"), + ) + self.map_markers[f"bue_{bue_id}"] = marker + + except (ValueError, IndexError) as e: + logger.error(f"Error plotting bUE {bue_id} on interactive map: {e}") + + # Add custom markers + for marker_id, marker_data in self.custom_markers.items(): + try: + # Create custom circle icon for custom markers + circle_icon = self.create_circle_marker_icon("red") + + # Create custom marker with circular icon + if circle_icon: + marker = self.map_widget.set_marker( + marker_data["lat"], + marker_data["lon"], + text=marker_data["name"], + icon=circle_icon, + font=("Arial", 10, "bold"), + text_color="white", + ) + else: + # Fallback to default marker if icon creation failed + marker = self.map_widget.set_marker( + marker_data["lat"], + marker_data["lon"], + text=marker_data["name"], + marker_color_circle="red", + marker_color_outside="darkred", + font=("Arial", 10, "bold"), + ) + self.map_markers[f"custom_{marker_id}"] = marker + except Exception as e: + logger.error(f"Error plotting custom marker {marker_id} on interactive map: {e}") + + except Exception as e: + logger.error(f"Error updating interactive map: {e}") + + def update_canvas_map(self): + """Update canvas-based map (original implementation)""" + if not hasattr(self, "map_widget") or not self.map_widget: + return + + # Clear canvas + self.map_widget.delete("all") + + if not self.base_station or not self.base_station.bue_coordinates: + self.map_widget.create_text( + 300, + 200, + text="No bUE coordinates available", + font=("Arial", 14), + fill="gray", + ) + return + + # Calculate map bounds + lats = [] + lons = [] + + # Get bUE coordinates + for coords in self.base_station.bue_coordinates.values(): + try: + lat, lon = float(coords[0]), float(coords[1]) + lats.append(lat) + lons.append(lon) + except (ValueError, IndexError): + continue + + # Add custom marker coordinates + for marker in self.custom_markers.values(): + lats.append(marker["lat"]) + lons.append(marker["lon"]) + + if not lats or not lons: + self.map_widget.create_text( + 300, + 200, + text="No valid coordinates available", + font=("Arial", 14), + fill="gray", + ) + return + + # Calculate bounds with padding + min_lat, max_lat = min(lats), max(lats) + min_lon, max_lon = min(lons), max(lons) + + # Add padding + lat_padding = (max_lat - min_lat) * 0.1 or 0.001 + lon_padding = (max_lon - min_lon) * 0.1 or 0.001 + + min_lat -= lat_padding + max_lat += lat_padding + min_lon -= lon_padding + max_lon += lon_padding + + # Get canvas dimensions + canvas_width = self.map_widget.winfo_width() or 600 + canvas_height = self.map_widget.winfo_height() or 400 + + # Map coordinate conversion functions + def lat_to_y(lat): + return canvas_height - ((lat - min_lat) / (max_lat - min_lat)) * canvas_height + + def lon_to_x(lon): + return ((lon - min_lon) / (max_lon - min_lon)) * canvas_width + + # Draw bUEs + for bue_id, coords in self.base_station.bue_coordinates.items(): + try: + lat, lon = float(coords[0]), float(coords[1]) + x, y = lon_to_x(lon), lat_to_y(lat) + + # Check proximity to custom markers + is_close = False + for marker in self.custom_markers.values(): + if marker.get("paired_bue") == bue_id: + distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) + if distance <= 20: # 20 meters proximity + is_close = True + break + + # Choose color based on proximity + color = "green" if is_close else "blue" + + # Draw bUE circle + radius = 8 + self.map_widget.create_oval( + x - radius, + y - radius, + x + radius, + y + radius, + fill=color, + outline="darkblue", + width=2, + tags=f"bue_{bue_id}", + ) + + # Label + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + self.map_widget.create_text( + x, + y - 15, + text=bue_name, + font=("Arial", 8), + fill="black", + tags=f"bue_{bue_id}", + ) + + except (ValueError, IndexError) as e: + logger.error(f"Error plotting bUE {bue_id}: {e}") + + # Draw custom markers + for marker_id, marker in self.custom_markers.items(): + x, y = lon_to_x(marker["lon"]), lat_to_y(marker["lat"]) + + # Draw marker + radius = 6 + self.map_widget.create_oval( + x - radius, + y - radius, + x + radius, + y + radius, + fill="red", + outline="darkred", + width=2, + tags=f"marker_{marker_id}", + ) + + # Label + self.map_widget.create_text( + x, + y - 15, + text=marker["name"], + font=("Arial", 8), + fill="red", + tags=f"marker_{marker_id}", + ) + + def update_tables(self): + """Update coordinate and distance tables""" + # Update coordinates table + for item in self.coord_tree.get_children(): + self.coord_tree.delete(item) + + if self.base_station: + for bue_id, coords in self.base_station.bue_coordinates.items(): + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + try: + lat, lon = coords[0], coords[1] + self.coord_tree.insert("", "end", text=bue_name, values=(lat, lon)) + except (IndexError, ValueError): + self.coord_tree.insert("", "end", text=bue_name, values=("Invalid", "Invalid")) + + # Update distance table + for item in self.dist_tree.get_children(): + self.dist_tree.delete(item) + + if self.base_station and len(self.base_station.connected_bues) > 1: + processed_pairs = set() + for bue1 in self.base_station.connected_bues: + for bue2 in self.base_station.connected_bues: + if ( + bue1 != bue2 + and bue1 in self.base_station.bue_coordinates + and bue2 in self.base_station.bue_coordinates + and (bue1, bue2) not in processed_pairs + and (bue2, bue1) not in processed_pairs + ): + + distance = self.base_station.get_distance(bue1, bue2) + if distance is not None: + pair_name = f"{bUEs.get(str(bue1), str(bue1))} ↔ {bUEs.get(str(bue2), str(bue2))}" + self.dist_tree.insert("", "end", text=pair_name, values=(f"{distance:.2f}")) + + processed_pairs.add((bue1, bue2)) + + def update_messages(self): + """Update the messages display""" + if self.base_station and hasattr(self.base_station, "stdout_history"): + # Get current content + current_content = self.messages_text.get(1.0, tk.END) + + # Build new content + new_content = "\n".join(self.base_station.stdout_history) + + # Only update if content changed + if new_content.strip() != current_content.strip(): + self.messages_text.delete(1.0, tk.END) + self.messages_text.insert(1.0, new_content) + self.messages_text.see(tk.END) # Scroll to bottom + + def update_status(self): + """Update the status bar""" + if self.base_station: + connected = len(self.base_station.connected_bues) + testing = len(getattr(self.base_station, "testing_bues", [])) + current_time = datetime.now().strftime("%H:%M:%S") + self.status_var.set(f"Time: {current_time} | Connected: {connected} | Testing: {testing}") + + def show_bue_context_menu(self, event): + """Show context menu for bUE operations""" + item = self.bue_tree.selection()[0] if self.bue_tree.selection() else None + if not item: + return + + bue_id = int(item) + + # Create context menu + context_menu = tk.Menu(self.root, tearoff=0) + context_menu.add_command(label="Disconnect", command=lambda: self.disconnect_bue(bue_id)) + context_menu.add_command(label="Reload", command=lambda: self.reload_bue(bue_id)) + context_menu.add_command(label="Restart", command=lambda: self.restart_bue(bue_id)) + context_menu.add_separator() + context_menu.add_command(label="Open Log File", command=lambda: self.open_bue_log(bue_id)) + + # Show menu + try: + context_menu.tk_popup(event.x_root, event.y_root) + finally: + context_menu.grab_release() + + def disconnect_bue(self, bue_id): + """Disconnect a specific bUE""" + if messagebox.askyesno( + "Confirm Disconnect", + f"Disconnect from {bUEs.get(str(bue_id), str(bue_id))}?", + ): + try: + self.base_station.connected_bues.remove(bue_id) + if bue_id in self.base_station.bue_coordinates: + del self.base_station.bue_coordinates[bue_id] + if bue_id in getattr(self.base_station, "testing_bues", []): + self.base_station.testing_bues.remove(bue_id) + if bue_id in self.base_station.bue_timeout_tracker: + del self.base_station.bue_timeout_tracker[bue_id] + logger.info(f"Disconnected from bUE {bue_id}") + except Exception as e: + messagebox.showerror("Error", f"Failed to disconnect: {e}") + + def reload_bue(self, bue_id): + """Reload a specific bUE""" + if messagebox.askyesno("Confirm Reload", f"Reload {bUEs.get(str(bue_id), str(bue_id))}?"): + try: + self.base_station.ota.send_ota_message(bue_id, "RELOAD") + self.disconnect_bue(bue_id) + logger.info(f"Sent reload command to bUE {bue_id}") + except Exception as e: + messagebox.showerror("Error", f"Failed to reload: {e}") + + def restart_bue(self, bue_id): + """Restart a specific bUE""" + if messagebox.askyesno("Confirm Restart", f"Restart {bUEs.get(str(bue_id), str(bue_id))}?"): + try: + self.base_station.ota.send_ota_message(bue_id, "RESTART") + self.disconnect_bue(bue_id) + logger.info(f"Sent restart command to bUE {bue_id}") + except Exception as e: + messagebox.showerror("Error", f"Failed to restart: {e}") + + def open_bue_log(self, bue_id): + """Open the log file for a specific bUE""" + log_path = f"logs/bue_{bue_id}.log" + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + LogViewerDialog(self.root, log_path, f"{bue_name} Log") + + def open_base_log(self): + """Open the base station log file""" + log_path = "logs/base_station.log" + LogViewerDialog(self.root, log_path, "Base Station Log") + + def run_test(self): + """Run test dialog and execute tests""" + if not self.base_station or not self.base_station.connected_bues: + messagebox.showwarning("No bUEs", "No bUEs currently connected") + return + + # Create test dialog + TestDialog(self.root, self.base_station) + + def cancel_tests(self): + """Cancel running tests""" + if not hasattr(self.base_station, "testing_bues") or not self.base_station.testing_bues: + messagebox.showinfo("No Tests", "No tests currently running") + return + + # Create cancel dialog + CancelTestDialog(self.root, self.base_station) + + def clear_messages(self): + """Clear the messages display""" + self.messages_text.delete(1.0, tk.END) + if self.base_station and hasattr(self.base_station, "stdout_history"): + self.base_station.stdout_history.clear() + + def add_custom_marker(self): + """Add a custom marker to the map""" + AddMarkerDialog(self.root, self) + + def manage_markers(self): + """Manage existing custom markers""" + ManageMarkersDialog(self.root, self) + + def on_map_click(self, event): + """Handle map click events""" + # Get clicked coordinates (simplified - would need proper coordinate conversion) + pass + + def on_map_hover(self, event): + """Handle map hover events""" + # Show coordinates or object info on hover + pass + + def calculate_distance(self, lat1, lon1, lat2, lon2): + """Calculate distance between two coordinates in meters""" + # Haversine formula + R = 6371000 # Earth's radius in meters + + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + + a = math.sin(delta_lat / 2) * math.sin(delta_lat / 2) + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin( + delta_lon / 2 + ) * math.sin(delta_lon / 2) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + return R * c + + def on_closing(self): + """Handle application closing""" + if messagebox.askokcancel("Quit", "Do you want to quit?"): + self.running = False + if self.base_station: + self.base_station.EXIT = True + if hasattr(self.base_station, "__del__"): + self.base_station.__del__() + self.root.destroy() + + +class TestDialog: + """All-in-one test dialog - everything in a single window""" + + def __init__(self, parent, base_station): + self.parent = parent + self.base_station = base_station + + self.dialog = tk.Toplevel(parent) + self.dialog.title("Test Management") + self.dialog.geometry("700x700") + self.dialog.grab_set() + + # Available test files + self.test_files = [ + "grc/lora_td_ru", + "grc/lora_tu_rd", + "Old/helloworld", + "Old/sf_msg_test", + "Old" "gpstest", + "gpstest2", + "../osu_testing/run_tx", + "../osu_testing/run_rx", + "../osu_testing/run_rx_12_20", + ] + + # Selected bUEs and their configurations + self.selected_bues = [] + self.bue_configs = {} # {bue_id: {'file': str, 'params': str}} + + self.setup_dialog() + + def setup_dialog(self): + """Setup the all-in-one test dialog""" + # Step 1: bUE Selection + selection_frame = ttk.LabelFrame(self.dialog, text="Step 1: Select bUEs for Testing", padding="10") + selection_frame.pack(fill=tk.X, padx=10, pady=5) + + ttk.Label(selection_frame, text="Choose which bUEs will run tests:").pack(anchor=tk.W, pady=(0, 5)) + + # Create checkboxes for connected bUEs + self.bue_vars = {} + checkbox_frame = ttk.Frame(selection_frame) + checkbox_frame.pack(fill=tk.X) + + row = 0 + col = 0 + for i, bue_id in enumerate(self.base_station.connected_bues): + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + var = tk.BooleanVar() + self.bue_vars[bue_id] = var + + cb = ttk.Checkbutton( + checkbox_frame, + text=bue_name, + variable=var, + command=self.update_selection, + ) + cb.grid(row=row, column=col, sticky=tk.W, padx=20, pady=2) + + col += 1 + if col > 1: # 2 columns + col = 0 + row += 1 + + # Selection summary + self.selection_label = ttk.Label(selection_frame, text="No bUEs selected", foreground="gray") + self.selection_label.pack(anchor=tk.W, pady=(5, 0)) + + # Step 2: Test Delay + time_frame = ttk.LabelFrame(self.dialog, text="Step 2: Set Test Delay", padding="10") + time_frame.pack(fill=tk.X, padx=10, pady=5) + + # Delay input + delay_controls = ttk.Frame(time_frame) + delay_controls.pack() + + ttk.Label(delay_controls, text="Start test in:").grid(row=0, column=0, padx=5) + self.delay_var = tk.StringVar(value="30") + delay_spin = tk.Spinbox(delay_controls, from_=5, to=300, textvariable=self.delay_var, width=5) + delay_spin.grid(row=0, column=1, padx=5) + ttk.Label(delay_controls, text="seconds").grid(row=0, column=2, padx=5) + + # Calculated start time display + self.start_time_label = ttk.Label(time_frame, text="", foreground="blue", font=("TkDefaultFont", 9)) + self.start_time_label.pack(pady=(10, 0)) + + # Update the calculated time when delay changes + self.delay_var.trace("w", self.update_calculated_time) + + # Start automatic time updates every second + self.start_auto_time_updates() + self.update_calculated_time() # Initial calculation + + # Step 3: Configure Individual bUEs - ALL IN ONE WINDOW + config_frame = ttk.LabelFrame(self.dialog, text="Step 3: Configure Each Selected bUE", padding="10") + config_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Create a scrollable frame for bUE configurations + canvas = tk.Canvas(config_frame, height=300) + scrollbar = ttk.Scrollbar(config_frame, orient="vertical", command=canvas.yview) + self.scrollable_frame = ttk.Frame(canvas) + + self.scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + + canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Initially empty - will be populated when bUEs are selected + self.no_selection_label = ttk.Label( + self.scrollable_frame, + text="Select bUEs above to configure their tests here", + foreground="gray", + ) + self.no_selection_label.pack(pady=50) + + # Buttons + button_frame = ttk.Frame(self.dialog) + button_frame.pack(fill=tk.X, padx=10, pady=10) + + self.run_btn = ttk.Button(button_frame, text="Run Tests", command=self.run_tests, state=tk.DISABLED) + self.run_btn.pack(side=tk.LEFT, padx=5) + + ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) + + def update_selection(self): + """Update the selection and show inline configuration""" + self.selected_bues = [bue_id for bue_id, var in self.bue_vars.items() if var.get()] + + if self.selected_bues: + bue_names = [bUEs.get(str(bid), f"bUE {bid}") for bid in self.selected_bues] + self.selection_label.config(text=f"Selected: {', '.join(bue_names)}", foreground="blue") + + # Show inline configuration for each selected bUE + self.show_inline_configs() + else: + self.selection_label.config(text="No bUEs selected", foreground="gray") + self.clear_inline_configs() + self.run_btn.config(state=tk.DISABLED) + + def start_auto_time_updates(self): + """Start automatic time updates every second""" + self.update_calculated_time() + # Schedule next update in 1000ms (1 second) + self.dialog.after(1000, self.start_auto_time_updates) + + def update_calculated_time(self, *args): + """Update the calculated start time display using current time""" + try: + delay_seconds = int(self.delay_var.get()) + # Always use current time for real-time updates + current_time = datetime.now().replace(microsecond=0) + start_time = current_time + timedelta(seconds=delay_seconds) + + # Format the time nicely + time_str = start_time.strftime("%I:%M:%S %p") + date_str = start_time.strftime("%Y-%m-%d") + + if start_time.date() == current_time.date(): + # Same day + self.start_time_label.config(text=f"Tests will start at: {time_str} (today)") + else: + # Next day + self.start_time_label.config(text=f"Tests will start at: {time_str} on {date_str}") + + except ValueError: + self.start_time_label.config(text="Invalid delay time") + + def show_inline_configs(self): + """Show configuration options for each selected bUE inline""" + # Clear existing config widgets + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + + self.config_widgets = {} + + for i, bue_id in enumerate(self.selected_bues): + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + + # Create a frame for this bUE's configuration + bue_frame = ttk.LabelFrame(self.scrollable_frame, text=f"Configure {bue_name}", padding="10") + bue_frame.pack(fill=tk.X, padx=5, pady=5) + + # Test file selection row + file_frame = ttk.Frame(bue_frame) + file_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(file_frame, text="Test File:", width=12).pack(side=tk.LEFT) + file_var = tk.StringVar(value=self.test_files[0]) + file_combo = ttk.Combobox( + file_frame, + textvariable=file_var, + values=self.test_files, + state="readonly", + width=20, + ) + file_combo.pack(side=tk.LEFT, padx=(5, 0)) + + # Message field (always visible) + msg_frame = ttk.Frame(bue_frame) + msg_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(msg_frame, text="Message:", width=12).pack(side=tk.LEFT) + msg_var = tk.StringVar() + msg_entry = ttk.Entry(msg_frame, textvariable=msg_var, width=20) + msg_entry.pack(side=tk.LEFT, padx=(5, 0)) + + # Conditional parameters frame for run_tx/run_rx only + params_frame = ttk.Frame(bue_frame) + + sf = [5, 6, 7, 8, 9, 10, 11, 12] + + # Create all the conditional widgets but don't pack them yet + sf_var = tk.IntVar(value=8) + bw_var = tk.StringVar(value="6000") # Default bandwidth + freq_var = tk.StringVar(value="12000") # Default center frequency + period_var = tk.StringVar(value="3000") # Default period + + # Row 1: Spreading Factor and Bandwidth + row1_frame = ttk.Frame(params_frame) + ttk.Label(row1_frame, text="Spreading Factor:", width=16).pack(side=tk.LEFT) + sf_entry = ttk.Combobox(row1_frame, textvariable=sf_var, values=sf, state="readonly", width=8) + sf_entry.pack(side=tk.LEFT, padx=(5, 15)) + + ttk.Label(row1_frame, text="Bandwidth:", width=12).pack(side=tk.LEFT) + bw_entry = ttk.Entry(row1_frame, textvariable=bw_var, width=10) + bw_entry.pack(side=tk.LEFT, padx=(5, 0)) + + # Row 2: Center Frequency and Period + row2_frame = ttk.Frame(params_frame) + ttk.Label(row2_frame, text="Center Frequency:", width=16).pack(side=tk.LEFT) + freq_entry = ttk.Entry(row2_frame, textvariable=freq_var, width=12) + freq_entry.pack(side=tk.LEFT, padx=(5, 15)) + + ttk.Label(row2_frame, text="Period:", width=12).pack(side=tk.LEFT) + period_entry = ttk.Entry(row2_frame, textvariable=period_var, width=10) + period_entry.pack(side=tk.LEFT, padx=(5, 0)) + + # Validation function for integer-only fields + def validate_integer(char): + return char.isdigit() + + vcmd = (self.dialog.register(validate_integer), "%S") + bw_entry.config(validate="key", validatecommand=vcmd) + freq_entry.config(validate="key", validatecommand=vcmd) + period_entry.config(validate="key", validatecommand=vcmd) + + def update_params_visibility(*args, fvar=file_var, pframe=params_frame, r1frame=row1_frame, r2frame=row2_frame): + selected_file = fvar.get() + if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): + pframe.pack(fill=tk.X, pady=(5, 0)) + r1frame.pack(fill=tk.X, pady=(0, 5)) + r2frame.pack(fill=tk.X, pady=(0, 5)) + else: + pframe.pack_forget() + + # Bind file selection changes to update visibility + file_var.trace("w", update_params_visibility) + # Initial visibility check + update_params_visibility() + + # Add placeholder functionality + placeholder_text = "No spaces" + msg_entry.insert(0, placeholder_text) + msg_entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) + + def on_focus_in(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): + if var.get() == placeholder: + entry.delete(0, tk.END) + entry.config(foreground="black", font=("TkDefaultFont", 10, "normal")) + + def on_focus_out(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): + if not var.get(): + entry.insert(0, placeholder) + entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) + + msg_entry.bind("", on_focus_in) + msg_entry.bind("", on_focus_out) + + # Store the variables for this bUE + self.config_widgets[bue_id] = { + "file_var": file_var, + "sf_var": sf_var, + "bw_var": bw_var, + "freq_var": freq_var, + "period_var": period_var, + "msg_var": msg_var, + "file_combo": file_combo, + "sf_entry": sf_entry, + "bw_entry": bw_entry, + "freq_entry": freq_entry, + "period_entry": period_entry, + "msg_entry": msg_entry, + } + + # Bind changes to enable run button + file_var.trace("w", self.check_ready_to_run) + sf_var.trace("w", self.check_ready_to_run) + bw_var.trace("w", self.check_ready_to_run) + freq_var.trace("w", self.check_ready_to_run) + period_var.trace("w", self.check_ready_to_run) + msg_var.trace("w", self.check_ready_to_run) + # Enable run button if we have configurations + self.check_ready_to_run() + + def clear_inline_configs(self): + """Clear all configuration widgets""" + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + + self.no_selection_label = ttk.Label( + self.scrollable_frame, + text="Select bUEs above to configure their tests here", + foreground="gray", + ) + self.no_selection_label.pack(pady=50) + + self.config_widgets = {} + + def check_ready_to_run(self, *args): + """Check if all configurations are ready and enable run button""" + if self.selected_bues and hasattr(self, "config_widgets") and self.config_widgets: + # All selected bUEs have configuration widgets, enable run + self.run_btn.config(state=tk.NORMAL) + else: + self.run_btn.config(state=tk.DISABLED) + + def run_tests(self): + """Execute the configured tests""" + if not self.selected_bues or not hasattr(self, "config_widgets"): + messagebox.showwarning( + "No Configuration", + "Please select and configure at least one bUE for testing", + ) + return + + # Collect configurations from the inline widgets + self.bue_configs = {} + for bue_id in self.selected_bues: + if bue_id in self.config_widgets: + widgets = self.config_widgets[bue_id] + config = { + "file": widgets["file_var"].get(), + "msg": widgets["msg_var"].get(), + } + + # Add run_tx/run_rx specific parameters if applicable + selected_file = widgets["file_var"].get() + if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): + config.update( + { + "sf": widgets["sf_var"].get(), + "bw": widgets["bw_var"].get(), + "freq": widgets["freq_var"].get(), + "period": widgets["period_var"].get(), + } + ) + + self.bue_configs[bue_id] = config + + # Calculate start time using delay - use CURRENT time for actual execution + try: + delay_seconds = int(self.delay_var.get()) + execution_time = datetime.now().replace(microsecond=0) # Fresh time for execution + start_time = execution_time + timedelta(seconds=delay_seconds) + unix_timestamp = int(start_time.timestamp()) + + # Format the start time for user confirmation + time_str = start_time.strftime("%I:%M:%S %p") + + # Send test commands + for bue_id, config in self.bue_configs.items(): + selected_file = config["file"] # ← FIX: Get file from config, not from outside loop + + if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): + command = f"TEST:{config['file']},{unix_timestamp},-s {config['sf']} -m {config['msg']} -c {config['freq']} -b {config['bw']} -p {config['period']}" + else: + command = f"TEST:{config['file']},{unix_timestamp}," + + self.base_station.ota.send_ota_message(bue_id, command) + time.sleep(0.1) + logger.info(f"Sent test command to bUE {bue_id}: {command}") + + bue_names = [bUEs.get(str(bue_id), str(bue_id)) for bue_id in self.bue_configs.keys()] + messagebox.showinfo( + "Tests Scheduled", + f"Tests scheduled for: {', '.join(bue_names)}\n\n" + f"Actual start time: {time_str}\n" + f"Delay: {delay_seconds} seconds from when you clicked 'Run'", + ) + self.dialog.destroy() + + except Exception as e: + messagebox.showerror("Error", f"Failed to schedule tests: {e}") + + +# Remove the IndividualBueConfigDialog since we don't need it anymore + + +class ConfigureBueDialog: + """Dialog for configuring a single bUE test""" + + def __init__(self, parent, bue_id, test_files, config_tree): + self.parent = parent + self.bue_id = bue_id + self.test_files = test_files + self.config_tree = config_tree + + self.dialog = tk.Toplevel(parent) + self.dialog.title(f"Configure {bUEs.get(str(bue_id), f'bUE {bue_id}')}") + self.dialog.geometry("400x200") + self.dialog.grab_set() + + self.setup_dialog() + + def setup_dialog(self): + """Setup the configuration dialog""" + ttk.Label( + self.dialog, + text=f"Configure test for {bUEs.get(str(self.bue_id), f'bUE {self.bue_id}')}", + ).pack(pady=10) + + # Test file selection + ttk.Label(self.dialog, text="Test File:").pack(anchor=tk.W, padx=20) + self.file_var = tk.StringVar(value=self.test_files[0]) + file_combo = ttk.Combobox( + self.dialog, + textvariable=self.file_var, + values=self.test_files, + state="readonly", + ) + file_combo.pack(fill=tk.X, padx=20, pady=5) + + # Parameters + ttk.Label(self.dialog, text="Parameters:").pack(anchor=tk.W, padx=20) + self.params_var = tk.StringVar() + ttk.Entry(self.dialog, textvariable=self.params_var).pack(fill=tk.X, padx=20, pady=5) + + # Buttons + button_frame = ttk.Frame(self.dialog) + button_frame.pack(fill=tk.X, padx=20, pady=10) + + ttk.Button(button_frame, text="OK", command=self.save_config).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) + + def save_config(self): + """Save the configuration""" + bue_name = bUEs.get(str(self.bue_id), f"bUE {self.bue_id}") + self.config_tree.insert( + "", + "end", + text=bue_name, + values=(self.file_var.get(), self.params_var.get()), + ) + self.dialog.destroy() + + +class CancelTestDialog: + """Dialog for canceling running tests""" + + def __init__(self, parent, base_station): + self.parent = parent + self.base_station = base_station + + self.dialog = tk.Toplevel(parent) + self.dialog.title("Cancel Tests") + self.dialog.geometry("300x200") + self.dialog.grab_set() + + self.setup_dialog() + + def setup_dialog(self): + """Setup the cancel dialog""" + ttk.Label(self.dialog, text="Select tests to cancel:").pack(pady=10) + + self.test_vars = {} + for bue_id in getattr(self.base_station, "testing_bues", []): + bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + var = tk.BooleanVar() + self.test_vars[bue_id] = var + ttk.Checkbutton(self.dialog, text=bue_name, variable=var).pack(anchor=tk.W, padx=20) + + # Buttons + button_frame = ttk.Frame(self.dialog) + button_frame.pack(fill=tk.X, padx=20, pady=10) + + ttk.Button(button_frame, text="Cancel Selected", command=self.cancel_tests).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) + + def cancel_tests(self): + """Cancel selected tests""" + canceled = [] + for bue_id, var in self.test_vars.items(): + if var.get(): + try: + for i in range(3): + self.base_station.ota.send_ota_message(bue_id, "CANC") + time.sleep(0.1) + canceled.append(bUEs.get(str(bue_id), str(bue_id))) + logger.info(f"Sent cancel command to bUE {bue_id}") + except Exception as e: + logger.error(f"Failed to cancel test for bUE {bue_id}: {e}") + + if canceled: + messagebox.showinfo("Tests Canceled", f"Canceled tests for: {', '.join(canceled)}") + + self.dialog.destroy() + + +class AddMarkerDialog: + """Dialog for adding custom markers""" + + def __init__(self, parent, main_gui): + self.parent = parent + self.main_gui = main_gui + + self.dialog = tk.Toplevel(parent) + self.dialog.title("Add Custom Marker") + self.dialog.geometry("400x350") + self.dialog.grab_set() + + self.setup_dialog() + + def setup_dialog(self): + """Setup the add marker dialog""" + # Marker name + ttk.Label(self.dialog, text="Marker Name:").pack(anchor=tk.W, padx=20, pady=(20, 5)) + self.name_var = tk.StringVar() + ttk.Entry(self.dialog, textvariable=self.name_var, width=30).pack(fill=tk.X, padx=20, pady=5) + + # Coordinates + ttk.Label(self.dialog, text="Latitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) + self.lat_var = tk.StringVar() + ttk.Entry(self.dialog, textvariable=self.lat_var, width=30).pack(fill=tk.X, padx=20, pady=5) + + ttk.Label(self.dialog, text="Longitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) + self.lon_var = tk.StringVar() + ttk.Entry(self.dialog, textvariable=self.lon_var, width=30).pack(fill=tk.X, padx=20, pady=5) + + # Pair with bUE + ttk.Label(self.dialog, text="Pair with bUE (optional):").pack(anchor=tk.W, padx=20, pady=(10, 5)) + try: + bue_options = ["None"] + [ + bUEs.get(str(bue_id), f"bUE {bue_id}") for bue_id in self.main_gui.base_station.connected_bues + ] + self.bue_var = tk.StringVar(value="None") + ttk.Combobox( + self.dialog, + textvariable=self.bue_var, + values=bue_options, + state="readonly", + ).pack(fill=tk.X, padx=20, pady=5) + except Exception as e: + print(f"Error creating bUE combobox: {e}") + # Fallback simple combobox + self.bue_var = tk.StringVar(value="None") + ttk.Combobox( + self.dialog, + textvariable=self.bue_var, + values=["None"], + state="readonly", + ).pack(fill=tk.X, padx=20, pady=5) + + # Buttons + button_frame = ttk.Frame(self.dialog) + button_frame.pack(fill=tk.X, padx=20, pady=20) + + # Add some spacing between buttons + ttk.Button(button_frame, text="Add Marker", command=self.add_marker).pack(side=tk.LEFT, padx=(0, 10)) + ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.LEFT, padx=(10, 0)) + + def add_marker(self): + """Add the custom marker""" + try: + name = self.name_var.get().strip() + lat = float(self.lat_var.get()) + lon = float(self.lon_var.get()) + + if not name: + messagebox.showwarning("Invalid Input", "Please enter a marker name") + return + + # Get paired bUE ID + paired_bue = None + bue_selection = self.bue_var.get() + if bue_selection != "None": + for bue_id in self.main_gui.base_station.connected_bues: + if bUEs.get(str(bue_id), f"bUE {bue_id}") == bue_selection: + paired_bue = bue_id + break + + # Add marker + marker_id = self.main_gui.marker_counter + self.main_gui.marker_counter += 1 + + self.main_gui.custom_markers[marker_id] = { + "name": name, + "lat": lat, + "lon": lon, + "paired_bue": paired_bue, + } + + # Refresh the map to show the new marker + self.main_gui.update_map() + + messagebox.showinfo("Marker Added", f"Added marker '{name}'") + self.dialog.destroy() + + except ValueError: + messagebox.showerror("Invalid Input", "Please enter valid coordinates") + except Exception as e: + messagebox.showerror("Error", f"Failed to add marker: {e}") + + +class ManageMarkersDialog: + """Dialog for managing custom markers""" + + def __init__(self, parent, main_gui): + self.parent = parent + self.main_gui = main_gui + + self.dialog = tk.Toplevel(parent) + self.dialog.title("Manage Custom Markers") + self.dialog.geometry("600x400") + self.dialog.grab_set() + + self.setup_dialog() + self.refresh_markers() + + def setup_dialog(self): + """Setup the manage markers dialog""" + # Markers list + self.markers_tree = ttk.Treeview(self.dialog, columns=("lat", "lon", "paired_bue"), show="tree headings") + self.markers_tree.heading("#0", text="Marker Name") + self.markers_tree.heading("lat", text="Latitude") + self.markers_tree.heading("lon", text="Longitude") + self.markers_tree.heading("paired_bue", text="Paired bUE") + + self.markers_tree.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) + + # Buttons + button_frame = ttk.Frame(self.dialog) + button_frame.pack(fill=tk.X, padx=20, pady=10) + + ttk.Button(button_frame, text="Delete Selected", command=self.delete_marker).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Edit Selected", command=self.edit_marker).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) + + def refresh_markers(self): + """Refresh the markers list""" + for item in self.markers_tree.get_children(): + self.markers_tree.delete(item) + + for marker_id, marker in self.main_gui.custom_markers.items(): + paired_bue_name = "None" + if marker.get("paired_bue"): + paired_bue_name = bUEs.get(str(marker["paired_bue"]), f"bUE {marker['paired_bue']}") + + self.markers_tree.insert( + "", + "end", + iid=marker_id, + text=marker["name"], + values=(marker["lat"], marker["lon"], paired_bue_name), + ) + + def delete_marker(self): + """Delete selected marker""" + selection = self.markers_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a marker to delete") + return + + marker_id = int(selection[0]) + marker_name = self.main_gui.custom_markers[marker_id]["name"] + + if messagebox.askyesno("Confirm Delete", f"Delete marker '{marker_name}'?"): + del self.main_gui.custom_markers[marker_id] + self.refresh_markers() + # Refresh the map to remove the deleted marker + self.main_gui.update_map() + + def edit_marker(self): + """Edit selected marker""" + selection = self.markers_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a marker to edit") + return + + marker_id = int(selection[0]) + # Could implement edit dialog similar to AddMarkerDialog + messagebox.showinfo("Edit Marker", "Edit functionality not yet implemented") + + +class LogViewerDialog: + """Dialog for viewing log files within the GUI""" + + def __init__(self, parent, log_path, title): + self.parent = parent + self.log_path = log_path + + self.dialog = tk.Toplevel(parent) + self.dialog.title(title) + self.dialog.geometry("900x600") + self.dialog.grab_set() + + # Make dialog resizable + self.dialog.resizable(True, True) + + self.setup_dialog() + self.load_log_content() + + # Auto-refresh thread for live log viewing + self.auto_refresh = True + self.refresh_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) + self.refresh_thread.start() + + # Handle dialog close + self.dialog.protocol("WM_DELETE_WINDOW", self.on_closing) + + def setup_dialog(self): + """Setup the log viewer dialog""" + # Top frame with controls + control_frame = ttk.Frame(self.dialog) + control_frame.pack(fill=tk.X, padx=10, pady=5) + + # File info + self.file_info_var = tk.StringVar() + ttk.Label(control_frame, textvariable=self.file_info_var).pack(side=tk.LEFT) + + # Buttons + button_frame = ttk.Frame(control_frame) + button_frame.pack(side=tk.RIGHT) + + ttk.Button(button_frame, text="Refresh", command=self.refresh_log).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="Clear", command=self.clear_log).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="Save As...", command=self.save_log).pack(side=tk.LEFT, padx=2) + + # Auto-refresh toggle + self.auto_refresh_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + button_frame, + text="Auto-refresh", + variable=self.auto_refresh_var, + command=self.toggle_auto_refresh, + ).pack(side=tk.LEFT, padx=5) + + # Search frame + search_frame = ttk.Frame(self.dialog) + search_frame.pack(fill=tk.X, padx=10, pady=2) + + ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT) + self.search_var = tk.StringVar() + self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var) + self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + self.search_entry.bind("", self.search_log) + self.search_entry.bind("", self.search_as_type) + + ttk.Button(search_frame, text="Find", command=self.search_log).pack(side=tk.LEFT, padx=2) + ttk.Button(search_frame, text="Clear Search", command=self.clear_search).pack(side=tk.LEFT, padx=2) + + # Log content area with scrollbars + content_frame = ttk.Frame(self.dialog) + content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Text widget with scrollbars + self.log_text = scrolledtext.ScrolledText( + content_frame, + wrap=tk.WORD, + font=("Consolas", 10), # Monospace font for logs + state=tk.DISABLED, + ) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # Configure text tags for highlighting + self.log_text.tag_configure("error", foreground="red", font=("Consolas", 10, "bold")) + self.log_text.tag_configure("warning", foreground="orange", font=("Consolas", 10, "bold")) + self.log_text.tag_configure("info", foreground="blue") + self.log_text.tag_configure("debug", foreground="gray") + self.log_text.tag_configure("search_highlight", background="yellow") + + # Status bar + self.status_var = tk.StringVar() + status_bar = ttk.Label(self.dialog, textvariable=self.status_var, relief=tk.SUNKEN) + status_bar.pack(side=tk.BOTTOM, fill=tk.X) + + def load_log_content(self): + """Load log file content""" + try: + if os.path.exists(self.log_path): + with open(self.log_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + # Update text widget + self.log_text.config(state=tk.NORMAL) + self.log_text.delete(1.0, tk.END) + + # Apply syntax highlighting + self.insert_with_highlighting(content) + + self.log_text.config(state=tk.DISABLED) + self.log_text.see(tk.END) # Scroll to bottom + + # Update file info + file_size = os.path.getsize(self.log_path) + line_count = content.count("\n") + self.file_info_var.set(f"File: {self.log_path} | Size: {file_size:,} bytes | Lines: {line_count:,}") + self.status_var.set("Log loaded successfully") + + else: + self.log_text.config(state=tk.NORMAL) + self.log_text.delete(1.0, tk.END) + self.log_text.insert(1.0, f"Log file not found: {self.log_path}") + self.log_text.config(state=tk.DISABLED) + self.file_info_var.set(f"File: {self.log_path} | Status: Not Found") + self.status_var.set("Log file not found") + + except Exception as e: + self.log_text.config(state=tk.NORMAL) + self.log_text.delete(1.0, tk.END) + self.log_text.insert(1.0, f"Error reading log file: {e}") + self.log_text.config(state=tk.DISABLED) + self.status_var.set(f"Error: {e}") + + def insert_with_highlighting(self, content): + """Insert content with syntax highlighting for log levels""" + lines = content.split("\n") + + for line in lines: + line_lower = line.lower() + + # Determine tag based on log level + if "error" in line_lower or "failed" in line_lower or "exception" in line_lower: + tag = "error" + elif "warning" in line_lower or "warn" in line_lower: + tag = "warning" + elif "info" in line_lower: + tag = "info" + elif "debug" in line_lower: + tag = "debug" + else: + tag = None + + if tag: + self.log_text.insert(tk.END, line + "\n", tag) + else: + self.log_text.insert(tk.END, line + "\n") + + def refresh_log(self): + """Manually refresh the log content""" + self.load_log_content() + + def clear_log(self): + """Clear the log display (not the actual file)""" + if messagebox.askyesno( + "Clear Display", + "Clear the log display? (This won't delete the actual log file)", + ): + self.log_text.config(state=tk.NORMAL) + self.log_text.delete(1.0, tk.END) + self.log_text.config(state=tk.DISABLED) + self.status_var.set("Display cleared") + + def save_log(self): + """Save log content to a new file""" + try: + file_path = filedialog.asksaveasfilename( + defaultextension=".log", + filetypes=[ + ("Log files", "*.log"), + ("Text files", "*.txt"), + ("All files", "*.*"), + ], + ) + + if file_path: + content = self.log_text.get(1.0, tk.END) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + self.status_var.set(f"Log saved to: {file_path}") + + except Exception as e: + messagebox.showerror("Save Error", f"Failed to save log: {e}") + + def search_log(self, event=None): + """Search for text in the log""" + search_text = self.search_var.get() + if not search_text: + return + + # Clear previous highlights + self.log_text.tag_remove("search_highlight", 1.0, tk.END) + + # Search and highlight + start_pos = 1.0 + matches = 0 + + while True: + pos = self.log_text.search(search_text, start_pos, tk.END, nocase=True) + if not pos: + break + + end_pos = f"{pos}+{len(search_text)}c" + self.log_text.tag_add("search_highlight", pos, end_pos) + start_pos = end_pos + matches += 1 + + if matches > 0: + # Jump to first match + first_match = self.log_text.search(search_text, 1.0, tk.END, nocase=True) + self.log_text.see(first_match) + self.status_var.set(f"Found {matches} matches for '{search_text}'") + else: + self.status_var.set(f"No matches found for '{search_text}'") + + def search_as_type(self, event=None): + """Search as user types (with delay)""" + # Cancel previous search + if hasattr(self, "search_timer"): + self.dialog.after_cancel(self.search_timer) + + # Schedule new search + self.search_timer = self.dialog.after(300, self.search_log) # 300ms delay + + def clear_search(self): + """Clear search highlighting""" + self.search_var.set("") + self.log_text.tag_remove("search_highlight", 1.0, tk.END) + self.status_var.set("Search cleared") + + def toggle_auto_refresh(self): + """Toggle auto-refresh functionality""" + self.auto_refresh = self.auto_refresh_var.get() + if self.auto_refresh: + self.status_var.set("Auto-refresh enabled") + else: + self.status_var.set("Auto-refresh disabled") + + def auto_refresh_loop(self): + """Auto-refresh loop for live log viewing""" + while self.auto_refresh: + try: + if self.auto_refresh_var.get(): + # Check if file has been modified + if os.path.exists(self.log_path): + current_mtime = os.path.getmtime(self.log_path) + if not hasattr(self, "last_mtime") or current_mtime > self.last_mtime: + self.last_mtime = current_mtime + self.dialog.after(0, self.load_log_content) + + time.sleep(2) # Check every 2 seconds + + except Exception as e: + logger.error(f"Auto-refresh error: {e}") + time.sleep(5) # Wait longer on error + + def on_closing(self): + """Handle dialog closing""" + self.auto_refresh = False + self.dialog.destroy() + + +def main(): + """Main function to start the GUI""" + root = tk.Tk() + app = BaseStationGUI(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/base_station_main.py b/base_station_main.py index a5d0e8f..86efd3d 100644 --- a/base_station_main.py +++ b/base_station_main.py @@ -10,40 +10,23 @@ defined in bue_main.py """ -# Standard library imports import queue -import os import sys -import threading -import time -from loguru import logger from yaml import load, Loader +import time +import threading +from datetime import datetime +import traceback -# For getting the distance between two bUE coordinates -from geopy import distance # type:ignore +from loguru import logger -from collections import deque +from ota import Ota logger.remove() # Remove default sink # Main log for everything logger.add("logs/base_station.log", rotation="10 MB") -""" -# This variable defines how long it will take for a connected bUE to be thought of as "disconnected" -# Unlike the TIMEOUT in bue_main.py, once this variable expires, it will not automatically disconnect the base -# station from the bUE. Instead, it will prompt the user that they might want to. This will allow the bUE to be able -# to reconnect once it is in range - -# The system will recommend disconnecting after missing TIMEOUT PINGs. -# Exact timing depends on CHECK_FOR_TIMEOUTS_INTERVAL variable in base_station_tick() -""" -from constants import TIMEOUT - - -# Internal imports -from ota import Ota - class Base_Station_Main: def __init__(self, yaml_str): @@ -59,10 +42,7 @@ def __init__(self, yaml_str): logger.error(f"__init__: YAML file {yaml_str} no found", file=sys.stderr) sys.exit(1) - # Hold all UPD messages so they can be displayed in the UI - self.stdout_history = deque() - - self.ota = Ota(self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"], self.stdout_history) + self.ota = Ota(self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"]) # Fetch the Reyax ID from the OTA module time.sleep(0.1) @@ -71,308 +51,215 @@ def __init__(self, yaml_str): logger.info(f"[DEBUG] OTA ID is set to: {self.reyax_id}") self.EXIT = False - - # A list of the bUEs currently connected to the base station - self.connected_bues = {} - + self.PING_TIMEOUT_SECONDS = 15 # Number of seconds waiting for a PING to come before its considered missed + self.PING_MAX_MISSES = 5 # Number of missed PINGs received before connected considered lost + + self.bue_id_to_hostname: dict[int, str] = {} # Dictionary that pairs rayex ids to bue name. (ex: 20 -> Perry) + + self.connected_bues: list[int] = [] # List to hold rayex ids of each connected bue. + self.bue_missed_ping_counter: dict[int, int] = {} # Dictionary to hold how many PINGs have been missed + self.bue_tout: list[str] = [] # List to hold messages that come with TOUT messages + self.bue_id_to_state: dict[int, str] = {} # Dictionary to hold what state each bUE is currently in + self.bue_id_to_coords: dict[int, (int, int)] = {} # Dictionary to hold the coords of each bUE + self.bue_id_to_last_ping_time: dict[int, int] = {} # Dictionary to hold when a bUE got its last PING + + # Set up the ota threads + self.ota_incoming_queue = queue.Queue() + self.ota_outgoing_queue = queue.Queue() + self.ota_trx_thread = threading.Thread(target=self.ota_message_trx) + self.ota_trx_thread.start() + + # Set up the ping handler thread + self.ping_timeout_handler_thread = threading.Thread(target=self.ping_timeout_handler) + self.ping_timeout_handler_thread.start() + + ## OTA Message Handling Thread and Functions ## + def ota_message_trx(self): """ - # A dictionary that will track how often a bUE is getting ticks - # A bUE's id is the key. - # If the bUE has a value of TIMEOUT or TIMEOUT + 1, it has received a PING recently - # If a bUE has missed a PING, this value will be decremented by one - # until too many PINGs have been missed + A thread to handle message transmission and reception on the OTA device. """ - self.bue_timeout_tracker = {} - - # Dictionary holds what each bUE's currently location is depending on last PING/UPD - self.bue_coordinates = {} - - # A list that tracks what bUEs are currently in the TEST state - self.testing_bues = [] - - # Ping thread - self.ping_bue_queue = queue.Queue() - self.ping_bue_thread = threading.Thread(target=self.ping_bue_queue_handler) - self.ping_bue_thread.start() - - # Listen for requests thread. Also handles PINGs. - self.message_queue = queue.Queue() - self.message_queue_thread = threading.Thread(target=self.req_queue_handler) - self.message_queue_thread.start() - - # Tick thread for state machine - self.tick_enabled = False - self.state_machine_thread = threading.Thread(target=self.base_station_tick) - self.state_machine_thread.start() - - def ping_bue_queue_handler(self): while not self.EXIT: + # Grab any messages from the OTA and store them in the incoming queue try: - task = self.ping_bue_queue.get(timeout=0.1) - task() - self.ping_bue_queue.task_done() - except queue.Empty: - time.sleep(0.01) - except Exception as e: - logger.error(f"ping_bue_queue_handler: Exception occurred: {e}") + new_messages = self.ota.get_new_messages() - def ping_bue(self, bue_id, lat="", long=""): - if bue_id in self.connected_bues: - try: - logger.bind(bue_id=bue_id).info( - f"Received PING from {bue_id}. Currently at Latitude: {lat}, Longitude: {long}" - ) - self.ota.send_ota_message(bue_id, "PINGR") + for message in new_messages: + self.ota_incoming_queue.put(message) + except Exception as e: + logger.error(f"Failed to get OTA messages: {e}") - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) + # Push any new messages from the outgoing queue to the OTA + while not self.ota_outgoing_queue.empty(): + (recipient_id, message) = self.ota_outgoing_queue.get() + self.ota.send_ota_message(recipient_id, message) + self.ota_outgoing_queue.task_done() - if lat != "" and long != "": - self.bue_coordinates[bue_id] = [lat, long] + if not self.ota_incoming_queue.empty(): + self.ota_message_handler() - self.bue_timeout_tracker[bue_id] = TIMEOUT + 1 - except Exception as e: - logger.bind(bue_id=bue_id).error(f"ping_bue: Error while handling PING from {bue_id}: {e}") + # Sleep for a short duration to avoid busy waiting + time.sleep(0.1) - def check_bue_timeout(self): + def ota_message_handler(self): """ - This function will cycle through each bUE the base station should be connected to and make sure that - it has been receiving some sort of message from it. - The messages it checks for our PINGs and UPDs - If we went a rotation without receiving a message, we might have lost connection with the bUE + When messages are received, they are interpretted here. """ - for bue_id in self.connected_bues: - if self.bue_timeout_tracker[bue_id] == TIMEOUT + 1: - # If this is true we know we are getting PINGs from this bue. No need to fear - self.bue_timeout_tracker[bue_id] = TIMEOUT - continue - if self.bue_timeout_tracker[bue_id] > 0: - logger.bind(bue_id=bue_id).error(f"We missed a PING from {bue_id}") - self.bue_timeout_tracker[bue_id] -= 1 - else: - logger.error(f"We haven't heard from {bue_id} in awhile. Maybe disconnected?") - - def req_queue_handler(self): - while not self.EXIT: + while not self.ota_incoming_queue.empty(): try: - task = self.message_queue.get(timeout=0.1) - task() - self.message_queue.task_done() - except queue.Empty: - time.sleep(0.01) - - # Listens for any incoming message from a bUE. Never called by itself. Runs if put in message_queue. - def message_listener(self): - - new_messages = self.ota.get_new_messages() - - for message in new_messages: - print(message) - try: # Receive messages should look like "{bue_id},{message}" - try: - parts = message.split(",", 1) - bue_id = int(parts[0]) - message_body = parts[1] - print(f"Message body: {message_body}") - - except Exception as e: - logger.error(f"message_listener: Failed to parse message '{message}': {e}") - continue # Skip to the next message - - if message_body.startswith("REQ"): - logger.debug("Sending a CON") - current_timestamp = int(time.time()) - _, payload = message_body.split(":", 1) # Split on first colon - bue_name, bue_id_check = payload.split(",", 1) # Split hostname and bUE_id - - if int(bue_id_check) != bue_id: - logger.error( - f"message_listener: Mismatched bUE ID in REQ message. Expected {bue_id}, got {bue_id_check}" - ) - continue # Skip to the next message - - self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}:{current_timestamp}") - # self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}") - self.bue_timeout_tracker[bue_id] = TIMEOUT - if not bue_name in self.connected_bues: - logger.bind(bue_id=bue_id).info(f"Received a request signal from {bue_id}:{bue_name}") - self.connected_bues[bue_id] = bue_name - else: - logger.error(f"Got a connection request from {bue_id} but it is already listed as connected") - - self.create_bue_log_file(bue_id) - - elif message_body.startswith("ACK"): - logger.bind(bue_id=bue_id).info(f"Received ACK from {self.connected_bues[bue_id]}") - - elif message_body.startswith("PING"): # Looks like ,PING,, - header, lat, long = message_body.split(",") - print(f"header: {header}, lat: {lat}, long: {long}", flush=True) - self.ping_bue(bue_id, lat, long) - - elif message_body.startswith("UPD"): # 40,55,UPD:LAT,LONG,STDOUT: [helloworld.py STDOUT] TyGoodTest,-42,8 - if not bue_id in self.testing_bues: - self.testing_bues.append(bue_id) - logger.bind(bue_id=bue_id).info( - f"Received UPD from {self.connected_bues[bue_id]} but it was not in testing_bues. Adding it now." - ) - header, lat, long, stdout = message_body.split(",", maxsplit=3) - # logger.info(f"Received UPD from {bue_id}. Currently at Latitude: {lat}, Longitude: {long}. Message: {stdout}") - logger.bind(bue_id=bue_id).info(f"Received UPD from {bue_id}. Message: {stdout}") - if lat != "" and long != "": - self.bue_coordinates[bue_id] = [lat, long] - else: - logger.bind(bue_id=bue_id).info("Lat and/or Long was empty") - # Reset the timeout for getting UPDs. If we haven't recieved an update in a while there is a problem - self.bue_timeout_tracker[bue_id] = TIMEOUT + 1 - - if stdout != "": - self.stdout_history.append(f"{self.connected_bues[bue_id]}: {stdout}") + message: str = self.ota_incoming_queue.get() + logger.info(f"Received OTA message: {message}") - elif message_body.startswith("FAIL"): - logger.bind(bue_id=bue_id).error(f"Received FAIL from {self.connected_bues[bue_id]}") - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) - - elif message_body.startswith("DONE"): - logger.bind(bue_id=bue_id).info(f"Received DONE from {self.connected_bues[bue_id]}") - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) - - elif message_body.startswith("PREPR"): - logger.bind(bue_id=bue_id).info(f"Received PREPR from {self.connected_bues[bue_id]}") - self.testing_bues.append(bue_id) - - elif message_body.startswith("CANCD"): - logger.bind(bue_id=bue_id).info(f"Received CANCD from {self.connected_bues[bue_id]}") - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) - - elif message_body.startswith("BAD"): - logger.bind(bue_id=bue_id).info(f"Received BAD from {self.connected_bues[bue_id]}") - self.stdout_history.append(f"Received a BAD from {self.connected_bues[bue_id]}") + # Process the message based on its type + # A message body is ",<:message body (optional)>" + src_id, msg = message.split(",", 1) + if ":" in msg: + msg_type, msg_body = msg.split(":", maxsplit=1) else: - logger.info(f"Received undefined message {message}") + msg_type, msg_body = msg, None - except ValueError: - logger.error("message_listener: Error listening for messages") + if msg_type == "REQ": # Expected format: REQ:, + hostname, bue_id = msg_body.split(",", 1) - def get_distance(self, bue_1, bue_2): - try: - c1 = self.bue_coordinates[bue_1] - c2 = self.bue_coordinates[bue_2] - - # Validate that coordinates are lists/tuples with 2 elements - if not isinstance(c1, (list, tuple)) or len(c1) != 2: - logger.error(f"Invalid coordinates for bUE {self.connected_bues[bue_1]}: {c1}") - return None - - if not isinstance(c2, (list, tuple)) or len(c2) != 2: - logger.error(f"Invalid coordinates for bUE {self.connected_bues[bue_2]}: {c2}") - return None + if int(src_id) != int(bue_id): + logger.warning(f"REQ message source ID {src_id} does not match body {bue_id}") + else: + self.bue_id_to_hostname[int(bue_id)] = str(hostname) + self.ota_outgoing_queue.put((bue_id, f"CON:{self.reyax_id}")) + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: REQ") + + elif msg_type == "ACK": + # If not already connected, list in connected bUEs and initialize all variables + if not int(src_id) in self.connected_bues: + self.connected_bues.append(int(src_id)) + self.bue_missed_ping_counter[int(src_id)] = 0 + self.bue_id_to_state[int(src_id)] = "IDLE" + self.bue_id_to_last_ping_time[int(src_id)] = time.time() + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: Received an ACK") + + elif msg_type == "PING": # Expected format: PING:,, + # If the bUE is connected, + if int(src_id) in self.connected_bues: + self.bue_missed_ping_counter[int(src_id)] = 0 + state, lat, long = msg_body.split(",", 2) + self.ota_ping_handler(src_id=src_id, state=state, lat=lat, long=long) + else: + logger.error(f"{self.bue_id_to_hostname[int(src_id)]}: PING but not listed as connected") - # Validate that coordinates are numeric and within valid ranges - try: - lat1, lon1 = float(c1[0]), float(c1[1]) - lat2, lon2 = float(c2[0]), float(c2[1]) - except (ValueError, TypeError): - logger.error( - f"Non-numeric coordinates: bUE {self.connected_bues[bue_1]}: {c1}, bUE {self.connected_bues[bue_2]}: {c2}" - ) - return None - - # Check if coordinates are within valid ranges - if not (-90 <= lat1 <= 90) or not (-180 <= lon1 <= 180): - logger.error(f"Invalid latitude/longitude for bUE {self.connected_bues[bue_1]}: lat={lat1}, lon={lon1}") - return None - - if not (-90 <= lat2 <= 90) or not (-180 <= lon2 <= 180): - logger.error(f"Invalid latitude/longitude for bUE {self.connected_bues[bue_2]}: lat={lat2}, lon={lon2}") - return None - - # Check if coordinates are not empty/zero (optional - depends on your use case) - if (lat1 == 0 and lon1 == 0) or (lat2 == 0 and lon2 == 0): - logger.warning(f"Zero coordinates detected: bUE {self.connected_bues[bue_1]}: {c1}, bUE {bue_2}: {c2}") - - logger.info( - f"Calculating distance between bUE {self.connected_bues[bue_1]} at ({lat1}, {lon1}) and bUE {self.connected_bues[bue_2]} at ({lat2}, {lon2})" - ) - - return distance.great_circle((lat1, lon1), (lat2, lon2)).meters - - except KeyError as e: - logger.error(f"bUE coordinates not found: {e}") - return None - except Exception as e: - logger.error(f"Error calculating distance: {e}") - return None + elif msg_type == "TOUT": + self.bue_tout.append(f"{self.bue_id_to_hostname[int(src_id)]}: {msg_body}") + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: TOUT") - def create_bue_log_file(self, bue_id): - """Create a log file for a specific bUE if it doesn't already exist.""" - try: - path = f"logs/{self.connected_bues[bue_id]}.log" - os.makedirs(os.path.dirname(path), exist_ok=True) + elif msg_type == "FAIL": + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: FAIL") - # This will append to existing file or create new one - logger.add( - path, - rotation="5 MB", - filter=lambda record, bue_id=bue_id: record["extra"].get("bue_id") == bue_id, - ) + elif msg_type == "DONE": + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: DONE") - logger.info(f"Created/resumed log file for bUE {self.connected_bues[bue_id]}: {path}") + else: + logger.warning(f"Unknown message type: {msg_type}") - except Exception as e: - logger.error(f"Failed to create log file for bUE {self.connected_bues[bue_id]}: {e}") + self.ota_incoming_queue.task_done() + except Exception as e: + tb_str = traceback.format_exc() + logger.error(f"Error processing OTA messages: {e}\nFull traceback:\n{tb_str}") + self.ota_incoming_queue.task_done() - def base_station_tick(self, loop_dur=0.01): + def ping_timeout_handler(self): + """ + Function runs in its own thread. Repeats every second (set by time.sleep below) + Checks to see if each connected bue has sent a PING in the last self.PING_TIMEOUT_SECONDS + If not PING received in that amount of time, increments self.bue_missed_ping_counter + """ + while not self.EXIT: + try: + current_time = time.time() - # The base station will read incoming messages roughly every LISTEN_FOR_MESSAGE_INTERVAL seconds - LISTEN_FOR_MESSAGE_INTERVAL = 1 - listen_for_message_counter = 0 - listen_for_message = round(LISTEN_FOR_MESSAGE_INTERVAL / loop_dur) + for bue_id in self.connected_bues.copy(): + last_ping_time = self.bue_id_to_last_ping_time[int(bue_id)] - # The base station will check to see if a bUE has timed out every CHECK_FOR_TIMEOUTS_INTERVAL seconds - CHECK_FOR_TIMEOUTS_INTERVAL = 10 - check_for_timeouts_counter = 0 - check_for_timeouts = round(CHECK_FOR_TIMEOUTS_INTERVAL / loop_dur) + if current_time - last_ping_time >= self.PING_TIMEOUT_SECONDS: + self.bue_missed_ping_counter[bue_id] += 1 - while not self.EXIT: + # Need to update last_ping_time or this will occur every loop + self.bue_id_to_last_ping_time[bue_id] = current_time - if not self.tick_enabled: - time.sleep(0.1) - continue + if self.bue_missed_ping_counter[bue_id] >= self.PING_MAX_MISSES: + logger.error( + f"{self.bue_id_to_hostname[int(bue_id)]}: Has missed {self.bue_missed_ping_counter[bue_id]} PINGs" + ) + else: + logger.warning( + f"{self.bue_id_to_hostname[int(bue_id)]}: Has missed {self.bue_missed_ping_counter[bue_id]} PINGs" + ) - loop_start = time.time() + except Exception as e: + tb_str = traceback.format_exc() + logger.error(f"ping_timeout_handler: Error {e}\nFull traceback:\n{tb_str}") - if listen_for_message_counter % listen_for_message == 0: - self.message_queue.put(self.message_listener) - listen_for_message_counter = 0 + time.sleep(1) - if check_for_timeouts_counter % check_for_timeouts == 0: - self.message_queue.put(self.check_bue_timeout) - check_for_timeouts_counter = 0 + # OTA Helper Functions + def ota_ping_handler(self, src_id: str, state: str, lat: str, long: str): + """ + Takes in the parts from a PING message. If the PING had valid coordinates, those are stored + and reported. Always note the time the PING was received, the state the bUE reports to be at, + and response to the bUE with a PINGR + """ + self.bue_id_to_state[int(src_id)] = state + self.bue_id_to_last_ping_time[int(src_id)] = time.time() - listen_for_message_counter += 1 - check_for_timeouts_counter += 1 + coords: str = "" + if lat != "" and long != "": + self.bue_id_to_coords[int(src_id)] = (int(lat), int(long)) + coords = f"@ {lat}, {long}" - elapsed = time.time() - loop_start - if elapsed < loop_dur: - time.sleep(loop_dur - elapsed) + self.ota_outgoing_queue.put((src_id, "PINGR")) + logger.info(f"{self.bue_id_to_hostname[int(src_id)]}: PING {coords}") def __del__(self): try: self.EXIT = True - if hasattr(self, "ping_bue_thread"): - self.ping_bue_thread.join() - if hasattr(self, "message_queue_thread"): - self.message_queue_thread.join() - if hasattr(self, "state_machine_thread"): - self.state_machine_thread.join() + if hasattr(self, "ota_trx_thread"): + self.ota_trx_thread.join() + if hasattr(self, "ping_timeout_handler_thread"): + self.ping_timeout_handler_thread.join() if hasattr(self, "ota"): self.ota.__del__() if hasattr(self, "connected_bues"): self.connected_bues.clear() except Exception as e: logger.warning(f"__del__: Exception during cleanup: {e}") + + +if __name__ == "__main__": + + # Get the current time + start_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + # Example usage + logger.info(f"This marks the start of the base station service at {start_time}") + + try: + base_station = Base_Station_Main(yaml_str="base_station.yaml") + + # Any other setup code can go here + time.sleep(2) # Allow some time for threads to initialize + + while True: + time.sleep(0.1) + + except KeyboardInterrupt: + if base_station is not None: + logger.info("Exiting the base station service") + base_station.EXIT = True + time.sleep(0.5) + base_station.__del__() + sys.exit(0) + except Exception as e: + logger.error(f"Unhandled exception in main: {e}") + if base_station is not None: + base_station.EXIT = True + time.sleep(0.5) + base_station.__del__() + sys.exit(1) diff --git a/base_station_main_old.py b/base_station_main_old.py new file mode 100644 index 0000000..a5d0e8f --- /dev/null +++ b/base_station_main_old.py @@ -0,0 +1,378 @@ +""" +base_station_main.py +Ty Young + +This file will handle all the functionaility required to run a base station. + +This file is being coded after bue_main.py and will be structured in a similar manner. + +The main goal in this file is to make a station that can handle multiple bUEs as they are currently +defined in bue_main.py +""" + +# Standard library imports +import queue +import os +import sys +import threading +import time +from loguru import logger +from yaml import load, Loader + +# For getting the distance between two bUE coordinates +from geopy import distance # type:ignore + +from collections import deque + +logger.remove() # Remove default sink + +# Main log for everything +logger.add("logs/base_station.log", rotation="10 MB") + +""" +# This variable defines how long it will take for a connected bUE to be thought of as "disconnected" +# Unlike the TIMEOUT in bue_main.py, once this variable expires, it will not automatically disconnect the base +# station from the bUE. Instead, it will prompt the user that they might want to. This will allow the bUE to be able +# to reconnect once it is in range + +# The system will recommend disconnecting after missing TIMEOUT PINGs. +# Exact timing depends on CHECK_FOR_TIMEOUTS_INTERVAL variable in base_station_tick() +""" +from constants import TIMEOUT + + +# Internal imports +from ota import Ota + + +class Base_Station_Main: + def __init__(self, yaml_str): + self.yaml_data = {} + + try: + with open(yaml_str) as yaml: + self.yaml_data = load(yaml, Loader=Loader) + logger.info("__init__: Loading config.yaml. Items are: ") + for key, value in self.yaml_data.items(): + logger.info(f" {key}: {value}") + except FileNotFoundError: + logger.error(f"__init__: YAML file {yaml_str} no found", file=sys.stderr) + sys.exit(1) + + # Hold all UPD messages so they can be displayed in the UI + self.stdout_history = deque() + + self.ota = Ota(self.yaml_data["OTA_PORT"], self.yaml_data["OTA_BAUDRATE"], self.stdout_history) + + # Fetch the Reyax ID from the OTA module + time.sleep(0.1) + self.reyax_id = self.ota.fetch_id() + + logger.info(f"[DEBUG] OTA ID is set to: {self.reyax_id}") + + self.EXIT = False + + # A list of the bUEs currently connected to the base station + self.connected_bues = {} + + """ + # A dictionary that will track how often a bUE is getting ticks + # A bUE's id is the key. + # If the bUE has a value of TIMEOUT or TIMEOUT + 1, it has received a PING recently + # If a bUE has missed a PING, this value will be decremented by one + # until too many PINGs have been missed + """ + self.bue_timeout_tracker = {} + + # Dictionary holds what each bUE's currently location is depending on last PING/UPD + self.bue_coordinates = {} + + # A list that tracks what bUEs are currently in the TEST state + self.testing_bues = [] + + # Ping thread + self.ping_bue_queue = queue.Queue() + self.ping_bue_thread = threading.Thread(target=self.ping_bue_queue_handler) + self.ping_bue_thread.start() + + # Listen for requests thread. Also handles PINGs. + self.message_queue = queue.Queue() + self.message_queue_thread = threading.Thread(target=self.req_queue_handler) + self.message_queue_thread.start() + + # Tick thread for state machine + self.tick_enabled = False + self.state_machine_thread = threading.Thread(target=self.base_station_tick) + self.state_machine_thread.start() + + def ping_bue_queue_handler(self): + while not self.EXIT: + try: + task = self.ping_bue_queue.get(timeout=0.1) + task() + self.ping_bue_queue.task_done() + except queue.Empty: + time.sleep(0.01) + except Exception as e: + logger.error(f"ping_bue_queue_handler: Exception occurred: {e}") + + def ping_bue(self, bue_id, lat="", long=""): + if bue_id in self.connected_bues: + try: + logger.bind(bue_id=bue_id).info( + f"Received PING from {bue_id}. Currently at Latitude: {lat}, Longitude: {long}" + ) + self.ota.send_ota_message(bue_id, "PINGR") + + if bue_id in self.testing_bues: + self.testing_bues.remove(bue_id) + + if lat != "" and long != "": + self.bue_coordinates[bue_id] = [lat, long] + + self.bue_timeout_tracker[bue_id] = TIMEOUT + 1 + except Exception as e: + logger.bind(bue_id=bue_id).error(f"ping_bue: Error while handling PING from {bue_id}: {e}") + + def check_bue_timeout(self): + """ + This function will cycle through each bUE the base station should be connected to and make sure that + it has been receiving some sort of message from it. + The messages it checks for our PINGs and UPDs + If we went a rotation without receiving a message, we might have lost connection with the bUE + """ + for bue_id in self.connected_bues: + if self.bue_timeout_tracker[bue_id] == TIMEOUT + 1: + # If this is true we know we are getting PINGs from this bue. No need to fear + self.bue_timeout_tracker[bue_id] = TIMEOUT + continue + if self.bue_timeout_tracker[bue_id] > 0: + logger.bind(bue_id=bue_id).error(f"We missed a PING from {bue_id}") + self.bue_timeout_tracker[bue_id] -= 1 + else: + logger.error(f"We haven't heard from {bue_id} in awhile. Maybe disconnected?") + + def req_queue_handler(self): + while not self.EXIT: + try: + task = self.message_queue.get(timeout=0.1) + task() + self.message_queue.task_done() + except queue.Empty: + time.sleep(0.01) + + # Listens for any incoming message from a bUE. Never called by itself. Runs if put in message_queue. + def message_listener(self): + + new_messages = self.ota.get_new_messages() + + for message in new_messages: + print(message) + try: # Receive messages should look like "{bue_id},{message}" + try: + parts = message.split(",", 1) + bue_id = int(parts[0]) + message_body = parts[1] + print(f"Message body: {message_body}") + + except Exception as e: + logger.error(f"message_listener: Failed to parse message '{message}': {e}") + continue # Skip to the next message + + if message_body.startswith("REQ"): + logger.debug("Sending a CON") + current_timestamp = int(time.time()) + _, payload = message_body.split(":", 1) # Split on first colon + bue_name, bue_id_check = payload.split(",", 1) # Split hostname and bUE_id + + if int(bue_id_check) != bue_id: + logger.error( + f"message_listener: Mismatched bUE ID in REQ message. Expected {bue_id}, got {bue_id_check}" + ) + continue # Skip to the next message + + self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}:{current_timestamp}") + # self.ota.send_ota_message(bue_id, f"CON:{self.reyax_id}") + self.bue_timeout_tracker[bue_id] = TIMEOUT + if not bue_name in self.connected_bues: + logger.bind(bue_id=bue_id).info(f"Received a request signal from {bue_id}:{bue_name}") + self.connected_bues[bue_id] = bue_name + else: + logger.error(f"Got a connection request from {bue_id} but it is already listed as connected") + + self.create_bue_log_file(bue_id) + + elif message_body.startswith("ACK"): + logger.bind(bue_id=bue_id).info(f"Received ACK from {self.connected_bues[bue_id]}") + + elif message_body.startswith("PING"): # Looks like ,PING,, + header, lat, long = message_body.split(",") + print(f"header: {header}, lat: {lat}, long: {long}", flush=True) + self.ping_bue(bue_id, lat, long) + + elif message_body.startswith("UPD"): # 40,55,UPD:LAT,LONG,STDOUT: [helloworld.py STDOUT] TyGoodTest,-42,8 + if not bue_id in self.testing_bues: + self.testing_bues.append(bue_id) + logger.bind(bue_id=bue_id).info( + f"Received UPD from {self.connected_bues[bue_id]} but it was not in testing_bues. Adding it now." + ) + header, lat, long, stdout = message_body.split(",", maxsplit=3) + # logger.info(f"Received UPD from {bue_id}. Currently at Latitude: {lat}, Longitude: {long}. Message: {stdout}") + logger.bind(bue_id=bue_id).info(f"Received UPD from {bue_id}. Message: {stdout}") + if lat != "" and long != "": + self.bue_coordinates[bue_id] = [lat, long] + else: + logger.bind(bue_id=bue_id).info("Lat and/or Long was empty") + # Reset the timeout for getting UPDs. If we haven't recieved an update in a while there is a problem + self.bue_timeout_tracker[bue_id] = TIMEOUT + 1 + + if stdout != "": + self.stdout_history.append(f"{self.connected_bues[bue_id]}: {stdout}") + + elif message_body.startswith("FAIL"): + logger.bind(bue_id=bue_id).error(f"Received FAIL from {self.connected_bues[bue_id]}") + if bue_id in self.testing_bues: + self.testing_bues.remove(bue_id) + + elif message_body.startswith("DONE"): + logger.bind(bue_id=bue_id).info(f"Received DONE from {self.connected_bues[bue_id]}") + if bue_id in self.testing_bues: + self.testing_bues.remove(bue_id) + + elif message_body.startswith("PREPR"): + logger.bind(bue_id=bue_id).info(f"Received PREPR from {self.connected_bues[bue_id]}") + self.testing_bues.append(bue_id) + + elif message_body.startswith("CANCD"): + logger.bind(bue_id=bue_id).info(f"Received CANCD from {self.connected_bues[bue_id]}") + if bue_id in self.testing_bues: + self.testing_bues.remove(bue_id) + + elif message_body.startswith("BAD"): + logger.bind(bue_id=bue_id).info(f"Received BAD from {self.connected_bues[bue_id]}") + self.stdout_history.append(f"Received a BAD from {self.connected_bues[bue_id]}") + + else: + logger.info(f"Received undefined message {message}") + + except ValueError: + logger.error("message_listener: Error listening for messages") + + def get_distance(self, bue_1, bue_2): + try: + c1 = self.bue_coordinates[bue_1] + c2 = self.bue_coordinates[bue_2] + + # Validate that coordinates are lists/tuples with 2 elements + if not isinstance(c1, (list, tuple)) or len(c1) != 2: + logger.error(f"Invalid coordinates for bUE {self.connected_bues[bue_1]}: {c1}") + return None + + if not isinstance(c2, (list, tuple)) or len(c2) != 2: + logger.error(f"Invalid coordinates for bUE {self.connected_bues[bue_2]}: {c2}") + return None + + # Validate that coordinates are numeric and within valid ranges + try: + lat1, lon1 = float(c1[0]), float(c1[1]) + lat2, lon2 = float(c2[0]), float(c2[1]) + except (ValueError, TypeError): + logger.error( + f"Non-numeric coordinates: bUE {self.connected_bues[bue_1]}: {c1}, bUE {self.connected_bues[bue_2]}: {c2}" + ) + return None + + # Check if coordinates are within valid ranges + if not (-90 <= lat1 <= 90) or not (-180 <= lon1 <= 180): + logger.error(f"Invalid latitude/longitude for bUE {self.connected_bues[bue_1]}: lat={lat1}, lon={lon1}") + return None + + if not (-90 <= lat2 <= 90) or not (-180 <= lon2 <= 180): + logger.error(f"Invalid latitude/longitude for bUE {self.connected_bues[bue_2]}: lat={lat2}, lon={lon2}") + return None + + # Check if coordinates are not empty/zero (optional - depends on your use case) + if (lat1 == 0 and lon1 == 0) or (lat2 == 0 and lon2 == 0): + logger.warning(f"Zero coordinates detected: bUE {self.connected_bues[bue_1]}: {c1}, bUE {bue_2}: {c2}") + + logger.info( + f"Calculating distance between bUE {self.connected_bues[bue_1]} at ({lat1}, {lon1}) and bUE {self.connected_bues[bue_2]} at ({lat2}, {lon2})" + ) + + return distance.great_circle((lat1, lon1), (lat2, lon2)).meters + + except KeyError as e: + logger.error(f"bUE coordinates not found: {e}") + return None + except Exception as e: + logger.error(f"Error calculating distance: {e}") + return None + + def create_bue_log_file(self, bue_id): + """Create a log file for a specific bUE if it doesn't already exist.""" + try: + path = f"logs/{self.connected_bues[bue_id]}.log" + os.makedirs(os.path.dirname(path), exist_ok=True) + + # This will append to existing file or create new one + logger.add( + path, + rotation="5 MB", + filter=lambda record, bue_id=bue_id: record["extra"].get("bue_id") == bue_id, + ) + + logger.info(f"Created/resumed log file for bUE {self.connected_bues[bue_id]}: {path}") + + except Exception as e: + logger.error(f"Failed to create log file for bUE {self.connected_bues[bue_id]}: {e}") + + def base_station_tick(self, loop_dur=0.01): + + # The base station will read incoming messages roughly every LISTEN_FOR_MESSAGE_INTERVAL seconds + LISTEN_FOR_MESSAGE_INTERVAL = 1 + listen_for_message_counter = 0 + listen_for_message = round(LISTEN_FOR_MESSAGE_INTERVAL / loop_dur) + + # The base station will check to see if a bUE has timed out every CHECK_FOR_TIMEOUTS_INTERVAL seconds + CHECK_FOR_TIMEOUTS_INTERVAL = 10 + check_for_timeouts_counter = 0 + check_for_timeouts = round(CHECK_FOR_TIMEOUTS_INTERVAL / loop_dur) + + while not self.EXIT: + + if not self.tick_enabled: + time.sleep(0.1) + continue + + loop_start = time.time() + + if listen_for_message_counter % listen_for_message == 0: + self.message_queue.put(self.message_listener) + listen_for_message_counter = 0 + + if check_for_timeouts_counter % check_for_timeouts == 0: + self.message_queue.put(self.check_bue_timeout) + check_for_timeouts_counter = 0 + + listen_for_message_counter += 1 + check_for_timeouts_counter += 1 + + elapsed = time.time() - loop_start + if elapsed < loop_dur: + time.sleep(loop_dur - elapsed) + + def __del__(self): + try: + self.EXIT = True + if hasattr(self, "ping_bue_thread"): + self.ping_bue_thread.join() + if hasattr(self, "message_queue_thread"): + self.message_queue_thread.join() + if hasattr(self, "state_machine_thread"): + self.state_machine_thread.join() + if hasattr(self, "ota"): + self.ota.__del__() + if hasattr(self, "connected_bues"): + self.connected_bues.clear() + except Exception as e: + logger.warning(f"__del__: Exception during cleanup: {e}") diff --git a/bue_main.py b/bue_main.py index 3a2b6c2..a259d9b 100644 --- a/bue_main.py +++ b/bue_main.py @@ -21,11 +21,10 @@ # Internal imports from ota import Ota +from constants import State # This variable manages how many PINGRs should be missed until the bUE disconnects from the base station # and goes back to its CONNECT_OTA state. -# TIMEOUT rotations must pass, then the bUE will disconnect. -# The length of the rotations is defined by IDLE_PING_OTA_INTERVAL in bue_tick() TIMEOUT = 6 BROADCAST_OTA_ID = 0 @@ -105,6 +104,9 @@ def __init__(self, yaml_str="bue_config.yaml"): self.ota_base_station_id = None self.ota_test_params = None + # Variable to hold how many PINGRs have been missed + self.ota_pingrs_missed: int = 0 + # Variables to handle test subprocess self.test_command = None self.test_start_time = None @@ -263,6 +265,11 @@ def ota_connect_req(self): def ota_ping(self): lat, long = self.gps_handler() + if self.flag_ota_pingr.is_set(): + self.flag_ota_pingr.clear() + else: + self.ota_pingrs_missed += 1 + self.ota_outgoing_queue.put((self.ota_base_station_id, f"PING:{self.cur_st.value},{lat},{long}")) logger.info(f"ota_ping: Sent ping to {self.ota_base_station_id}") @@ -333,8 +340,8 @@ def ota_send_update(self): logger.error("ota_send_update: test_stdout_queue is empty") return - self.ota_outgoing_queue.put((self.ota_base_station_id, f"UPD:{stdout}")) # TODO: Change UPD to TOUT - logger.info(f"Sent UPD to {self.ota_base_station_id} with console output: {stdout}") + self.ota_outgoing_queue.put((self.ota_base_station_id, f"TOUT:{stdout}")) + logger.info(f"Sent TOUT to {self.ota_base_station_id} with console output: {stdout}") """ Checks to see if the ota system a valid TEST message from the base station diff --git a/constants.py b/constants.py index 7c57d76..0557208 100644 --- a/constants.py +++ b/constants.py @@ -1,16 +1,10 @@ -"""Simple Dictionary to map Reyex names to bUE names""" +from enum import Enum, auto -bUEs = {"10": "Doof", "70": "Vanessa", "30": "Major", "40": "Buford", "50": "Carl", "60": "Monty", "20": "Monty"} -bUEs_inverted = { - "Doof": "10", - "Vanessa": "70", - "Major": "30", - "Buford": "40", - "Carl": "50", - "Perry": "60", -} - -""" Defines how many seconds pass until the base station/bUE consider themselves disconnected - TIMEOUT * 10 seconds must pass""" -TIMEOUT = 6 +class State(Enum): + INIT = auto() + CONNECT_OTA = auto() + IDLE = auto() + WAIT_FOR_START = auto() + UTW_TEST = auto() + TEST_CLEANUP = auto() \ No newline at end of file diff --git a/setup/message_dict.txt b/setup/message_dict.txt index bf94e73..28d5318 100644 --- a/setup/message_dict.txt +++ b/setup/message_dict.txt @@ -38,7 +38,7 @@ PING: Direction: bUE -> base Meaning: The bUE periodically pings the base station Body: None - Example: (bue_main.py) self.ota.send_ota_message(10, "PING:,") <- NOTICE NO COLON + Example: (bue_main.py) self.ota.send_ota_message(10, PING:,,) NOTE: lat, long are '' if GPS isn't working Response: The base station will respond with a PINGR. If too much time passes between PINGR's, the bUE knows it has become disconnected from the network. PINGR: @@ -78,11 +78,11 @@ PREPR: Example: (bue_main.py) self.ota.send_ota_message(10, "TESTR:1745004290") Response: None -UPD: +TOUT: Direction: bUE -> base - Meaning: The bUE sends an update on the UTW test; includes current GPS coords and any message that might be in stdout - Body: ,, - Example: (bue_main.py) self.ota.send_ota_message(10, "UPD:") + Meaning: The bUE sends stdout/stderr from its UTW test + Body: + Example: (bue_main.py) self.ota.send_ota_message(10, "TOUT:") Response: None DONE: From f40906a98a2c4f0becc193bf8696941e6300eb61 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 8 Jan 2026 09:22:37 -0700 Subject: [PATCH 13/15] Update import in ota.py --- ota.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ota.py b/ota.py index 806aac2..1b74417 100644 --- a/ota.py +++ b/ota.py @@ -12,9 +12,6 @@ import queue import crc8 -from constants import bUEs - - class Ota: def __init__(self, port, baudrate, stdout_history=None): @@ -87,7 +84,7 @@ def read_from_port(self): if not valid_crc: # Bad checksum self.send_ota_message(origin, "BAD") if self.stdout_history: - self.stdout_history.append(f"Got a message with a bad checksum from {bUEs(str(origin))}") + self.stdout_history.append(f"Got a message with a bad checksum from {origin}") continue self.recv_msgs.put(f"{origin},{original_message}") From 867d1b9b7f7600da7fc0af1f4b3b143a475cad80 Mon Sep 17 00:00:00 2001 From: BYU NET Lab Date: Wed, 21 Jan 2026 14:35:40 -0700 Subject: [PATCH 14/15] Updated UPD messages to be TOUT messages --- bue_main.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bue_main.py b/bue_main.py index a259d9b..6244234 100644 --- a/bue_main.py +++ b/bue_main.py @@ -95,6 +95,7 @@ def __init__(self, yaml_str="bue_config.yaml"): self.flag_ota_cancel_test = threading.Event() self.flag_ota_reload = threading.Event() self.flag_ota_restart = threading.Event() + self.flag_ota_tout = threading.Event() # State machine - statuses # These are the main internal signals used by the state machine @@ -331,17 +332,18 @@ def utw_task_queue_handler(self): pass # Sends the first message in the self.test_stdout_queue back to the base station - def ota_send_update(self): + def ota_send_tout(self): try: stdout = self.test_stdout_queue.get_nowait().strip() - if len(stdout) > 0: # See if the message is empty + if len(stdout) == 0: # See if the message is empty return except: - logger.error("ota_send_update: test_stdout_queue is empty") + logger.error("ota_send_tout: test_stdout_queue is empty") return self.ota_outgoing_queue.put((self.ota_base_station_id, f"TOUT:{stdout}")) logger.info(f"Sent TOUT to {self.ota_base_station_id} with console output: {stdout}") + self.flag_ota_tout.clear() """ Checks to see if the ota system a valid TEST message from the base station @@ -735,8 +737,9 @@ def bue_tick(self, loop_dur=0.01): self.check_on_test() self.check_for_test_interrupt() - if not self.test_stdout_queue.empty(): - self.ota_task_queue.put(self.ota_send_update) + if not self.flag_ota_tout.is_set() and not self.test_stdout_queue.empty(): + self.flag_ota_tout.set() + self.ota_task_queue.put(self.ota_send_tout) # elif self.cur_st == State.TEST_CLEANUP: counter_ping += 1 @@ -746,8 +749,8 @@ def bue_tick(self, loop_dur=0.01): self.ota_task_queue.put(self.ota_ping) counter_ping = 0 - if not self.test_stdout_queue.empty(): - self.ota_task_queue.put(self.ota_send_update) + if not self.flag_ota_tout.is_set() and not self.test_stdout_queue.empty(): + self.ota_task_queue.put(self.ota_send_tout) else: self.clean_up_test() # From 58b93cdfb100b4db782bd4d3d9c2c76fcae523ee Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 12 Feb 2026 10:41:03 -0700 Subject: [PATCH 15/15] QT QUI integration --- base_station_gui.py | 47 +++++++++++++++++++++++------------------- base_station_main.py | 5 +++-- setup/requirements.txt | 3 +++ 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/base_station_gui.py b/base_station_gui.py index 0227f81..9c28fb5 100644 --- a/base_station_gui.py +++ b/base_station_gui.py @@ -40,7 +40,8 @@ print("TkinterMapView not available - using fallback canvas map") from base_station_main import Base_Station_Main -from constants import bUEs, TIMEOUT + +# from constants import bUEs, TIMEOUT class BaseStationGUI: @@ -514,7 +515,7 @@ def update_interactive_map(self): for bue_id, coords in self.base_station.bue_id_to_coords.items(): try: lat, lon = float(coords[0]), float(coords[1]) - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = 55555 # Check proximity to custom markers is_close = False @@ -686,7 +687,8 @@ def lon_to_x(lon): ) # Label - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + # bue_name = bUEs5.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) self.map_widget.create_text( x, y - 15, @@ -734,7 +736,8 @@ def update_tables(self): if self.base_station: for bue_id, coords in self.base_station.bue_id_to_coords.items(): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + # bue_name = bUEs5.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) try: lat, lon = coords[0], coords[1] self.coord_tree.insert("", "end", text=bue_name, values=(lat, lon)) @@ -759,7 +762,8 @@ def update_tables(self): distance = self.base_station.get_distance(bue1, bue2) if distance is not None: - pair_name = f"{bUEs.get(str(bue1), str(bue1))} ↔ {bUEs.get(str(bue2), str(bue2))}" + # pair_name = f"{bUEs5.get(str(bue1), str(bue1))} ↔ {bUEs5.get(str(bue2), str(bue2))}" + pair_name = f"{self.base_station.bue_id_to_hostname(int(bue1))} ↔ {self.base_station.bue_id_to_hostname(int(bue1))}" self.dist_tree.insert("", "end", text=pair_name, values=(f"{distance:.2f}")) processed_pairs.add((bue1, bue2)) @@ -813,7 +817,8 @@ def disconnect_bue(self, bue_id): """Disconnect a specific bUE""" if messagebox.askyesno( "Confirm Disconnect", - f"Disconnect from {bUEs.get(str(bue_id), str(bue_id))}?", + # f"Disconnect from {bUEs5.get(str(bue_id), str(bue_id))}?", + f"Disconnect from {self.base_station.bue_id_to_hostname(int(bue_id))}?", ): try: self.base_station.connected_bues.remove(bue_id) @@ -829,7 +834,7 @@ def disconnect_bue(self, bue_id): def reload_bue(self, bue_id): """Reload a specific bUE""" - if messagebox.askyesno("Confirm Reload", f"Reload {bUEs.get(str(bue_id), str(bue_id))}?"): + if messagebox.askyesno("Confirm Reload", f"Reload {self.base_station.bue_id_to_hostname(int(bue_id))}?"): try: self.base_station.ota.send_ota_message(bue_id, "RELOAD") self.disconnect_bue(bue_id) @@ -839,7 +844,7 @@ def reload_bue(self, bue_id): def restart_bue(self, bue_id): """Restart a specific bUE""" - if messagebox.askyesno("Confirm Restart", f"Restart {bUEs.get(str(bue_id), str(bue_id))}?"): + if messagebox.askyesno("Confirm Restart", f"Restart {self.base_station.bue_id_to_hostname(int(bue_id))}?"): try: self.base_station.ota.send_ota_message(bue_id, "RESTART") self.disconnect_bue(bue_id) @@ -850,7 +855,7 @@ def restart_bue(self, bue_id): def open_bue_log(self, bue_id): """Open the log file for a specific bUE""" log_path = f"logs/bue_{bue_id}.log" - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) LogViewerDialog(self.root, log_path, f"{bue_name} Log") def open_base_log(self): @@ -975,7 +980,7 @@ def setup_dialog(self): row = 0 col = 0 for i, bue_id in enumerate(self.base_station.connected_bues): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname[int(bue_id)] var = tk.BooleanVar() self.bue_vars[bue_id] = var @@ -1060,7 +1065,7 @@ def update_selection(self): self.selected_bues = [bue_id for bue_id, var in self.bue_vars.items() if var.get()] if self.selected_bues: - bue_names = [bUEs.get(str(bid), f"bUE {bid}") for bid in self.selected_bues] + bue_names = [self.base_station.bue_id_to_hostname(int(bid)) for bid in self.selected_bues] self.selection_label.config(text=f"Selected: {', '.join(bue_names)}", foreground="blue") # Show inline configuration for each selected bUE @@ -1107,7 +1112,7 @@ def show_inline_configs(self): self.config_widgets = {} for i, bue_id in enumerate(self.selected_bues): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) # Create a frame for this bUE's configuration bue_frame = ttk.LabelFrame(self.scrollable_frame, text=f"Configure {bue_name}", padding="10") @@ -1315,7 +1320,7 @@ def run_tests(self): time.sleep(0.1) logger.info(f"Sent test command to bUE {bue_id}: {command}") - bue_names = [bUEs.get(str(bue_id), str(bue_id)) for bue_id in self.bue_configs.keys()] + bue_names = [self.base_station.bue_id_to_hostname(int(bue_id)) for bue_id in self.bue_configs.keys()] messagebox.showinfo( "Tests Scheduled", f"Tests scheduled for: {', '.join(bue_names)}\n\n" @@ -1341,7 +1346,7 @@ def __init__(self, parent, bue_id, test_files, config_tree): self.config_tree = config_tree self.dialog = tk.Toplevel(parent) - self.dialog.title(f"Configure {bUEs.get(str(bue_id), f'bUE {bue_id}')}") + self.dialog.title(f"Configure {self.base_station.bue_id_to_hostname(int(bue_id))}") self.dialog.geometry("400x200") self.dialog.grab_set() @@ -1351,7 +1356,7 @@ def setup_dialog(self): """Setup the configuration dialog""" ttk.Label( self.dialog, - text=f"Configure test for {bUEs.get(str(self.bue_id), f'bUE {self.bue_id}')}", + text=f"Configure test for {self.base_station.bue_id_to_hostname(int(self.bue_id))}", ).pack(pady=10) # Test file selection @@ -1379,7 +1384,7 @@ def setup_dialog(self): def save_config(self): """Save the configuration""" - bue_name = bUEs.get(str(self.bue_id), f"bUE {self.bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(self.bue_id)) self.config_tree.insert( "", "end", @@ -1409,7 +1414,7 @@ def setup_dialog(self): self.test_vars = {} for bue_id in getattr(self.base_station, "testing_bues", []): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") + bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) var = tk.BooleanVar() self.test_vars[bue_id] = var ttk.Checkbutton(self.dialog, text=bue_name, variable=var).pack(anchor=tk.W, padx=20) @@ -1430,7 +1435,7 @@ def cancel_tests(self): for i in range(3): self.base_station.ota.send_ota_message(bue_id, "CANC") time.sleep(0.1) - canceled.append(bUEs.get(str(bue_id), str(bue_id))) + canceled.append(self.base_station.bue_id_to_hostname(int(bue_id))) logger.info(f"Sent cancel command to bUE {bue_id}") except Exception as e: logger.error(f"Failed to cancel test for bUE {bue_id}: {e}") @@ -1475,7 +1480,7 @@ def setup_dialog(self): ttk.Label(self.dialog, text="Pair with bUE (optional):").pack(anchor=tk.W, padx=20, pady=(10, 5)) try: bue_options = ["None"] + [ - bUEs.get(str(bue_id), f"bUE {bue_id}") for bue_id in self.main_gui.base_station.connected_bues + self.base_station.bue_id_to_hostname(int(bue_id)) for bue_id in self.main_gui.base_station.connected_bues ] self.bue_var = tk.StringVar(value="None") ttk.Combobox( @@ -1519,7 +1524,7 @@ def add_marker(self): bue_selection = self.bue_var.get() if bue_selection != "None": for bue_id in self.main_gui.base_station.connected_bues: - if bUEs.get(str(bue_id), f"bUE {bue_id}") == bue_selection: + if self.base_station.bue_id_to_hostname(int(bue_id)) == bue_selection: paired_bue = bue_id break @@ -1588,7 +1593,7 @@ def refresh_markers(self): for marker_id, marker in self.main_gui.custom_markers.items(): paired_bue_name = "None" if marker.get("paired_bue"): - paired_bue_name = bUEs.get(str(marker["paired_bue"]), f"bUE {marker['paired_bue']}") + paired_bue_name = self.base_station.bue_id_to_hostname(int(paired_bue_name)) self.markers_tree.insert( "", diff --git a/base_station_main.py b/base_station_main.py index 86efd3d..43bfa67 100644 --- a/base_station_main.py +++ b/base_station_main.py @@ -21,6 +21,7 @@ from loguru import logger from ota import Ota +from constants import State logger.remove() # Remove default sink @@ -141,7 +142,7 @@ def ota_message_handler(self): # If the bUE is connected, if int(src_id) in self.connected_bues: self.bue_missed_ping_counter[int(src_id)] = 0 - state, lat, long = msg_body.split(",", 2) + state, lat, long = msg_body.split(",", 2) self.ota_ping_handler(src_id=src_id, state=state, lat=lat, long=long) else: logger.error(f"{self.bue_id_to_hostname[int(src_id)]}: PING but not listed as connected") @@ -206,7 +207,7 @@ def ota_ping_handler(self, src_id: str, state: str, lat: str, long: str): and reported. Always note the time the PING was received, the state the bUE reports to be at, and response to the bUE with a PINGR """ - self.bue_id_to_state[int(src_id)] = state + self.bue_id_to_state[int(src_id)] = State(int(state)) self.bue_id_to_last_ping_time[int(src_id)] = time.time() coords: str = "" diff --git a/setup/requirements.txt b/setup/requirements.txt index 77d0e8f..1adf2fd 100644 --- a/setup/requirements.txt +++ b/setup/requirements.txt @@ -28,3 +28,6 @@ six==1.17.0 survey==5.4.2 tkintermapview==1.29 urllib3==2.5.0 +qt6-applications==6.5.0.2.3 +qt6-tools==6.5.0.1.3 +