From 26c559b3c61472538b40cb1c3d0341206ba6a7b9 Mon Sep 17 00:00:00 2001 From: Dwi Swandhana Date: Sun, 22 Feb 2026 06:25:04 +0700 Subject: [PATCH] Add MyLA Vitek3 flag handling --- .../Http/Controllers/FrontpageController.php | 5 + listener/app.py | 246 ++++++++++++------ 2 files changed, 175 insertions(+), 76 deletions(-) diff --git a/htdocs/app/Http/Controllers/FrontpageController.php b/htdocs/app/Http/Controllers/FrontpageController.php index e29de35f..9ee1b2b0 100644 --- a/htdocs/app/Http/Controllers/FrontpageController.php +++ b/htdocs/app/Http/Controllers/FrontpageController.php @@ -1078,6 +1078,7 @@ class FrontpageController extends Controller 'flg_gxp1' => false, 'flg_gxp2' => false, 'flg_gxp3' => false, + 'flg_vitek3' => false, ] ); if ($pesan != ''){ @@ -1295,6 +1296,7 @@ class FrontpageController extends Controller 'flg_gxp1' => false, 'flg_gxp2' => false, 'flg_gxp3' => false, + 'flg_vitek3' => false, ] ); return response()->json(['status' => 'Sukses', 'message' => $pesan], 201); @@ -1544,6 +1546,7 @@ class FrontpageController extends Controller 'flg_gxp1' => false, 'flg_gxp2' => false, 'flg_gxp3' => false, + 'flg_vitek3' => false, ] ); @@ -1668,6 +1671,7 @@ class FrontpageController extends Controller 'flg_gxp1' => false, 'flg_gxp2' => false, 'flg_gxp3' => false, + 'flg_vitek3' => false, ] ); if ($pesan != ''){ @@ -1736,6 +1740,7 @@ class FrontpageController extends Controller 'flg_gxp1' => false, 'flg_gxp2' => false, 'flg_gxp3' => false, + 'flg_vitek3' => false, ] ); }catch (Exception $e) { diff --git a/listener/app.py b/listener/app.py index 395cf234..4c0f9200 100644 --- a/listener/app.py +++ b/listener/app.py @@ -102,7 +102,7 @@ MYLA_PORT = 60090 # Karakter kontrol standar STX, ETX, ACK, NAK, EOT, ENQ = b'\x02', b'\x03', b'\x06', b'\x15', b'\x04', b'\x05' -RS, GS = b'\x1e', b'\x1d' # Record Separator, Group Separator +RS, GS = b'\x1e', b'\x1d' ports_lock = threading.Lock() active_serial_ports = {} @@ -183,6 +183,7 @@ class PaslabOrder(Base): flg_gxp1 = Column(Boolean, default=False) flg_gxp2 = Column(Boolean, default=False) flg_gxp3 = Column(Boolean, default=False) + flg_vitek3 = Column(Boolean, default=False) created_at = Column(SqDateTime, default=datetime.datetime.now) updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) @@ -271,7 +272,6 @@ def trigger_result_query_to_genexpert(accnumber, register_no, target_ip=None, wa if not target_ips: return {"ok": False, "message": f"Koneksi GeneXpert {target_ip} tidak aktif."} else: - # Default: kirim query ke semua GeneXpert yang sedang terkoneksi. target_ips = active_ips ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') @@ -301,7 +301,6 @@ def trigger_result_query_to_genexpert(accnumber, register_no, target_ip=None, wa try: conn.sendall(mllp_payload) sent_ips.append(ip) - logging.info(f"[GENEXPERT-QUERY] Kirim query hasil accnumber={accnumber} ke {ip}") print(f"[GENEXPERT-QUERY] Kirim query hasil accnumber={accnumber} ke {ip}") except Exception as e: failed_ips.append({"ip": ip, "error": str(e)}) @@ -530,7 +529,10 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment): # Mapping Data sample_id = str(order.rnoreg) pid_norm = order.norm if order.norm else "" - pid_nama = order.nama if order.nama else "No Name" + first_name, last_name = split_patient_name(order.nama) + pid_nama = f"{last_name}^{first_name}" + room = str(getattr(order, "ruangan", "") or "RSSA MALANG").strip().upper() + room = room.replace("|", " ").replace("^", " ")[:30] # Gender raw_gender = str(order.rjenis).upper() @@ -545,11 +547,12 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment): # GeneXpert standar biasanya terima format ORM di dalam DSR atau sequence PID-ORC-OBR pid = f"PID|1||{pid_norm}||{pid_nama}|||{pid_gender}" + pv1 = f"PV1|1|I|{room}" orc = f"ORC|NW|{sample_id}" obr = f"OBR|1|{sample_id}||{test_code}^{nama_tes_db}^L|||{timestamp}" # Gabungkan - return f"{msh}\r{msa}\r{qrd}\r{qrf}\r{pid}\r{orc}\r{obr}\r" + return f"{msh}\r{msa}\r{qrd}\r{qrf}\r{pid}\r{pv1}\r{orc}\r{obr}\r" else: # Jika TIDAK ADA ORDER (Not Found) @@ -559,8 +562,8 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment): def parse_myla_result(hl7_message, device_name="MYLA"): """ - Parser khusus untuk HL7 dari bioMérieux MYLA. - Menangani hasil BACT/ALERT (Pos/Neg) dan VITEK (Organisme & AST/Sensitivitas). + Parser khusus untuk HL7 dari bioMérieux MYLA (Kalibrasi V4.9). + Menangani hasil BACT/ALERT (Kultur Darah) dan VITEK (Identifikasi & AST). """ session = SessionLocal() try: @@ -571,9 +574,8 @@ def parse_myla_result(hl7_message, device_name="MYLA"): patient_name = "" result_date = datetime.datetime.now() - # Penampung Hasil - kultur_id = [] # Untuk hasil organisme / Positif/Negatif - ast_results = [] # Untuk hasil sensitivitas antibiotik + kultur_id = [] + ast_results = [] for segment in segments: fields = segment.split('|') @@ -591,35 +593,38 @@ def parse_myla_result(hl7_message, device_name="MYLA"): if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() elif seg_type == 'OBR': - # OBR-3 (Filler Order Number) atau OBR-2 (Placer) biasanya berisi Sample ID if len(fields) > 3 and fields[3]: sample_id = fields[3].replace('^', '') elif len(fields) > 2 and fields[2]: sample_id = fields[2].replace('^', '') elif seg_type == 'OBX': - # Parsing detail OBX - # OBX-3: Parameter (Misal: Organism / Nama Antibiotik) - # OBX-5: Nilai Hasil / MIC - # OBX-8: Interpretasi (S / I / R / + / -) - if len(fields) > 5: test_param = fields[3].split('^')[1] if '^' in fields[3] else fields[3] - result_val = fields[5].replace('^', ' ').strip() + + if "Time To Detection" in test_param: + continue + + raw_val = fields[5] + val_parts = raw_val.split('^') + + if len(val_parts) > 1: + if val_parts[0] in ['<', '<=', '>', '>=', '=']: + result_val = f"{val_parts[0]} {val_parts[1]}" + else: + result_val = val_parts[1] + else: + result_val = raw_val.strip() interpretation = "" if len(fields) > 8 and fields[8]: - interpretation = fields[8].strip() + interpretation = fields[8].split('^')[0].strip().upper() - # Logika Pemisahan (Tergantung mapping kata kunci MYLA) - # Biasanya VITEK mengirim interpretasi 'S', 'I', 'R' untuk antibiotik - if interpretation in ['S', 'I', 'R']: + if interpretation in ['S', 'I', 'R', 'NS']: ast_results.append(f"{test_param}: {result_val} ({interpretation})") else: - # Ini kemungkinan hasil Kultur / Identifikasi Organisme kultur_id.append(f"{test_param}: {result_val}") - # --- GABUNGKAN HASIL --- final_results = [] if kultur_id: final_results.append("ID: " + ", ".join(kultur_id)) @@ -628,9 +633,7 @@ def parse_myla_result(hl7_message, device_name="MYLA"): final_result_str = " | ".join(final_results) if final_results else "No Data" - # --- SIMPAN KE DB --- if not sample_id: - # Fallback jika tidak ada ID sample_id = f"ERR_MYLA_{datetime.datetime.now().strftime('%H%M%S')}" final_result_str = f"[NO_ID] {final_result_str}" @@ -642,7 +645,7 @@ def parse_myla_result(hl7_message, device_name="MYLA"): rnmpas=patient_name, tgl_data=result_date, rawdt=hl7_message, - organisme=final_result_str[:255], # Potong jika field database terbatas + organisme=final_result_str[:255], alat=device_name ) session.add(new_entry) @@ -700,9 +703,73 @@ def handle_myla_client(conn, addr): # --- PROSES QUERY (QRY/QBP) - JIKA MYLA BERTANYA ORDER --- elif "QRY^" in hl7_str or "QBP^" in hl7_str: - logging.info(f"[MYLA] Menerima Query (Belum diimplementasikan detail)") - # Anda bisa menambahkan logika lookup order disini mirip dengan GeneXpert - pass + client_ip = addr[0] + logging.info(f"[MYLA] Menerima Query dari {client_ip} (Control ID: {incoming_control_id})") + + # Khusus MyLA gunakan flag VITEK3. + target_flag_col = "flg_vitek3" + + lines = hl7_str.split('\r') + search_sample_id = None + qrd_line = "" + + for line in lines: + if line.startswith("QRD|"): + qrd_line = line + fields = line.split('|') + if len(fields) > 8 and fields[8]: + search_sample_id = fields[8].strip() + break + + if not search_sample_id: + for line in lines: + if line.startswith("QPD|"): + qpd_fields = line.split('|') + for candidate in qpd_fields[3:]: + value = candidate.strip() + if value: + search_sample_id = value.split('^')[0].strip() + break + break + + session = SessionLocal() + try: + flag_attr = getattr(PaslabOrder, target_flag_col, None) + if flag_attr is None: + raise Exception(f"Kolom flag tidak valid: {target_flag_col}") + + order = None + if search_sample_id: + order = session.query(PaslabOrder).filter( + PaslabOrder.rnoreg == search_sample_id, + (flag_attr == False) | (flag_attr == None) + ).first() + else: + # Fallback: jika query tidak membawa sample ID, kirim order pending paling awal. + order = session.query(PaslabOrder).filter( + (flag_attr == False) | (flag_attr == None) + ).order_by(PaslabOrder.urut.asc()).first() + + if order: + reply_msg = create_hl7_dsr_response(order, incoming_control_id, qrd_line) + conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) + + setattr(order, target_flag_col, True) + session.commit() + logging.info(f"[MYLA] Order terkirim rnoreg={order.rnoreg}, set {target_flag_col}=TRUE") + else: + reply_msg = create_hl7_dsr_response(None, incoming_control_id, qrd_line) + conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) + logging.info(f"[MYLA] Tidak ada order pending untuk sample={search_sample_id or '-'}") + + except Exception as db_err: + session.rollback() + logging.error(f"[MYLA] Gagal proses query: {db_err}") + reply_msg = create_hl7_dsr_response(None, incoming_control_id, qrd_line) + conn.sendall(f"\x0b{reply_msg}\x1c\r".encode('utf-8')) + finally: + session.close() + continue # --- KIRIM ACK --- if incoming_control_id: @@ -721,20 +788,29 @@ def handle_myla_client(conn, addr): conn.close() logging.info(f"[MYLA-TCP] Koneksi {addr} ditutup.") -# Tambahkan fungsi starter ini di atas blok if __name__ == '__main__': def start_myla_server(host, port): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.bind((host, port)) - server.listen(5) - logging.info(f"[START] MYLA TCP Server berjalan di {host}:{port}") - - while True: - client_socket, addr = server.accept() - # Jalankan di thread terpisah agar bisa tangani banyak koneksi - client_thread = threading.Thread(target=handle_myla_client, args=(client_socket, addr)) - client_thread.daemon = True - client_thread.start() + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + server.bind((host, port)) + server.listen(5) + logging.info(f"[START] MYLA TCP Server berjalan di {host}:{port}") + print(f"[START] MYLA TCP Server berjalan di {host}:{port}") + + while True: + client_socket, addr = server.accept() + # Jalankan di thread terpisah agar bisa tangani banyak koneksi + client_thread = threading.Thread(target=handle_myla_client, args=(client_socket, addr), daemon=True) + client_thread.start() + except Exception as e: + logging.critical(f"[START] Gagal start MYLA TCP Server di {host}:{port}: {e}") + print(f"[START] Gagal start MYLA TCP Server di {host}:{port}: {e}") + finally: + try: + server.close() + except Exception: + pass # ========================================== # HL7 TCP LISTENER FOR GENEXPERT @@ -1201,6 +1277,41 @@ def parse_and_save_vitek_result(raw_data, port_name="VITEK"): finally: session.close() +def split_patient_name(full_name): + """ + Pecah nama pasien menjadi first_name dan last_name. + - Jika ada '^', diasumsikan format ASTM/HL7: Last^First. + - Jika nama biasa, token terakhir = last_name, sisanya = first_name. + """ + if not full_name: + return "NO", "NAME" + + raw = str(full_name).strip() + if not raw: + return "NO", "NAME" + + first_name = "" + last_name = "" + + if "^" in raw: + parts = [p.strip() for p in raw.split("^") if p.strip()] + if len(parts) >= 2: + last_name = parts[0] + first_name = parts[1] + elif len(parts) == 1: + first_name = parts[0] + else: + tokens = raw.split() + if len(tokens) >= 2: + last_name = tokens[-1] + first_name = " ".join(tokens[:-1]) + elif len(tokens) == 1: + first_name = tokens[0] + + first_name = (first_name or "NO").upper()[:20] + last_name = (last_name or "NAME").upper()[:20] + return first_name, last_name + def create_vitek_order_message(order): """ Membuat Frame Order Vitek sesuai Manual Ref 514937. @@ -1210,8 +1321,11 @@ def create_vitek_order_message(order): # Field Delimiter menggunakan Pipe '|' pid = str(order.norm).strip() if order.norm else "" sid = str(order.rnoreg).strip() if order.rnoreg else "" - p_name = str(order.nama).strip().replace('^', ' ').upper()[:20] if order.nama else "NO NAME" + first_name, last_name = split_patient_name(order.nama) + p_name = f"{last_name}^{first_name}" specimen = str(order.kd_spesimen).upper() if order.kd_spesimen else "BLOOD" + room = str(getattr(order, 'ruangan', "") or "RSSA MALANG").strip().upper() + room = room.replace("|", " ").replace("^", " ")[:30] now = datetime.datetime.now() date_str = now.strftime("%m/%d/%Y") @@ -1222,6 +1336,7 @@ def create_vitek_order_message(order): content_body = ( f"mtmpr|pi{pid}|pn{p_name}" f"|si|ss{specimen}" + f"|lo{room}" f"|s1{date_str}|s2{time_str}" f"|ci{sid}|t11|zz" ) @@ -1255,9 +1370,7 @@ def manage_vitek_port(config): flag_col = config.get('flag_column') alat_name = config.get('alat_name', 'VITEK') - logging.info(f"[{port_name}] START VITEK SERVICE (Relaxed Mode)...") print(f"[{port_name}] START VITEK SERVICE (Relaxed Mode)...") - # Deteksi stuck dalam window waktu tertentu. STUCK_WINDOW_SEC = 120 stuck_count = 0 stuck_window_start = None @@ -1276,7 +1389,7 @@ def manage_vitek_port(config): ) as ser: ser.reset_input_buffer() - logging.info(f"[{port_name}] Ready & Listening.") + print(f"[{port_name}] Ready & Listening.") while True: # ========================================== @@ -1287,14 +1400,12 @@ def manage_vitek_port(config): # --- HANDSHAKE --- if header == b'\x05': - logging.info(f"[{port_name}] Got ENQ -> Reply ACK") print(f"[{port_name}] Got ENQ -> Reply ACK") ser.write(b'\x06') ser.reset_input_buffer() # --- DATA FRAME --- elif header == b'\x02': - logging.info(f"[{port_name}] Frame Start. Reading...") print(f"[{port_name}] Frame Start. Reading...") original_timeout = ser.timeout ser.timeout = 8 @@ -1310,12 +1421,9 @@ def manage_vitek_port(config): is_valid = True elif b'\x1d' in full_frame[-20:]: is_valid = True - logging.warning(f"[{port_name}] Frame tanpa ETX tapi ada Checksum. Menerima paksa...") print(f"[{port_name}] Frame tanpa ETX tapi ada Checksum. Menerima paksa...") if is_valid: - logging.info(f"[{port_name}] Frame Accepted. Sending ACK.") print(f"[{port_name}] Frame OK -> ACK Sent.") - print(f"[{port_name}] FULL FRAME: {full_frame}") # 1. KIRIM ACK (WAJIB) ser.write(b'\x06') stuck_count = 0 @@ -1362,7 +1470,6 @@ def manage_vitek_port(config): ).first() if pending_order: - logging.info(f"[{port_name}] Ada Order: {pending_order.rnoreg}. Coba Handshake...") print(f"[{port_name}] Ada Order: {pending_order.rnoreg}...") # --- LOGIC HANDSHAKE DENGAN RETRY --- @@ -1389,7 +1496,7 @@ def manage_vitek_port(config): stuck_count = 0 stuck_window_start = None # === KIRIM DATA ORDER === - logging.info(f"[{port_name}] Handshake OK. Kirim Frames...") + print(f"[{port_name}] Handshake OK. Kirim Frames...") frames = create_vitek_order_message(pending_order) all_sent = True @@ -1412,7 +1519,6 @@ def manage_vitek_port(config): ser.write(b'\x04') # EOT if all_sent: - logging.info(f"[{port_name}] Order SELESAI Terkirim.") print(f"[{port_name}] Order SELESAI Terkirim.") setattr(pending_order, flag_col, True) session.commit() @@ -1420,12 +1526,12 @@ def manage_vitek_port(config): stuck_window_start = None else: logging.error(f"[{port_name}] Order Gagal (No ACK).") + print(f"[{port_name}] Order Gagal (No ACK).") else: # === FORCE RESET (ANTI-STUCK) === # Jika sudah 3x ENQ tidak dibalas, anggap alat 'bengong' # Kirim EOT untuk mereset status alat - logging.warning(f"[{port_name}] Alat Sibuk/Stuck. Kirim Force EOT.") print(f"[{port_name}] Alat Sibuk/Stuck. Kirim Force EOT.") ser.write(b'\x04') time.sleep(2.0) @@ -1584,8 +1690,9 @@ def create_astm_order_message(order): # --- 1. PERSIAPAN DATA --- pid = str(order.norm).strip() if order.norm else "" sid = str(order.rnoreg).strip() if order.rnoreg else "" - # Nama Pasien: Ganti karakter topi '^' dengan spasi agar tidak merusak format - p_name = str(order.nama).strip().replace('^', ' ')[:20] if order.nama else "No Name" + # Nama pasien dipisah agar mengikuti format ASTM: Last^First + first_name, last_name = split_patient_name(order.nama) + p_name = f"{last_name}^{first_name}" sex = "M" if str(order.rjenis).upper().startswith("L") else "F" # Lokasi / Ruangan (Field 26) @@ -1669,7 +1776,7 @@ def manage_bd_port(config): port_name = config['port'] flag_col = config.get('flag_column') alat_name = config.get('alat_name', 'BD') - logging.info(f"[{port_name}] Membuka port untuk alat {alat_name}...") + print(f"[{port_name}] Membuka port untuk alat {alat_name}...") # Buffer untuk menampung pecahan data rx_buffer = b"" @@ -1695,7 +1802,6 @@ def manage_bd_port(config): if data_chunk: # 1. Handle Handshake Awal (ENQ) if b'\x05' in data_chunk: - logging.info(f"[{port_name}] Terima ENQ (Alat mau kirim data). Kirim ACK.") print(f"[{port_name}] Terima ENQ (Alat mau kirim data). Kirim ACK.") ser.write(b'\x06') # ACK rx_buffer = "" # Reset buffer untuk data baru @@ -1703,7 +1809,6 @@ def manage_bd_port(config): # 2. Handle Akhir Transmisi (EOT) if b'\x04' in data_chunk: - logging.info(f"[{port_name}] Terima EOT (Selesai). Memproses data...") print(f"[{port_name}] Terima EOT (Selesai). Memproses data...") # Proses semua data yang terkumpul di buffer parse_and_save_bd_result(rx_buffer, alat_name) @@ -1748,7 +1853,6 @@ def manage_bd_port(config): if pending_order: has_activity = True # Jangan sleep lama-lama - logging.info(f"[{port_name}] Menemukan Order: {pending_order.rnoreg}. Memulai Handshake...") print(f"[{port_name}] Menemukan Order: {pending_order.rnoreg}. Memulai Handshake...") # --- STEP 1: HANDSHAKE (ENQ) --- ser.reset_input_buffer() @@ -1758,7 +1862,6 @@ def manage_bd_port(config): ack_response = ser.read(1) if ack_response == b'\x06': - logging.info(f"[{port_name}] Handshake Sukses (Dapat ACK). Menunggu alat siap...") print(f"[{port_name}] Handshake Sukses (Dapat ACK). Menunggu alat siap...") # --- PERBAIKAN 1: BERI JEDA SETELAH HANDSHAKE --- # Mesin butuh napas sebelum terima data panjang @@ -1778,7 +1881,6 @@ def manage_bd_port(config): while retry_count < max_retries: ser.reset_input_buffer() - logging.info(f"[{port_name}] Kirim Frame {i+1} (Percobaan {retry_count+1})...") print(f"[{port_name}] Kirim Frame {i+1} (Percobaan {retry_count+1})...") ser.write(frame) @@ -1790,21 +1892,18 @@ def manage_bd_port(config): if frame_ack == b'\x06': # ACK (Sukses) frame_success = True - logging.info(f"[{port_name}] Frame {i+1} ACK diterima.") print(f"[{port_name}] Frame {i+1} ACK diterima.") # Beri jeda dikit sebelum kirim frame berikutnya time.sleep(0.2) break elif frame_ack == b'\x15': # NAK (Ditolak - Checksum Salah) - logging.warning(f"[{port_name}] Frame ditolak (NAK). Checksum mungkin salah.") print(f"[{port_name}] Frame ditolak (NAK). Checksum mungkin salah.") time.sleep(2) # Tunggu 2 detik retry_count += 1 elif not frame_ack: # Timeout (Sepi) # --- PERBAIKAN 2: BERI JEDA SAAT TIMEOUT --- - logging.warning(f"[{port_name}] Timeout (Alat diam). Menunggu sebelum retry...") print(f"[{port_name}] Timeout (Alat diam). Menunggu sebelum retry...") time.sleep(2) # Tunggu 2 detik agar alat recover retry_count += 1 @@ -1825,7 +1924,6 @@ def manage_bd_port(config): # --------------------------------------------------- if all_frames_sent: ser.write(b'\x04') # EOT (End of Transmission) - logging.info(f"[{port_name}] Order {pending_order.rnoreg} SUKSES Terkirim.") print(f"[{port_name}] Order {pending_order.rnoreg} SUKSES Terkirim.") # Update Database setattr(pending_order, flag_col, True) @@ -1908,7 +2006,12 @@ if __name__ == "__main__": all_threads.append(t_tcp) # 3. Start Thread TCP Server (MyLA) - myla_thread = threading.Thread(target=start_myla_server, args=(MYLA_HOST, MYLA_PORT)) + myla_thread = threading.Thread( + target=start_myla_server, + args=(MYLA_HOST, MYLA_PORT), + name="Manager-TCP-MyLA", + daemon=True + ) myla_thread.start() all_threads.append(myla_thread) @@ -1918,31 +2021,22 @@ if __name__ == "__main__": all_threads.append(t_http) # 5. LOOP UTAMA (Keep-Alive & Monitoring) - # Ini sekarang bisa berjalan karena Flask sudah dipindah ke thread try: while True: - logging.debug(f"--- Monitoring {len(all_threads)} Threads ---") print(f"--- Monitoring {len(all_threads)} Threads ---") - # Cek status setiap thread alive_count = 0 for t in all_threads: if t.is_alive(): alive_count += 1 else: - logging.warning(f"!!! THREAD MATI: {t.name} !!!") print(f"!!! THREAD MATI: {t.name} !!!") - # Disini Anda bisa menambahkan logika restart thread jika mati - # Jika semua mati, exit (atau restart service) if alive_count == 0: - logging.critical("Semua thread mati. System Shutdown.") print("Semua thread mati. System Shutdown.") break - time.sleep(10) # Cek setiap 10 detik (Hemat CPU) + time.sleep(10) except KeyboardInterrupt: - logging.info("Mematikan Service (Ctrl+C)...") print("Mematikan Service (Ctrl+C)...") stop_event.set() - # Thread daemon akan mati otomatis saat main exit