From 493dfe18eb51924df0cda779bb554cf52e3ac6fc Mon Sep 17 00:00:00 2001 From: Dwi Swandhana Date: Wed, 13 May 2026 15:29:02 +0700 Subject: [PATCH] update --- listener/app.py | 1476 ++++++++-------- listener/geneexpert.py | 3800 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 4502 insertions(+), 774 deletions(-) create mode 100644 listener/geneexpert.py diff --git a/listener/app.py b/listener/app.py index 4c44cecc..f105318b 100644 --- a/listener/app.py +++ b/listener/app.py @@ -76,11 +76,7 @@ genexpert_query_inflight_lock = threading.Lock() TCP_LISTENER_PORT = 6001 # PC GeneXpert set ke mode Client, konek ke IP:PORT ini SERVER_HOST = '0.0.0.0' # Listen di semua interface HTTP_API_PORT = 6002 # Endpoint trigger dari Laravel -> Python -GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS = 60 -GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS = 120 -GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS = 21600 -GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS = 45 -GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER = False + GENEXPERT_RESPONSE_MODE_DEFAULT = "hl7_passive" GENEXPERT_RESPONSE_MODE_BY_IP = { # "10.10.120.73": "astm_active", @@ -93,6 +89,12 @@ TARGET_MAPPING = { 'flg_gxp2': '10.10.120.13', 'flg_gxp3': '10.10.120.75' } +GENEXPERT_HOST_APPLICATION_DEFAULT = "DE002" +GENEXPERT_HOST_APPLICATION_BY_IP = { + "10.10.120.75": "GE01", + "10.10.120.13": "DE002", + "10.10.120.73": "GE01", +} # GeneXpert Configuration # ========================================== # KONFIGURASI MAPPING TES (DATABASE -> GENEXPERT) @@ -102,8 +104,8 @@ TARGET_MAPPING = { GENEXPERT_TEST_MAPPING = { # Mapping untuk IP 10.10.120.75 (Multi-Assay) "HIV": "HIV-1_VL", # Xpert HIV-1 Viral Load XC Version 3 - "TCM TB": "MTB-RIF", # Xpert MTB-RIF Assay G4 Version 6 - "TCM TB ULTRA": "MTB-RIF_ULTRA2", # Xpert MTB-RIF Ultra Version 4 + "TCM TB": "MTBRIF", # Xpert MTBRIF Assay G4 Version 6 + "TCM TB ULTRA": "MTBRIF_ULTRA2", # Xpert MTBRIF Ultra Version 4 "TCM TB XDR": "MTB-XDR", # Xpert MTB-XDR Version 1 "HCV VL": "HCV", # Xpert HCV Viral Load Version 1 "COVID-19": "COV-2 2", # Xpert Xpress SARS-CoV-2 Version 2 @@ -113,8 +115,8 @@ GENEXPERT_TEST_MAPPING = { "18.1.1 TCM HCV": "HCV", "18.1.2 TCM HIV VIRAL LOAD": "HIV-1_VL", "18.1.4 TCM HPV": "HCV", - "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-RIF", - "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTB-RIF", + "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", + "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR", "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", @@ -135,26 +137,26 @@ GENEXPERT_TEST_MAPPING = { "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTB-RIF", - "12.3.8 TCM TB (GENE EXPERT)": "MTB-RIF", - "15.2.3 TCM TB (GENE EXPERT)": "MTB-RIF", - "2.3.9 TCM GENE EXPERT": "MTB-RIF", - "3.3.8 TCM GENE EXPERT": "MTB-RIF", - "3.3.9 TCM MYCOBACTERIUM TUBERCULOSIS": "MTB-RIF", - "5.3.9 TCM TBC (GENE EXPERT)": "MTB-RIF", - "7.3.8 TCM TBC (GENE EXPERT)": "MTB-RIF", - "8.3.8 TCM TBC (GENE EXPERT)": "MTB-RIF", - "10.3.8 TCM TBC (GENE EXPERT)": "MTB-RIF", - "11.3.9 TCM TB (GENE EXPERT)": "MTB-RIF", + "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTBRIF", + "12.3.8 TCM TB (GENE EXPERT)": "MTBRIF", + "15.2.3 TCM TB (GENE EXPERT)": "MTBRIF", + "2.3.9 TCM GENE EXPERT": "MTBRIF", + "3.3.8 TCM GENE EXPERT": "MTBRIF", + "3.3.9 TCM MYCOBACTERIUM TUBERCULOSIS": "MTBRIF", + "5.3.9 TCM TBC (GENE EXPERT)": "MTBRIF", + "7.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "8.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "10.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "11.3.9 TCM TB (GENE EXPERT)": "MTBRIF", } GENEXPERT_IP_CAPABILITIES = { - "10.10.120.75": ["MTB-RIF", "MTB-RIF_ULTRA2", "MTB-XDR", "HIV-1_VL", "COV-2 2"], + "10.10.120.75": ["MTBRIF", "MTBRIF_ULTRA2", "MTB-XDR", "HIV-1_VL", "COV-2 2"], "10.10.120.13": ["HCV", "HBV"], - "10.10.120.73": ["MTB-RIF"] + "10.10.120.73": ["MTBRIF"] } # Default code jika nama tes di database tidak dikenali -DEFAULT_GXP_CODE = "MTB-RIF" +DEFAULT_GXP_CODE = "MTBRIF" DEVICE_CONFIGS = [ { @@ -305,6 +307,667 @@ def extract_segment(hl7_message, segment_name): return segment return "" +def format_hl7_date(value): + if not value: + return "" + if isinstance(value, datetime.datetime): + return value.strftime("%Y%m%d") + if isinstance(value, datetime.date): + return value.strftime("%Y%m%d") + text = str(value).strip() + digits = re.sub(r"[^0-9]", "", text) + return digits[:8] if len(digits) >= 8 else "" + +def map_hl7_sex(value): + text = str(value or "").strip().upper() + if not text: + return "" + if text.startswith("L") or "LAKI" in text or text == "M": + return "M" + if text.startswith("P") or "PEREM" in text or text == "F": + return "F" + return "" + +def extract_msg_control_id(hl7_message): + try: + segments = hl7_message.split('\r') + msh = segments[0].split('|') + if len(msh) > 9: + return msh[9].strip() + return None + except: + return None + +def extract_message_type(hl7_message): + try: + segments = hl7_message.split('\r') + msh = segments[0].split('|') + if len(msh) > 8: + return msh[8].strip() + return "" + except: + return "" + +def build_hl7_preview(hl7_message, max_segments=4): + try: + segments = [segment.strip() for segment in str(hl7_message or "").split('\r') if segment.strip()] + preview = " | ".join(segments[:max_segments]) + return preview[:800] + except Exception: + return str(hl7_message or "")[:800] + +# ========================================== +# geneXpert TCP Server & HL7/ASTM Handler +# ========================================== + +def manage_tcp_server(): + """Thread Server Utama untuk GeneXpert""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Allow reuse address agar tidak error 'Address already in use' saat restart + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + server.bind((SERVER_HOST, TCP_LISTENER_PORT)) + server.listen(5) # Bisa antri 5 koneksi + print(f"[TCP-SERVER] Listening GeneXpert di port {TCP_LISTENER_PORT}...") + while True: + # Accept koneksi baru (Blocking, tapi aman karena di thread sendiri) + client_sock, addr = server.accept() + + # Buat thread kecil untuk handle client tersebut (agar server bisa terima client lain) + client_thread = threading.Thread( + target=handle_genexpert_client, + args=(client_sock, addr), + daemon=True + ) + client_thread.start() + + except Exception as e: + logging.critical(f"[TCP-SERVER] Gagal Start: {e}") + print(f"[TCP-SERVER] Gagal Start: {e}") + +def run_http_api_server(): + print(f"[HTTP-API] Listening di port {HTTP_API_PORT}...") + app.run(host=SERVER_HOST, port=HTTP_API_PORT, debug=False, use_reloader=False, threaded=True) + +def get_genexpert_host_application(ip_addr): + ip_addr = str(ip_addr or "").strip() + host_app = GENEXPERT_HOST_APPLICATION_BY_IP.get(ip_addr, GENEXPERT_HOST_APPLICATION_DEFAULT) + host_app = str(host_app or "").strip() + return host_app or GENEXPERT_HOST_APPLICATION_DEFAULT + +def parse_astm_records(message_text): + records = [] + for rec in str(message_text or "").split("\r"): + rec = rec.strip() + if not rec: + continue + if rec and rec[0].isdigit(): + rec = rec[1:] + records.append(rec) + return records + +def parse_genexpert_astm_query(message_text): + records = parse_astm_records(message_text) + query = { + "query_sample_id": "", + "query_tag": "", + "raw_records": records, + } + for rec in records: + fields = rec.split("|") + if not fields: + continue + if fields[0] == "H": + query["query_tag"] = fields[4] if len(fields) > 4 else "" + elif fields[0] == "Q": + query["query_sample_id"] = fields[2] if len(fields) > 2 else "" + return query + +def create_genexpert_astm_order_message(orders, ip_addr=None, query_tag=""): + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + records = [ + f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{timestamp}" + ] + + for index, order in enumerate(orders, start=1): + patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=32) + sample_id = sanitize_astm_field(order.rnoreg, max_len=32) + assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) + if not assay_code: + print(f"[GENEXPERT] ASTM payload order dilewati rnoreg={sample_id} karena assay kosong.") + continue + + first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) + first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) + last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) + patient_name = f"{last_name}^{first_name}".strip("^") + sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) + sex = "M" if sex_raw.startswith("L") else ("F" if sex_raw else "") + order_ts = ( + getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') + if getattr(order, "rtglast", None) + else timestamp + ) + + print( + f"[GENEXPERT-ASTM-ORDER] rnoreg={sample_id}, ip={ip_addr}, patient_id={patient_id}, " + f"assay_code={assay_code}, assay_source={assay_source}, capability_match={capability_match}" + ) + + records.append(f"P|{index}|{patient_id}||{patient_id}|{patient_name}|||{sex}") + records.append(f"O|1|{sample_id}||^^^{assay_code}|R|{order_ts}|||||||||ORH||||||||||A") + + records.append("L|1|N") + message = "\r".join(records) + "\r" + print(f"[GENEXPERT-ASTM-ORDER] ip={ip_addr}, query_tag={query_tag}, records={len(records)}, visible={_visible_bytes(message.encode('latin-1'))}") + return message + +def send_all_orders_astm(conn, ip_addr, astm_msg, response_framing="astm"): + query = parse_genexpert_astm_query(astm_msg) + requested_sample_id = str(query.get("query_sample_id") or "").strip() + query_tag = str(query.get("query_tag") or "").strip() + flag = get_flag_by_device(ip_addr) + if not flag: + print(f"[GENEXPERT] ASTM query diabaikan, flag untuk {ip_addr} tidak ditemukan.") + return + + session = SessionLocal() + try: + flag_attr = getattr(PaslabOrder, flag, None) + if flag_attr is None: + print(f"[GENEXPERT] ASTM query diabaikan, atribut flag {flag} tidak ada.") + return + + base_orders = session.query(PaslabOrder).filter( + (flag_attr == False) | (flag_attr == None) + ).order_by(PaslabOrder.urut.asc()).all() + + selected_orders = [] + if requested_sample_id and requested_sample_id.upper() != "ALL": + for order in base_orders: + if str(order.rnoreg or "").strip() != requested_sample_id: + continue + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if assay_code: + selected_orders = [order] + break + else: + for order in base_orders: + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if assay_code: + selected_orders = [order] + break + + print( + f"[GENEXPERT-ASTM-QUERY] ip={ip_addr}, query_tag={query_tag}, requested_sample_id='{requested_sample_id}', " + f"selected_rnoreg={[str(order.rnoreg or '').strip() for order in selected_orders]}" + ) + + if not selected_orders: + reply = f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}\rL|1|N\r" + send_genexpert_response(conn, ip_addr, reply, response_framing, label="astm-q-empty") + return + + reply = create_genexpert_astm_order_message(selected_orders, ip_addr=ip_addr, query_tag=query_tag) + send_genexpert_response(conn, ip_addr, reply, response_framing, label=f"astm-q-order:{selected_orders[0].rnoreg}") + for order in selected_orders: + print(f"[GENEXPERT] Order ASTM ditawarkan ke {ip_addr}: {order.rnoreg}") + finally: + session.close() + +def process_genexpert_hl7_message(conn, ip_addr, clean_hl7, response_framing): + # ========================================================== + # 1. BLOK PENANGANAN ASTM (Karena tidak diawali "MSH|") + # ========================================================== + if not str(clean_hl7 or "").startswith("MSH|"): + records = parse_astm_records(clean_hl7) + record_types = [rec.split("|", 1)[0] for rec in records if rec] + print(f"[GENEXPERT-ASTM] ip={ip_addr}, record_types={record_types}") + + # A. Cek Jika Alat Meminta Order (Query) + if any(rec.startswith("Q|") for rec in records): + print(f"[GENEXPERT-ASTM] Alat meminta ORDER (Q Record)") + send_all_orders_astm(conn, ip_addr, clean_hl7, response_framing="astm") + return + + # B. Cek Jika Alat Mengirim Hasil Lab (Result) + if any(rec.startswith("R|") for rec in records): + print(f"[RESULT] Menerima Hasil Lab ASTM dari {ip_addr}.") + # Memanggil fungsi parser Anda untuk menyimpan hasil ke DB + parse_genexpert_astm_records(clean_hl7, device_name=f"GeneXpert-{ip_addr}") + return + + # C. [PERBAIKAN] Cek Jika Alat Mengirim Komentar/Penolakan (Comment) + if any(rec.startswith("C|") for rec in records): + # 1. Ekstrak teks komentar untuk ditampilkan di log + comments = [rec for rec in records if rec.startswith("C|")] + for c in comments: + parts = c.split('|') + comment_text = parts[3] if len(parts) > 3 else c + print(f"[GENEXPERT-ASTM-INFO] Komentar dari Alat: {comment_text}") + + # 2. Ekstrak NoReg (Nomor Order) dari record 'O' + rnoreg = None + for rec in records: + if rec.startswith("O|"): + o_parts = rec.split('|') + if len(o_parts) > 2: + rnoreg = o_parts[2].strip() + break + + # 3. Update Database PaslabOrder + if rnoreg: + # Cari kolom flag yang cocok dengan IP yang sedang terkoneksi + target_flag_col = None + for flag_col, mapped_ip in TARGET_MAPPING.items(): + if mapped_ip == ip_addr: + target_flag_col = flag_col + break + + if target_flag_col: + try: + # Buka sesi database dan update + with SessionLocal() as session: + order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == rnoreg).first() + if order: + # Set flag mesin tersebut menjadi True agar tidak dikirim ulang + setattr(order, target_flag_col, True) + session.commit() + print(f"[GENEXPERT-DB] Order {rnoreg} ditolak alat. Flag {target_flag_col} di-set True (Selesai).") + else: + print(f"[GENEXPERT-DB] Order {rnoreg} tidak ditemukan di database saat memproses penolakan.") + except Exception as e: + print(f"[GENEXPERT-DB-ERROR] Gagal update order duplikat {rnoreg}: {e}") + + print(f"[GENEXPERT-ASTM] Transaksi penolakan order selesai diproses.") + return + + # D. Jika hanya berisi H dan L tanpa ada transaksi berarti (Status Echo) + if set(record_types).issubset({'H', 'L'}): + print(f"[GENEXPERT-ASTM] Menerima Heartbeat / Sesi Kosong dari alat.") + return + + print(f"[GENEXPERT-ASTM] Pesan ASTM tidak dikenali dari {ip_addr}. Isi: {clean_hl7[:50]}") + return + + # ========================================================== + # 2. BLOK PENANGANAN HL7 (Fallback / Cadangan) + # ========================================================== + log_genexpert_hl7("IN", ip_addr, clean_hl7) + lines = clean_hl7.split('\r') + msh_fields = lines[0].split('|') + incoming_control_id = msh_fields[9] if len(msh_fields) > 9 else "UNKNOWN" + msg_id = incoming_control_id + + if "QRY^" in clean_hl7 or "QRY|" in clean_hl7: + print( + f"[GENEXPERT] Legacy QRY diterima dari {ip_addr} tetapi diabaikan. " + "Host hanya mendukung QBP^Z01/QBP^Z03 untuk order query." + ) + return + + if "ORU^" in clean_hl7: + # --- [PATCH] CEK APAKAH INI PESAN ERROR / PENOLAKAN --- + if "|Error^" in clean_hl7 or "|X\r" in clean_hl7 or "\rTE|" in clean_hl7: + print(f"[GENEXPERT-ERROR] Mesin {ip_addr} menolak tes (Test Unknown/Disabled).") + + # Coba ekstrak NoReg dari segmen SPM agar kita bisa mengunci ordernya (set Flag = True) + rnoreg_error = None + for line in clean_hl7.split('\r'): + if line.startswith('SPM|'): + parts = line.split('|') + if len(parts) > 2: + rnoreg_error = parts[2].replace('^', '').strip() + break + + if rnoreg_error: + # Cari kolom flag yang cocok dengan IP yang sedang terkoneksi + target_flag_col = None + for flag_col, mapped_ip in TARGET_MAPPING.items(): + if mapped_ip == ip_addr: + target_flag_col = flag_col + break + + if target_flag_col: + try: + # Buka sesi database dan update + with SessionLocal() as session: + order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == rnoreg_error).first() + if order: + # Set flag mesin tersebut menjadi True agar tidak dikirim ulang + setattr(order, target_flag_col, True) + session.commit() + print(f"[GENEXPERT-DB] Order {rnoreg_error} ditolak alat. Flag {target_flag_col} di-set True (Selesai).") + else: + print(f"[GENEXPERT-DB] Order {rnoreg_error} tidak ditemukan di database saat memproses penolakan.") + except Exception as e: + print(f"[GENEXPERT-DB-ERROR] Gagal update order duplikat {rnoreg_error}: {e}") + + # Kirim ACK agar alat berhenti mengirim error + ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") + return # BERHENTI DI SINI. Jangan lanjut ke parse_hl7_result! + # ----------------------------------------------------- + + print(f"[RESULT] Menerima Hasil Lab.") + parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}") + ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") + print(f"[ACK SENT] Untuk hasil ID {incoming_control_id}") + return + + if "QBP^Z01" in clean_hl7 or "QBP^Z03" in clean_hl7: + print("[GENEXPERT] Alat meminta ORDER") + msg_id = extract_msg_control_id(clean_hl7) + send_all_orders(conn, ip_addr, clean_hl7, msg_id, response_framing=response_framing) + return + + if "QCN^J01" in clean_hl7: + clear_genexpert_inflight_for_ip(ip_addr, reason="query-confirmation") + ack_msg = create_genexpert_ack_j01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="qcn-ack") + print(f"[GENEXPERT] Menerima konfirmasi query dari {ip_addr}.") + return + + print(f"[GenExpert_TCP] Pesan Lengkap Diterima: {clean_hl7[:50]}...") + parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}, ") + + try: + if len(msh_fields) > 9: + msg_control_id = msh_fields[9] + ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ack_msg = f"MSH|^~\\&|MyLIS|LAB|GeneXpert|Cepheid|{ack_time}||ACK|{msg_control_id}|P|2.5\rMSA|AA|{msg_control_id}\r" + full_ack = f"\x0b{ack_msg}\x1c\r" + log_genexpert_hl7("OUT", ip_addr, ack_msg, label="generic-ack") + conn.sendall(full_ack.encode('utf-8')) + print(f"[ACK] Terkirim untuk ID {msg_control_id}") + except Exception as e: + print(f"Gagal kirim ACK: {e}") + +def handle_genexpert_client(conn, addr): + print(f"[GenExpert_TCP] Koneksi baru dari {addr}") + buffer = b"" + pending_astm_hl7 = None + pending_astm_framing = None + conn.settimeout(60) + client_ip = addr[0] + with connection_lock: + active_genexpert_connections[client_ip] = conn + print(f"[GenExpert_TCP] Register koneksi aktif {client_ip}") + + try: + while True: + try: + data = conn.recv(4096) + if not data: + if pending_astm_hl7: + log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail="reason=connection-close") + process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") + pending_astm_hl7 = None + pending_astm_framing = None + print(f"[GenExpert_TCP] Client {addr} menutup koneksi.") + break + + buffer += data + if b"\x02" in data: + log_genexpert_handshake(addr[0], "STX-RX", detail=f"bytes={len(data)}") + if b"\x03" in data: + log_genexpert_handshake(addr[0], "ETX-RX", detail=f"bytes={len(data)}") + if b"\x04" in data: + log_genexpert_handshake(addr[0], "EOT-RX", detail=f"bytes={len(data)}") + if b"\x06" in data: + log_genexpert_handshake(addr[0], "ACK-RX", detail=f"bytes={len(data)}") + if b"\x15" in data: + log_genexpert_handshake(addr[0], "NAK-RX", detail=f"bytes={len(data)}") + + # --- 1. HANDLE HANDSHAKE (ENQ) --- + # Jika alat kirim ENQ (\x05/♣), langsung balas ACK (\x06) + if b'\x05' in buffer: + # logging.info(f"[TCP] Terima ENQ dari {addr}, kirim ACK.") + log_genexpert_handshake(addr[0], "ENQ-RX", detail=f"buffer_len={len(buffer)}") + conn.sendall(b'\x06') + log_genexpert_handshake(addr[0], "ACK-TX", detail="reason=enq") + # Hapus ENQ dari buffer agar tidak mengganggu + buffer = buffer.replace(b'\x05', b'') + + if b'\x15' in buffer: + log_genexpert_handshake(addr[0], "NAK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") + buffer = buffer.replace(b'\x15', b'') + if b'\x06' in buffer: + log_genexpert_handshake(addr[0], "ACK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") + buffer = buffer.replace(b'\x06', b'') + + # --- 2. CEK APAKAH PESAN SUDAH LENGKAP? --- + # Kita cari tanda akhir pesan umum: + # - \x1c (End Block MLLP) + # - \x03 (ETX - End Text ASTM) + # - \x04 (EOT - End Transmission ASTM) + + msg_complete = False + end_marker_pos = -1 + + if b'\x1c' in buffer: # Pola MLLP Standard + end_marker_pos = buffer.find(b'\x1c') + msg_complete = True + elif b'\x03' in buffer: # Pola ASTM (Ada Checksum setelahnya) + # ASTM: ...CS + # Kita cari \x03, lalu ambil ETX + 2 checksum + CRLF secara penuh. + pos = buffer.find(b'\x03') + if len(buffer) >= pos + 5: + end_marker_pos = pos + 5 + msg_complete = True + elif b'\x04' in buffer: # Pola EOT (Putus Koneksi/Selesai) + end_marker_pos = buffer.find(b'\x04') + msg_complete = True + + # --- 3. PROSES JIKA LENGKAP --- + if msg_complete: + if end_marker_pos == 0 and buffer[:1] == b'\x04': + log_genexpert_handshake(addr[0], "EOT-CLEAR", detail="standalone-eot") + buffer = buffer[1:].lstrip(b'\r').lstrip(b'\n') + if pending_astm_hl7: + log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail=f"framing={pending_astm_framing}") + process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") + pending_astm_hl7 = None + pending_astm_framing = None + continue + + # Ambil pesan dari awal sampai marker + # (Gunakan slice sampai end_marker_pos+1 agar karakter penutup ikut terambil/dibuang) + if end_marker_pos == -1: end_marker_pos = len(buffer) + + full_message_bytes = buffer[:end_marker_pos] + response_framing = detect_genexpert_message_framing(full_message_bytes) + log_genexpert_handshake( + addr[0], + "FRAME-COMPLETE", + detail=f"framing={response_framing}, frame_len={len(full_message_bytes)}" + ) + + send_genexpert_transport_ack( + conn, + addr[0], + response_framing, + reason="incoming-frame-complete" + ) + + # Sisa buffer (jika ada paket nempel di belakangnya) disimpan untuk loop berikutnya + buffer = buffer[end_marker_pos:] + + # Jangan buang EOT di sini; jika EOT datang menempel setelah frame ASTM, + # ia harus diproses pada iterasi berikutnya agar pending ASTM message dijalankan. + buffer = buffer.lstrip(b'\r').lstrip(b'\n') + + # Decode ke string + temp_str = full_message_bytes.decode('latin-1', errors='ignore') + + # --- SANITIZING (PEMBERSIHAN) --- + # Cari MSH pertama + if "MSH|" in temp_str: + msh_index = temp_str.find("MSH|") + clean_hl7 = temp_str[msh_index:] + if response_framing == "astm": + pending_astm_hl7 = clean_hl7 + pending_astm_framing = response_framing + log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(clean_hl7)}") + continue + process_genexpert_hl7_message(conn, addr[0], clean_hl7, response_framing) + else: + # Jika pesan lengkap tapi tidak ada MSH (misal cuma EOT doang) + pass + else: + if buffer: + head_hex = buffer[:12].hex() + log_genexpert_handshake( + addr[0], + "BUFFER-WAIT", + detail=f"buffer_len={len(buffer)}, head_hex={head_hex}" + ) + + except ConnectionResetError: + logging.warning(f"[GenExpert_TCP] Connection reset by peer: {addr}") + break + except OSError as e: + if getattr(e, "winerror", None) == 10054: + logging.warning(f"[GenExpert_TCP] WinError 10054 dari {addr}") + break + raise + except socket.timeout: + continue + except Exception as e: + print(f"[Loop Error] {e}") + logging.exception(f"[Loop Error] Unexpected error from {addr}: {e}") + break + + except Exception as e: + logging.error(f"[GenExpert_TCP Error] Koneksi {addr} terputus: {e}") + finally: + with connection_lock: + if active_genexpert_connections.get(client_ip) is conn: + del active_genexpert_connections[client_ip] + remaining_connections = len(active_genexpert_connections) + clear_genexpert_inflight_for_ip(client_ip, reason="connection-closed") + if remaining_connections == 0: + stop_all_scheduled_result_queries(reason="no-active-genexpert") + try: + conn.close() + except Exception: + pass + logging.info(f"[GenExpert_TCP] Koneksi {addr} ditutup.") + +def send_order_via_active_connection(target_ip, hl7_message): + conn = None + with connection_lock: + conn = active_genexpert_connections.get(target_ip) + + if not conn: + logging.warning(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") + print(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") + return False + + try: + # Bungkus pesan dengan MLLP (Minimal Lower Layer Protocol) standard HL7 + # Format: message + mllp_msg = f"\x0b{hl7_message}\x1c\r" + + logging.info(f"Mengirim Order ke {target_ip}...") + print(f"Mengirim Order ke {target_ip}...") + conn.sendall(mllp_msg.encode('utf-8')) + + # Opsi: Jika ingin menunggu ACK balasan untuk Order + # Namun hati-hati ini bisa blocking jika alat lambat + ack = conn.recv(1024) + logging.info(f"Dapat ACK Order dari {target_ip}: {ack}") + print(f"Dapat ACK Order dari {target_ip}: {ack}") + return True + except Exception as e: + logging.error(f"Error mengirim ke {target_ip}: {e}") + print(f"Error mengirim ke {target_ip}: {e}") + # Jika error saat kirim, anggap koneksi rusak + with connection_lock: + if target_ip in active_genexpert_connections: + del active_genexpert_connections[target_ip] + return False + +def parse_genexpert_astm_records(astm_string, device_name): + """ + Parser khusus untuk membaca hasil ASTM dari instrumen GeneXpert + dan menyimpannya ke tabel LisPhoenix dengan aman (mencegah VARCHAR limit error). + """ + try: + records = astm_string.split('\r') + no_id = "" + rnmpas = "" + seq_no = "" + hasil_list = [] + + for rec in records: + # 1. Ambil Data Pasien (P Record) + if rec.startswith("P|"): + parts = rec.split('|') + if len(parts) > 3: + # Ambil Patient ID (Bisa di index 3 atau 4 tergantung setting alat) + no_id = parts[3].strip() or (parts[4].strip() if len(parts) > 4 else "") + if len(parts) > 5: + # Ambil Nama Pasien, ganti ^ dengan spasi + rnmpas = parts[5].replace('^', ' ').strip() + + # 2. Ambil Nomor Order / Registrasi (O Record) + elif rec.startswith("O|"): + parts = rec.split('|') + if len(parts) > 2: + seq_no = parts[2].strip() + + # 3. Ambil Hasil Tes (R Record) + elif rec.startswith("R|"): + parts = rec.split('|') + if len(parts) > 3: + test_info = parts[2] # Contoh: ^^^MTB-RIF_ULTRA 2^^^MTB^ + result_val = parts[3].replace('^', '').strip() # Contoh: DETECTED atau INVALID + + # [KUNCI]: Abaikan kurva analitik agar teks tidak kepanjangan + if "Ct|" not in rec and "EndPt|" not in rec and result_val: + # Ekstrak nama targetnya saja (misal "MTB" atau "RIF Resistance") + target_match = test_info.split('^^^') + target_name = target_match[-1].strip('^') if len(target_match) > 1 else "" + + if target_name and result_val: + hasil_list.append(f"{target_name}: {result_val}") + + # Gabungkan hasil-hasil penting menjadi 1 string + kesimpulan = " | ".join(hasil_list) + + # [PATCH KRITIS]: Potong string agar TIDAK CRASH di database + no_id_safe = no_id[:50] + seq_no_safe = seq_no[:50] + rnmpas_safe = rnmpas[:100] + kesimpulan_safe = kesimpulan[:100] # Membatasi maksimal 100 karakter untuk kolom 'organisme' + + if not seq_no_safe: + print("[GENEXPERT-PARSER] Warning: seq_no (No Order) tidak ditemukan dalam pesan hasil.") + return + + # Simpan ke Database + with SessionLocal() as session: + new_result = LisPhoenix( + no_id=no_id_safe, + seq_no=seq_no_safe, + rnmpas=rnmpas_safe, + tgl_data=datetime.datetime.now().date(), + rawdt=astm_string, # Simpan data mentahnya (utuh) ke TEXT untuk jaga-jaga/bisa dibaca ulang + organisme=kesimpulan_safe, # Hasil yang sudah bersih dan muat + alat=device_name, + processed='N' # Atau menyesuaikan default sistem Anda + ) + session.add(new_result) + session.commit() + print(f"[GENEXPERT-DB-SUCCESS] Hasil lab untuk Order {seq_no_safe} berhasil disimpan ke LisPhoenix!") + + except Exception as e: + print(f"[GENEXPERT-PARSER-ERROR] Gagal memparsing/menyimpan hasil: {e}") + traceback.print_exc() + def parse_genexpert_qpd(qpd_segment): fields = str(qpd_segment or "").split('|') query_name = fields[1] if len(fields) > 1 else "" @@ -407,27 +1070,6 @@ def create_genexpert_ack_r01_response(incoming_hl7): msa = f"MSA|CA|{incoming_control_id}" return f"{msh}\r{msa}\r" -def format_hl7_date(value): - if not value: - return "" - if isinstance(value, datetime.datetime): - return value.strftime("%Y%m%d") - if isinstance(value, datetime.date): - return value.strftime("%Y%m%d") - text = str(value).strip() - digits = re.sub(r"[^0-9]", "", text) - return digits[:8] if len(digits) >= 8 else "" - -def map_hl7_sex(value): - text = str(value or "").strip().upper() - if not text: - return "" - if text.startswith("L") or "LAKI" in text or text == "M": - return "M" - if text.startswith("P") or "PEREM" in text or text == "F": - return "F" - return "" - def create_genexpert_rsp_z02_response(orders, incoming_hl7, ip_addr=None): qpd_segment = extract_segment(incoming_hl7, "QPD") qpd = parse_genexpert_qpd(qpd_segment) @@ -516,34 +1158,6 @@ def send_all_orders(conn, ip_addr, hl7_msg, msg_id, response_framing="mllp"): target_ip=scheduled_order["target_ip"], ) -def extract_msg_control_id(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 9: - return msh[9].strip() - return None - except: - return None - -def extract_message_type(hl7_message): - try: - segments = hl7_message.split('\r') - msh = segments[0].split('|') - if len(msh) > 8: - return msh[8].strip() - return "" - except: - return "" - -def build_hl7_preview(hl7_message, max_segments=4): - try: - segments = [segment.strip() for segment in str(hl7_message or "").split('\r') if segment.strip()] - preview = " | ".join(segments[:max_segments]) - return preview[:800] - except Exception: - return str(hl7_message or "")[:800] - def log_genexpert_hl7(direction, ip_addr, hl7_message, label=""): message_type = extract_message_type(hl7_message) or "UNKNOWN" control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" @@ -753,344 +1367,13 @@ def clear_genexpert_inflight_for_ip(ip_addr, reason="cleared"): return True return False -def stop_all_scheduled_result_queries(reason="no-active-genexpert"): - with scheduled_result_query_lock: - if not scheduled_result_queries: - return 0 - - stopped_accnumbers = list(scheduled_result_queries.keys()) - for accnumber in stopped_accnumbers: - state = scheduled_result_queries.get(accnumber) - if not state: - continue - state["status"] = reason - stop_event = state.get("stop_event") - if stop_event: - stop_event.set() - - scheduled_result_queries.clear() - - logging.info( - f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" - ) - print( - f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" - ) - with genexpert_query_inflight_lock: - genexpert_query_inflight_by_ip.clear() - return len(stopped_accnumbers) - -def stop_scheduled_result_query(accnumber, reason="completed"): - accnumber = str(accnumber or "").strip() - if not accnumber: - return False - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return False - state["status"] = reason - stop_event = state.get("stop_event") - if stop_event: - stop_event.set() - scheduled_result_queries.pop(accnumber, None) - - logging.info(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") - print(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") - return True - -def scheduled_result_query_worker(accnumber): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - stop_scheduled_result_query(accnumber, reason="disabled") - return - - while True: - if not get_active_genexpert_ips(): - stop_all_scheduled_result_queries(reason="no-active-genexpert") - return - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - stop_event = state["stop_event"] - target_ip = state.get("target_ip") - register_no = state.get("register_no") or accnumber - attempt = int(state.get("attempt", 0)) - created_at = state.get("created_at") or datetime.datetime.now() - max_duration_seconds = max( - int(state.get("max_duration_seconds", GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS)), - 1 - ) - age_seconds = max(int((datetime.datetime.now() - created_at).total_seconds()), 0) - if age_seconds >= max_duration_seconds: - state["status"] = "expired" - state["expired_at"] = datetime.datetime.now() - state["age_seconds"] = age_seconds - stop_event.set() - scheduled_result_queries.pop(accnumber, None) - logging.info( - f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " - f"age={age_seconds}s, max_duration={max_duration_seconds}s" - ) - print( - f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " - f"age={age_seconds}s, max_duration={max_duration_seconds}s" - ) - return - next_delay = max( - int( - state.get( - "initial_delay_seconds" if attempt == 0 else "interval_seconds", - GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS if attempt == 0 else GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS - ) - ), - 1 - ) - - if stop_event.wait(next_delay): - return - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - state["attempt"] = int(state.get("attempt", 0)) + 1 - state["last_requested_at"] = datetime.datetime.now() - attempt_no = state["attempt"] - - print( - f"[GENEXPERT-SCHEDULER] Trigger query hasil accnumber={accnumber}, " - f"register_no={register_no}, target_ip={target_ip}, attempt={attempt_no}" - ) - - result = trigger_result_query_to_genexpert( - accnumber=accnumber, - register_no=register_no, - target_ip=target_ip, - wait_seconds=0, - ) - - with scheduled_result_query_lock: - state = scheduled_result_queries.get(accnumber) - if not state: - return - state["last_result"] = result - if result.get("ok"): - state["last_successful_query_at"] = datetime.datetime.now() - else: - state["last_error"] = result.get("message") - -def schedule_result_query_for_order( - accnumber, - register_no, - target_ip=None, - initial_delay_seconds=GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS, - interval_seconds=GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS, - max_duration_seconds=GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS, -): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - print( - f"[GENEXPERT-SCHEDULER] Nonaktif. Jadwal query hasil dilewati untuk accnumber={accnumber}" - ) - return False - - accnumber = str(accnumber or "").strip() - register_no = str(register_no or accnumber).strip() - target_ip = str(target_ip or "").strip() or None - - if not accnumber: - print("[GENEXPERT-SCHEDULER] Jadwal query hasil dilewati karena accnumber kosong.") - return False - - with scheduled_result_query_lock: - existing = scheduled_result_queries.get(accnumber) - if existing: - existing["register_no"] = register_no - existing["target_ip"] = target_ip - existing["initial_delay_seconds"] = initial_delay_seconds - existing["interval_seconds"] = interval_seconds - existing["max_duration_seconds"] = max_duration_seconds - existing["status"] = "active" - print(f"[GENEXPERT-SCHEDULER] Jadwal sudah aktif untuk accnumber={accnumber}") - return True - - stop_event = threading.Event() - worker = threading.Thread( - target=scheduled_result_query_worker, - args=(accnumber,), - name=f"GeneXpertResultQuery-{accnumber}", - daemon=True, - ) - scheduled_result_queries[accnumber] = { - "register_no": register_no, - "target_ip": target_ip, - "initial_delay_seconds": initial_delay_seconds, - "interval_seconds": interval_seconds, - "max_duration_seconds": max_duration_seconds, - "attempt": 0, - "status": "active", - "created_at": datetime.datetime.now(), - "stop_event": stop_event, - "thread_name": worker.name, - } - - worker.start() - print( - f"[GENEXPERT-SCHEDULER] Jadwal dibuat accnumber={accnumber}, " - f"register_no={register_no}, target_ip={target_ip}, " - f"initial_delay={initial_delay_seconds}s, interval={interval_seconds}s, " - f"max_duration={max_duration_seconds}s" - ) - return True - -def trigger_result_query_to_genexpert(accnumber, register_no, target_ip=None, wait_seconds=20): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - return { - "ok": False, - "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", - } - - active_ips = get_active_genexpert_ips() - if not active_ips: - return {"ok": False, "message": "Tidak ada koneksi GeneXpert aktif."} - - if target_ip: - target_ips = [target_ip] if target_ip in active_ips else [] - if not target_ips: - return {"ok": False, "message": f"Koneksi GeneXpert {target_ip} tidak aktif."} - else: - target_ips = active_ips - - ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - msg_control_id = f"LISQRY{ts}{int(time.time() * 1000) % 1000:03d}" - query_message = build_genexpert_result_query(accnumber, msg_control_id) - mllp_payload = f"\x0b{query_message}\x1c\r".encode("utf-8") - - pending_event = threading.Event() - with pending_query_lock: - pending_result_queries[accnumber] = { - "register_no": register_no, - "target_ips": list(target_ips), - "msg_control_id": msg_control_id, - "requested_at": datetime.datetime.now(), - "event": pending_event, - "status": "requested", - } - - sent_ips = [] - failed_ips = [] - for ip in target_ips: - now = datetime.datetime.now() - with genexpert_query_inflight_lock: - inflight = genexpert_query_inflight_by_ip.get(ip) - if inflight: - inflight_age = max( - (now - inflight.get("requested_at", now)).total_seconds(), - 0 - ) - if inflight_age < GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS: - failed_ips.append({ - "ip": ip, - "error": f"query-inflight:{inflight.get('accnumber')}", - }) - print( - f"[GENEXPERT-QUERY] Skip query accnumber={accnumber} ke {ip} " - f"karena masih menunggu accnumber={inflight.get('accnumber')} " - f"({int(inflight_age)}s)" - ) - continue - genexpert_query_inflight_by_ip.pop(ip, None) - - with connection_lock: - conn = active_genexpert_connections.get(ip) - if not conn: - failed_ips.append({"ip": ip, "error": "connection-not-active"}) - continue - try: - log_genexpert_hl7("OUT", ip, query_message, label=f"result-query:{accnumber}") - log_genexpert_hl7_full("OUT", ip, query_message, label=f"result-query:{accnumber}") - conn.sendall(mllp_payload) - with genexpert_query_inflight_lock: - genexpert_query_inflight_by_ip[ip] = { - "accnumber": accnumber, - "register_no": register_no, - "requested_at": now, - "msg_control_id": msg_control_id, - } - sent_ips.append(ip) - print(f"[GENEXPERT-QUERY] Kirim query hasil accnumber={accnumber} ke {ip}") - except Exception as e: - failed_ips.append({"ip": ip, "error": str(e)}) - clear_genexpert_inflight_for_ip(ip, reason="send-failed") - - if not sent_ips: - with pending_query_lock: - pending_result_queries.pop(accnumber, None) - return {"ok": False, "message": "Gagal kirim query ke semua GeneXpert aktif.", "failures": failed_ips} - - if wait_seconds and wait_seconds > 0: - pending_event.wait(wait_seconds) - with pending_query_lock: - state = pending_result_queries.get(accnumber) - if state and state.get("status") == "found": - pending_result_queries.pop(accnumber, None) - return { - "ok": True, - "message": "Hasil ditemukan dan disimpan ke LisPhoenix.", - "target_ips": sent_ips, - "accnumber": accnumber, - "source_ip": state.get("source_ip"), - } - # Timeout/hasil belum masuk, biarkan state tetap ada agar response telat tetap bisa diproses. - return { - "ok": True, - "message": "Query terkirim. Menunggu hasil dari GeneXpert.", - "target_ips": sent_ips, - "accnumber": accnumber, - "failures": failed_ips, - } - - return { - "ok": True, - "message": "Query terkirim.", - "target_ips": sent_ips, - "accnumber": accnumber, - "failures": failed_ips, - } - @app.route("/api/genexpert/query-result", methods=["POST"]) def api_query_genexpert_result(): - if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: - return jsonify({ - "ok": False, - "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", - }), 409 + return jsonify({ + "ok": False, + "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", + }), 409 - payload = request.get_json(silent=True) or {} - accnumber = str(payload.get("accnumber") or "").strip() - register_no = str(payload.get("register_no") or payload.get("nomor_register") or "").strip() - target_ip = str(payload.get("target_ip") or "").strip() or None - - try: - wait_seconds = int(payload.get("wait_seconds", 20)) - except Exception: - wait_seconds = 20 - - if not accnumber: - return jsonify({"ok": False, "message": "Field 'accnumber' wajib diisi."}), 400 - if not register_no: - return jsonify({"ok": False, "message": "Field 'register_no' (nomor register pasien) wajib diisi."}), 400 - - result = trigger_result_query_to_genexpert( - accnumber=accnumber, - register_no=register_no, - target_ip=target_ip, - wait_seconds=wait_seconds, - ) - status_code = 200 if result.get("ok") else 409 - return jsonify(result), status_code - def parse_hl7_result(conn, msg_id, hl7_message, device_name="GeneXpert"): session = SessionLocal() clean_hl7 = hl7_message @@ -1297,7 +1580,7 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment): # Test Code (Pakai Mapping yang tadi kita buat) nama_tes_db = str(order.tes).strip() if order.tes else "" - test_code = GENEXPERT_TEST_MAPPING.get(nama_tes_db, DEFAULT_GXP_CODE) # Default: MTB-RIF + test_code = GENEXPERT_TEST_MAPPING.get(nama_tes_db, DEFAULT_GXP_CODE) # Default: MTBRIF # Segmen Data # DSP/PID/ORC/OBR tergantung setting alat. @@ -1317,6 +1600,9 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment): # Atau cukup kirim MSA|AA tapi tanpa segmen Order return f"{msh}\r{msa}\r{qrd}\r{qrf}\r" +# ========================================== +# bioMérieux MYLA TCP Server Handler +# ========================================== def parse_myla_result(hl7_message, device_name="MYLA"): """ Parser khusus untuk HL7 dari bioMérieux MYLA (Kalibrasi V4.9). @@ -1877,9 +2163,7 @@ def send_order_to_myla_hl7(conn, order, peer_ip): timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS ) return ack_ok -# ========================================== -# 4. NETWORK & COMMUNICATION LOGIC -# ========================================== + def handle_myla_client(conn, addr): """ TCP Handler untuk bioMérieux MYLA. @@ -1926,71 +2210,8 @@ def handle_myla_client(conn, addr): elif "QRY^" in hl7_str or "QBP^" in hl7_str: client_ip = addr[0] print(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() - print(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')) - print(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}") - print(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 + logging.info(f"[MYLA] Permintaan Order Tidak di Proses (Control ID: {incoming_control_id})") + # --- KIRIM ACK --- if incoming_control_id: @@ -2105,295 +2326,6 @@ def start_myla_inbound_server(host, port): except Exception: pass -# ========================================== -# HL7 TCP LISTENER FOR GENEXPERT -# ========================================== - -def manage_tcp_server(): - """Thread Server Utama untuk GeneXpert""" - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # Allow reuse address agar tidak error 'Address already in use' saat restart - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - server.bind((SERVER_HOST, TCP_LISTENER_PORT)) - server.listen(5) # Bisa antri 5 koneksi - print(f"[TCP-SERVER] Listening GeneXpert di port {TCP_LISTENER_PORT}...") - while True: - # Accept koneksi baru (Blocking, tapi aman karena di thread sendiri) - client_sock, addr = server.accept() - - # Buat thread kecil untuk handle client tersebut (agar server bisa terima client lain) - client_thread = threading.Thread( - target=handle_genexpert_client, - args=(client_sock, addr), - daemon=True - ) - client_thread.start() - - except Exception as e: - logging.critical(f"[TCP-SERVER] Gagal Start: {e}") - print(f"[TCP-SERVER] Gagal Start: {e}") - -def run_http_api_server(): - print(f"[HTTP-API] Listening di port {HTTP_API_PORT}...") - app.run(host=SERVER_HOST, port=HTTP_API_PORT, debug=False, use_reloader=False, threaded=True) - -def process_genexpert_hl7_message(conn, ip_addr, clean_hl7, response_framing): - log_genexpert_hl7("IN", ip_addr, clean_hl7) - lines = clean_hl7.split('\r') - msh_fields = lines[0].split('|') - incoming_control_id = msh_fields[9] if len(msh_fields) > 9 else "UNKNOWN" - msg_id = incoming_control_id - - if "QRY^" in clean_hl7 or "QRY|" in clean_hl7: - print( - f"[GENEXPERT] Legacy QRY diterima dari {ip_addr} tetapi diabaikan. " - "Host hanya mendukung QBP^Z01/QBP^Z03 untuk order query." - ) - return - - if "ORU^" in clean_hl7: - print(f"[RESULT] Menerima Hasil Lab.") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}") - ack_msg = create_genexpert_ack_r01_response(clean_hl7) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") - print(f"[ACK SENT] Untuk hasil ID {incoming_control_id}") - return - - if "QBP^Z01" in clean_hl7 or "QBP^Z03" in clean_hl7: - print("[GENEXPERT] Alat meminta ORDER") - msg_id = extract_msg_control_id(clean_hl7) - send_all_orders(conn, ip_addr, clean_hl7, msg_id, response_framing=response_framing) - return - - if "QCN^J01" in clean_hl7: - clear_genexpert_inflight_for_ip(ip_addr, reason="query-confirmation") - ack_msg = create_genexpert_ack_j01_response(clean_hl7) - send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="qcn-ack") - print(f"[GENEXPERT] Menerima konfirmasi query dari {ip_addr}.") - return - - print(f"[GenExpert_TCP] Pesan Lengkap Diterima: {clean_hl7[:50]}...") - parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}, ") - - try: - if len(msh_fields) > 9: - msg_control_id = msh_fields[9] - ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - ack_msg = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ack_time}||ACK|{msg_control_id}|P|2.5\rMSA|AA|{msg_control_id}\r" - full_ack = f"\x0b{ack_msg}\x1c\r" - log_genexpert_hl7("OUT", ip_addr, ack_msg, label="generic-ack") - conn.sendall(full_ack.encode('utf-8')) - print(f"[ACK] Terkirim untuk ID {msg_control_id}") - except Exception as e: - print(f"Gagal kirim ACK: {e}") - -def handle_genexpert_client(conn, addr): - print(f"[GenExpert_TCP] Koneksi baru dari {addr}") - buffer = b"" - pending_astm_hl7 = None - pending_astm_framing = None - conn.settimeout(60) - client_ip = addr[0] - with connection_lock: - active_genexpert_connections[client_ip] = conn - print(f"[GenExpert_TCP] Register koneksi aktif {client_ip}") - - try: - while True: - try: - data = conn.recv(4096) - if not data: - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail="reason=connection-close") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - print(f"[GenExpert_TCP] Client {addr} menutup koneksi.") - break - - buffer += data - if b"\x02" in data: - log_genexpert_handshake(addr[0], "STX-RX", detail=f"bytes={len(data)}") - if b"\x03" in data: - log_genexpert_handshake(addr[0], "ETX-RX", detail=f"bytes={len(data)}") - if b"\x04" in data: - log_genexpert_handshake(addr[0], "EOT-RX", detail=f"bytes={len(data)}") - if b"\x06" in data: - log_genexpert_handshake(addr[0], "ACK-RX", detail=f"bytes={len(data)}") - if b"\x15" in data: - log_genexpert_handshake(addr[0], "NAK-RX", detail=f"bytes={len(data)}") - - # --- 1. HANDLE HANDSHAKE (ENQ) --- - # Jika alat kirim ENQ (\x05/♣), langsung balas ACK (\x06) - if b'\x05' in buffer: - # logging.info(f"[TCP] Terima ENQ dari {addr}, kirim ACK.") - log_genexpert_handshake(addr[0], "ENQ-RX", detail=f"buffer_len={len(buffer)}") - conn.sendall(b'\x06') - log_genexpert_handshake(addr[0], "ACK-TX", detail="reason=enq") - # Hapus ENQ dari buffer agar tidak mengganggu - buffer = buffer.replace(b'\x05', b'') - - if b'\x15' in buffer: - log_genexpert_handshake(addr[0], "NAK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x15', b'') - if b'\x06' in buffer: - log_genexpert_handshake(addr[0], "ACK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") - buffer = buffer.replace(b'\x06', b'') - - # --- 2. CEK APAKAH PESAN SUDAH LENGKAP? --- - # Kita cari tanda akhir pesan umum: - # - \x1c (End Block MLLP) - # - \x03 (ETX - End Text ASTM) - # - \x04 (EOT - End Transmission ASTM) - - msg_complete = False - end_marker_pos = -1 - - if b'\x1c' in buffer: # Pola MLLP Standard - end_marker_pos = buffer.find(b'\x1c') - msg_complete = True - elif b'\x03' in buffer: # Pola ASTM (Ada Checksum setelahnya) - # ASTM: ...CS - # Kita cari \x03, lalu ambil ETX + 2 checksum + CRLF secara penuh. - pos = buffer.find(b'\x03') - if len(buffer) >= pos + 5: - end_marker_pos = pos + 5 - msg_complete = True - elif b'\x04' in buffer: # Pola EOT (Putus Koneksi/Selesai) - end_marker_pos = buffer.find(b'\x04') - msg_complete = True - - # --- 3. PROSES JIKA LENGKAP --- - if msg_complete: - if end_marker_pos == 0 and buffer[:1] == b'\x04': - log_genexpert_handshake(addr[0], "EOT-CLEAR", detail="standalone-eot") - buffer = buffer[1:].lstrip(b'\r').lstrip(b'\n') - if pending_astm_hl7: - log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail=f"framing={pending_astm_framing}") - process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") - pending_astm_hl7 = None - pending_astm_framing = None - continue - - # Ambil pesan dari awal sampai marker - # (Gunakan slice sampai end_marker_pos+1 agar karakter penutup ikut terambil/dibuang) - if end_marker_pos == -1: end_marker_pos = len(buffer) - - full_message_bytes = buffer[:end_marker_pos] - response_framing = detect_genexpert_message_framing(full_message_bytes) - log_genexpert_handshake( - addr[0], - "FRAME-COMPLETE", - detail=f"framing={response_framing}, frame_len={len(full_message_bytes)}" - ) - - send_genexpert_transport_ack( - conn, - addr[0], - response_framing, - reason="incoming-frame-complete" - ) - - # Sisa buffer (jika ada paket nempel di belakangnya) disimpan untuk loop berikutnya - buffer = buffer[end_marker_pos:] - - # Jangan buang EOT di sini; jika EOT datang menempel setelah frame ASTM, - # ia harus diproses pada iterasi berikutnya agar pending ASTM message dijalankan. - buffer = buffer.lstrip(b'\r').lstrip(b'\n') - - # Decode ke string - temp_str = full_message_bytes.decode('latin-1', errors='ignore') - - # --- SANITIZING (PEMBERSIHAN) --- - # Cari MSH pertama - if "MSH|" in temp_str: - msh_index = temp_str.find("MSH|") - clean_hl7 = temp_str[msh_index:] - if response_framing == "astm": - pending_astm_hl7 = clean_hl7 - pending_astm_framing = response_framing - log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(clean_hl7)}") - continue - process_genexpert_hl7_message(conn, addr[0], clean_hl7, response_framing) - else: - # Jika pesan lengkap tapi tidak ada MSH (misal cuma EOT doang) - pass - else: - if buffer: - head_hex = buffer[:12].hex() - log_genexpert_handshake( - addr[0], - "BUFFER-WAIT", - detail=f"buffer_len={len(buffer)}, head_hex={head_hex}" - ) - - except ConnectionResetError: - logging.warning(f"[GenExpert_TCP] Connection reset by peer: {addr}") - break - except OSError as e: - if getattr(e, "winerror", None) == 10054: - logging.warning(f"[GenExpert_TCP] WinError 10054 dari {addr}") - break - raise - except socket.timeout: - continue - except Exception as e: - print(f"[Loop Error] {e}") - logging.exception(f"[Loop Error] Unexpected error from {addr}: {e}") - break - - except Exception as e: - logging.error(f"[GenExpert_TCP Error] Koneksi {addr} terputus: {e}") - finally: - with connection_lock: - if active_genexpert_connections.get(client_ip) is conn: - del active_genexpert_connections[client_ip] - remaining_connections = len(active_genexpert_connections) - clear_genexpert_inflight_for_ip(client_ip, reason="connection-closed") - if remaining_connections == 0: - stop_all_scheduled_result_queries(reason="no-active-genexpert") - try: - conn.close() - except Exception: - pass - logging.info(f"[GenExpert_TCP] Koneksi {addr} ditutup.") - -def send_order_via_active_connection(target_ip, hl7_message): - conn = None - with connection_lock: - conn = active_genexpert_connections.get(target_ip) - - if not conn: - logging.warning(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") - print(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") - return False - - try: - # Bungkus pesan dengan MLLP (Minimal Lower Layer Protocol) standard HL7 - # Format: message - mllp_msg = f"\x0b{hl7_message}\x1c\r" - - logging.info(f"Mengirim Order ke {target_ip}...") - print(f"Mengirim Order ke {target_ip}...") - conn.sendall(mllp_msg.encode('utf-8')) - - # Opsi: Jika ingin menunggu ACK balasan untuk Order - # Namun hati-hati ini bisa blocking jika alat lambat - ack = conn.recv(1024) - logging.info(f"Dapat ACK Order dari {target_ip}: {ack}") - print(f"Dapat ACK Order dari {target_ip}: {ack}") - return True - except Exception as e: - logging.error(f"Error mengirim ke {target_ip}: {e}") - print(f"Error mengirim ke {target_ip}: {e}") - # Jika error saat kirim, anggap koneksi rusak - with connection_lock: - if target_ip in active_genexpert_connections: - del active_genexpert_connections[target_ip] - return False - # ========================================== # VITEK PARSER # ========================================== @@ -3298,24 +3230,20 @@ if __name__ == "__main__": # List untuk menampung semua thread agar bisa dimonitor all_threads = [] stop_event = threading.Event() - # 1. Start Thread Order Poller (Pengecek Order Baru di DB) - #t_poller = threading.Thread(target=order_poller, args=(stop_event,), name="OrderPoller", daemon=True) - #t_poller.start() - #all_threads.append(t_poller) - - # 2. Start Thread Serial Manager (Vitek & BD) + + # 1. Start Thread Serial Manager (Vitek & BD) for config in DEVICE_CONFIGS: if config['protocol'] == 'serial': t_serial = threading.Thread( target=manage_serial_port, args=(config,), - name=f"Manager-{config['device_type']}-{config['port']}", # Nama lebih jelas + name=f"Manager-{config['device_type']}-{config['port']}", daemon=True ) t_serial.start() all_threads.append(t_serial) - # 3. Start Thread TCP Server (GeneXpert) + # 2. Start Thread TCP Server (GeneXpert) t_tcp = threading.Thread(target=manage_tcp_server, name="Manager-TCP-GeneXpert", daemon=True) t_tcp.start() all_threads.append(t_tcp) @@ -3330,7 +3258,7 @@ if __name__ == "__main__": myla_thread.start() all_threads.append(myla_thread) - # 3b. Start Thread TCP Server Inbound (MyLA/BCI -> LIS: kirim hasil) + # 3. Start Thread TCP Server Inbound (MyLA/BCI -> LIS: kirim hasil) myla_inbound_thread = threading.Thread( target=start_myla_inbound_server, args=(MYLA_INBOUND_HOST, MYLA_INBOUND_PORT), diff --git a/listener/geneexpert.py b/listener/geneexpert.py new file mode 100644 index 00000000..ad29bb5d --- /dev/null +++ b/listener/geneexpert.py @@ -0,0 +1,3800 @@ +import builtins +from enum import Enum +from logging import config +from logging.handlers import TimedRotatingFileHandler +import os +from queue import Queue +import re +import socket +import logging +from sqlite3 import Date +import threading +import time +import datetime +import traceback +import serial # type: ignore + +from flask import Flask, jsonify, request # type: ignore +from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text # type: ignore +from sqlalchemy import DateTime as SqDateTime # type: ignore +from sqlalchemy import Date as SqDate # type: ignore +from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore +# Logging Setup +# Konfigurasi logging per hari +log_handler = TimedRotatingFileHandler( + filename="app.log", + when="midnight", + interval=1, + backupCount=7, + encoding="utf-8" +) +formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s') +log_handler.setFormatter(formatter) +logging.basicConfig(level=logging.INFO, handlers=[log_handler]) + +THREAD_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "thread_logs") +thread_log_lock = threading.Lock() + +def _sanitize_thread_log_name(thread_name): + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(thread_name or "main").strip()) + return safe_name or "main" + +def _write_thread_log(message): + try: + os.makedirs(THREAD_LOG_DIR, exist_ok=True) + thread_name = threading.current_thread().name + safe_thread_name = _sanitize_thread_log_name(thread_name) + log_path = os.path.join(THREAD_LOG_DIR, f"{safe_thread_name}.log") + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with thread_log_lock: + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(f"{timestamp} | {message}\n") + except Exception: + pass + +def print(*args, **kwargs): + sep = kwargs.get("sep", " ") + end = kwargs.get("end", "\n") + message = sep.join(str(arg) for arg in args) + _write_thread_log(message) + return builtins.print(*args, **kwargs) + +def _visible_bytes(data: bytes) -> str: + mapping = { + 0x02: "", + 0x03: "", + 0x04: "", + 0x05: "", + 0x06: "", + 0x0D: "", + 0x0A: "", + 0x17: "", + 0x15: "", + 0x1C: "", + 0x0B: "", + } + parts = [] + for b in data: + if b in mapping: + parts.append(mapping[b]) + elif 32 <= b <= 126: + parts.append(chr(b)) + else: + parts.append(f"<0x{b:02X}>") + return "".join(parts) + +def _hex_bytes(data: bytes, limit: int = 160) -> str: + clipped = data[:limit] + text = clipped.hex().upper() + return text + ("..." if len(data) > limit else "") + +# ========================================== +# 1. KONFIGURASI SISTEM +# ========================================== +# Global Variables +app = Flask(__name__) +active_genexpert_connections = {} +connection_lock = threading.Lock() +pending_result_queries = {} +pending_query_lock = threading.Lock() +scheduled_result_queries = {} +scheduled_result_query_lock = threading.Lock() +genexpert_query_inflight_by_ip = {} +genexpert_query_inflight_lock = threading.Lock() +# Network Configuration +TCP_LISTENER_PORT = 6001 # PC GeneXpert set ke mode Client, konek ke IP:PORT ini +SERVER_HOST = '0.0.0.0' # Listen di semua interface +HTTP_API_PORT = 6002 # Endpoint trigger dari Laravel -> Python +GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS = 60 +GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS = 120 +GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS = 21600 +GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS = 45 +GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER = False +GENEXPERT_RESPONSE_MODE_DEFAULT = "astm_active" +GENEXPERT_RESPONSE_MODE_BY_IP = { + # "10.10.120.75": "hl7_passive", +} +GENEXPERT_HOST_APPLICATION_DEFAULT = "DE002" +GENEXPERT_HOST_APPLICATION_BY_IP = { + "10.10.120.75": "GE01", + "10.10.120.13": "DE002", + "10.10.120.73": "GE01", +} +# Mapping Flag ke IP Address GeneXpert +# Pastikan IP ini SESUAI dengan settingan "Server IP" di masing-masing alat (Client Mode) +TARGET_MAPPING = { + 'flg_gxp1': '10.10.120.73', + 'flg_gxp2': '10.10.120.13', + 'flg_gxp3': '10.10.120.75' +} +# GeneXpert Configuration +# ========================================== +# KONFIGURASI MAPPING TES (DATABASE -> GENEXPERT) +# ========================================== +# Kiri: Nama di kolom 'tes' database Anda +# Kanan: 'Host Test Code' dari Dokumen Word Anda +GENEXPERT_TEST_MAPPING = { + # Mapping untuk IP 10.10.120.75 (Multi-Assay) + "HIV": "HIV-1_VL", # Xpert HIV-1 Viral Load XC Version 3 + "TCM TB": "MTBRIF", # Xpert MTBRIF Assay G4 Version 6 + "TCM TB ULTRA": "MTBRIF_ULTRA2", # Xpert MTBRIF Ultra Version 4 + "TCM TB XDR": "MTB-XDR", # Xpert MTB-XDR Version 1 + "HCV VL": "HCV", # Xpert HCV Viral Load Version 1 + "COVID-19": "COV-2 2", # Xpert Xpress SARS-CoV-2 Version 2 + "17.3.1 TCM COVID-19": "COV-2 2", # Xpert Xpress SARS-CoV-2 Version 2 + "17.3.2 PCR COVID-19": "COV-2 2", # Xpert Xpress SARS-CoV-2 Version 2 + "E.2.5 HCV TCM": "HCV", + "18.1.1 TCM HCV": "HCV", + "18.1.2 TCM HIV VIRAL LOAD": "HIV-1_VL", + "18.1.4 TCM HPV": "HCV", + "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", + "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", + "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR", + "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "1.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "1.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "15.2.1 KULTUR TB MEDIA LJ": "MTB-XDR", + "8.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "9.3.5 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "9.3.6 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "12.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "H.2.5 PEMERIKSAAN KULTUR MYCROBACTERIUM TBC": "MTB-XDR", + "12.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "11.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "10.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "10.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "3.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "15.2.2 KULTUR TB MEDIA MGIT (AUTOMATIC)": "MTB-XDR", + "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", + "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTBRIF", + "12.3.8 TCM TB (GENE EXPERT)": "MTBRIF", + "15.2.3 TCM TB (GENE EXPERT)": "MTBRIF", + "2.3.9 TCM GENE EXPERT": "MTBRIF", + "3.3.8 TCM GENE EXPERT": "MTBRIF", + "3.3.9 TCM MYCOBACTERIUM TUBERCULOSIS": "MTBRIF", + "5.3.9 TCM TBC (GENE EXPERT)": "MTBRIF", + "7.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "8.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "10.3.8 TCM TBC (GENE EXPERT)": "MTBRIF", + "11.3.9 TCM TB (GENE EXPERT)": "MTBRIF", + +} +GENEXPERT_IP_CAPABILITIES = { + "10.10.120.75": ["MTBRIF", "MTBRIF_ULTRA2", "MTB-XDR", "HIV-1_VL", "COV-2 2"], + "10.10.120.13": ["HCV", "HBV"], + "10.10.120.73": ["MTBRIF"] +} +# Default code jika nama tes di database tidak dikenali +DEFAULT_GXP_CODE = "MTBRIF" + +DEVICE_CONFIGS = [ + { + 'port': 'COM6', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 1', + 'protocol': 'serial', 'flag_column': 'flg_vitek1' + }, + { + 'port': 'COM4', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 2', + 'protocol': 'serial', 'flag_column': 'flg_vitek2' + }, + { + 'port': 'COM5', 'baud_rate': 9600, 'device_type': 'bd', 'alat_name': 'BACTEC', + 'protocol': 'serial', 'flag_column': 'flg_bd1' + }, + #BD_MGIT yang di dalam ruangan isolasi + #{ + # 'port': 'COM4', 'baud_rate': 19200, 'device_type': 'bd', 'alat_name': 'MGIT', + # 'protocol': 'serial', 'flag_column': 'flg_bd2' + #}, +] +MYLA_HOST = '10.10.120.89' +MYLA_PORT = 8000 +MYLA_INBOUND_HOST = '0.0.0.0' +MYLA_INBOUND_PORT = 8000 +MYLA_POLL_INTERVAL_SECONDS = 1 +MYLA_CONNECT_RETRY_SECONDS = 5 +MYLA_CONTROL_TIMEOUT_SECONDS = 6 +MYLA_ACK_TIMEOUT_SECONDS = 8 +MYLA_IDLE_LOG_INTERVAL_SECONDS = 30 + +# 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' +ports_lock = threading.Lock() +active_serial_ports = {} + +order_queues = {config['port']: Queue() for config in DEVICE_CONFIGS if config['protocol'] == 'serial'} + +# ========================================== +# 2. DATABASE MODEL +# ========================================== +DATABASE_URL = "postgresql://lismikro:lismikro@10.10.123.193:5002/lismikro" +engine = create_engine(DATABASE_URL, pool_recycle=3600) +SessionLocal = sessionmaker(bind=engine) +Base = declarative_base() + +class Sample(Base): + __tablename__ = 'samples' + id = Column(Integer, primary_key=True) + patient_name = Column(String(100)) + patient_id = Column(String(50)) + sample_id = Column(String(50), unique=True) + test_type = Column(String(100)) + result = Column(Text) + raw_message = Column(Text) + created_at = Column(SqDateTime, default=datetime.datetime.now) + +class SerialOrderQueue(Base): + __tablename__ = 'serial_order_queue' + id = Column(Integer, primary_key=True) + target_port = Column(String(50), nullable=False, index=True) + message_to_send = Column(Text, nullable=False) + status = Column(String(20), default='pending', index=True) + created_at = Column(SqDateTime, default=datetime.datetime.now) + updated_at = Column(SqDateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) + +class LisPhoenix(Base): + __tablename__ = 'lis_phoenix' + id = Column(Integer, primary_key=True) + no_id = Column(String(50)) # Patient ID + seq_no = Column(String(50)) # Isolate ID / Sample ID + rnmpas = Column(String(100)) # Patient Name + tgl_data = Column(SqDate) + rawdt = Column(Text) + organisme = Column(String(100)) + kd_orgm = Column(String(50)) + alat = Column(String(50)) + processed = Column(String(50), nullable=True) + +class LisPhoenixDtl(Base): + __tablename__ = 'lis_phoenix_dtl' + id = Column(Integer, primary_key=True) + seq_no = Column(String(50)) + kd_antibiotik = Column(String(50)) + nm_antibiotik = Column(String(100)) + keterangan = Column(String(50)) + interpretasi = Column(String(10)) # S, I, R + no = Column(Integer) + +class PaslabOrder(Base): + __tablename__ = 'paslab' + urut = Column(Integer, primary_key=True) + rnoreg = Column(String) + nama = Column(String) + norm = Column(String) + rjenis = Column(String) + rtglast = Column(SqDateTime) + alamat = Column(String) + umur = Column(String) + namadok = Column(String) + ruangan = Column(String) + tes = Column(String) + alat = Column(String) + kd_spesimen = Column(String) + nm_spesimen = Column(String) + tgllahir = Column(SqDate) + flg_vitek1 = Column(Boolean, default=False) + flg_vitek2 = Column(Boolean, default=False) + flg_bd1 = Column(Boolean, default=False) + flg_bd2 = Column(Boolean, default=False) + 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) + +Base.metadata.create_all(bind=engine) + +# ========================================== +# 3. HL7 HELPER FUNCTIONS +# ========================================== +def get_flag_by_device(ip_addr): + for flag, ip in TARGET_MAPPING.items(): + if ip == ip_addr: + return flag + return None + +def get_pending_orders(ip_addr): + flag = get_flag_by_device(ip_addr) + if not flag: + return [] + + session = SessionLocal() + try: + q = session.query(PaslabOrder).filter(getattr(PaslabOrder, flag) == False) + return q.all() + finally: + session.close() + +def parse_hl7_segments(hl7_message): + return [segment for segment in str(hl7_message or "").split('\r') if segment] + +def extract_segment(hl7_message, segment_name): + prefix = f"{segment_name}|" + for segment in parse_hl7_segments(hl7_message): + if segment.startswith(prefix): + return segment + return "" + +def parse_genexpert_qpd(qpd_segment): + fields = str(qpd_segment or "").split('|') + query_name = fields[1] if len(fields) > 1 else "" + query_tag = fields[2] if len(fields) > 2 else "" + param_1 = fields[3] if len(fields) > 3 else "" + param_2 = fields[4] if len(fields) > 4 else "" + return { + "query_name": query_name, + "query_tag": query_tag, + "param_1": param_1, + "param_2": param_2, + } + +def parse_astm_records(message_text): + records = [] + for rec in str(message_text or "").split("\r"): + rec = rec.strip() + if not rec: + continue + if rec and rec[0].isdigit(): + rec = rec[1:] + records.append(rec) + return records + +def parse_genexpert_astm_query(message_text): + records = parse_astm_records(message_text) + query = { + "query_sample_id": "", + "query_tag": "", + "raw_records": records, + } + for rec in records: + fields = rec.split("|") + if not fields: + continue + if fields[0] == "H": + query["query_tag"] = fields[4] if len(fields) > 4 else "" + elif fields[0] == "Q": + query["query_sample_id"] = fields[2] if len(fields) > 2 else "" + return query + +def detect_genexpert_message_framing(message_bytes): + raw = message_bytes or b"" + if b"\x1c" in raw or raw.startswith(b"\x0b"): + return "mllp" + # [PERBAIKAN KRITIS]: Harus mendeteksi ETX (\x03) ATAU ETB (\x17) + if b"\x03" in raw or b"\x17" in raw: + return "astm" + return "plain" + +def extract_astm_frame_text(frame_bytes): + raw = frame_bytes or b"" + if not raw.startswith(b"\x02"): # Jika tidak diawali STX, kosongkan + return "" + + etx_pos = raw.find(b"\x03") + etb_pos = raw.find(b"\x17") + + # Logika aman jika kebetulan ada dua-duanya di dalam buffer + if etx_pos != -1 and etb_pos != -1: + end_pos = min(etx_pos, etb_pos) + else: + end_pos = max(etx_pos, etb_pos) + + if end_pos == -1 or end_pos < 2: + return "" + + # Ambil mulai dari index ke-2 (mengabaikan STX dan Frame Number) + text_bytes = raw[2:end_pos] + return text_bytes.decode("latin-1", errors="ignore") + +def resolve_genexpert_assay(order, ip_addr=None): + assay_name = str(getattr(order, "tes", "") or "").strip() + specimen_code = str(getattr(order, "kd_spesimen", "") or "").strip() + assay_code = GENEXPERT_TEST_MAPPING.get(assay_name) + assay_source = "mapping:tes" + + if not assay_code and specimen_code: + assay_code = specimen_code + assay_source = "fallback:kd_spesimen" + + supported_codes = GENEXPERT_IP_CAPABILITIES.get(str(ip_addr or "").strip(), []) if ip_addr else [] + capability_match = True if not supported_codes else assay_code in supported_codes + + if not assay_code: + print( + f"[GENEXPERT-DEBUG] rnoreg={getattr(order, 'rnoreg', '')}, ip={ip_addr}, " + f"tes='{assay_name}', kd_spesimen='{specimen_code}', assay_code=EMPTY" + ) + return None, "none", capability_match + + print( + f"[GENEXPERT-DEBUG] rnoreg={getattr(order, 'rnoreg', '')}, ip={ip_addr}, " + f"tes='{assay_name}', kd_spesimen='{specimen_code}', assay_code='{assay_code}', " + f"assay_source={assay_source}, capability_match={capability_match}" + ) + return assay_code, assay_source, capability_match + +def get_genexpert_query_orders(ip_addr, hl7_msg): + flag = get_flag_by_device(ip_addr) + if not flag: + return [] + + qpd_segment = extract_segment(hl7_msg, "QPD") + qpd = parse_genexpert_qpd(qpd_segment) + param_1 = str(qpd.get("param_1") or "").strip() + param_2 = str(qpd.get("param_2") or "").strip() + + session = SessionLocal() + try: + flag_attr = getattr(PaslabOrder, flag, None) + if flag_attr is None: + return [] + + base_orders = session.query(PaslabOrder).filter( + (flag_attr == False) | (flag_attr == None) + ).order_by(PaslabOrder.urut.asc()).all() + + requested_sample_id = (param_2 or param_1).strip() if (param_2 or param_1) else "" + if requested_sample_id and requested_sample_id.upper() != "ALL": + for order in base_orders: + if str(order.rnoreg or "").strip() != requested_sample_id: + continue + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if not assay_code: + return [] + return [order] + print(f"[GENEXPERT] Tidak ada order untuk sample_id={requested_sample_id} di {ip_addr}") + return [] + + for order in base_orders: + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if assay_code: + return [order] + print(f"[GENEXPERT] Tidak ada order dengan assay valid untuk IP {ip_addr}") + return [] + finally: + session.close() + +def get_genexpert_host_application(ip_addr): + ip_addr = str(ip_addr or "").strip() + host_app = GENEXPERT_HOST_APPLICATION_BY_IP.get(ip_addr, GENEXPERT_HOST_APPLICATION_DEFAULT) + host_app = str(host_app or "").strip() + return host_app or GENEXPERT_HOST_APPLICATION_DEFAULT + +def build_genexpert_response_msh(message_code, incoming_hl7, resp_control_id, ip_addr=None): + timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + msh_fields = extract_segment(incoming_hl7, "MSH").split('|') + sender_app = msh_fields[2] if len(msh_fields) > 2 else "GeneXpert" + sender_fac = msh_fields[3] if len(msh_fields) > 3 else "" + host_app = get_genexpert_host_application(ip_addr) + return f"MSH|^~\\&|{host_app}||{sender_app}|{sender_fac}|{timestamp}||{message_code}|{resp_control_id}|P|2.5|||NE|NE" + +def create_genexpert_ack_j01_response(incoming_hl7, ip_addr=None): + incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" + resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" + msh = build_genexpert_response_msh("ACK^J01", incoming_hl7, resp_control_id, ip_addr=ip_addr) + msa = f"MSA|CA|{incoming_control_id}" + return f"{msh}\r{msa}\r" + +def create_genexpert_ack_r01_response(incoming_hl7, ip_addr=None): + incoming_control_id = extract_msg_control_id(incoming_hl7) or "UNKNOWN" + resp_control_id = f"ACK{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" + msh = build_genexpert_response_msh("ACK^R01", incoming_hl7, resp_control_id, ip_addr=ip_addr) + msa = f"MSA|CA|{incoming_control_id}" + return f"{msh}\r{msa}\r" + +def format_hl7_date(value): + if not value: + return "" + if isinstance(value, datetime.datetime): + return value.strftime("%Y%m%d") + if isinstance(value, datetime.date): + return value.strftime("%Y%m%d") + text = str(value).strip() + digits = re.sub(r"[^0-9]", "", text) + return digits[:8] if len(digits) >= 8 else "" + +def map_hl7_sex(value): + text = str(value or "").strip().upper() + if not text: + return "" + if text.startswith("L") or "LAKI" in text or text == "M": + return "M" + if text.startswith("P") or "PEREM" in text or text == "F": + return "F" + return "" + +def create_genexpert_rsp_z02_response(orders, incoming_hl7, ip_addr=None): + qpd_segment = extract_segment(incoming_hl7, "QPD") + qpd = parse_genexpert_qpd(qpd_segment) + query_tag = qpd.get("query_tag") or (extract_msg_control_id(incoming_hl7) or "UNKNOWN") + incoming_message_type = extract_message_type(incoming_hl7) + query_name = qpd.get("query_name") or "Z03^HOST QUERY" + resp_control_id = f"RSP{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" + + if str(incoming_message_type or "").startswith("QBP^Z03"): + query_name = "Z03^HOST QUERY" + + msh = build_genexpert_response_msh("RSP^Z02", incoming_hl7, resp_control_id, ip_addr=ip_addr) + msa = f"MSA|AA|{query_tag}" + qak = f"QAK|{query_tag}|OK|{query_name}" + segments = [msh, msa, qak] + if str(incoming_message_type or "").startswith("QBP^Z03"): + selected_patient_id = "" + selected_sample_id = "" + if orders: + selected_patient_id = sanitize_astm_field(orders[0].norm or "", max_len=50) + selected_sample_id = sanitize_astm_field(orders[0].rnoreg or "", max_len=50) + segments.append(f"QPD|{query_name}|{query_tag}|{selected_patient_id}|{selected_sample_id}") + elif qpd_segment: + segments.append(qpd_segment) + + for patient_idx, order in enumerate(orders, start=1): + patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=50) + sample_id = sanitize_astm_field(order.rnoreg, max_len=50) + order_ts = ( + getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') + if getattr(order, "rtglast", None) + else datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ) + assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) + if not assay_code: + print(f"[GENEXPERT] Payload order dilewati rnoreg={sample_id} karena assay kosong.") + continue + + print( + f"[GENEXPERT-DEBUG] Build RSP rnoreg={sample_id}, ip={ip_addr}, " + f"patient_id={patient_id}, assay_code={assay_code}, assay_source={assay_source}, " + f"capability_match={capability_match}, query_name='{query_name}', query_tag='{query_tag}', " + "profile='minimal-rsp-z02'" + ) + + segments.append(f"PID|{patient_idx}||{patient_id}") + segments.append(f"ORC|NW|1|||||||{order_ts}") + segments.append(f"OBR|1|||{assay_code}|||||||A") + segments.append("TQ1|||||||||R") + segments.append(f"SPM|1|{sample_id}^||ORH|||||||P") + + return "\r".join(segments) + "\r" + +def create_genexpert_astm_order_message(orders, ip_addr=None, query_tag=""): + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + records = [ + f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{timestamp}" + ] + + for index, order in enumerate(orders, start=1): + patient_id = sanitize_astm_field(order.norm or order.rnoreg, max_len=32) + sample_id = sanitize_astm_field(order.rnoreg, max_len=32) + assay_code, assay_source, capability_match = resolve_genexpert_assay(order, ip_addr) + if not assay_code: + print(f"[GENEXPERT] ASTM payload order dilewati rnoreg={sample_id} karena assay kosong.") + continue + + first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) + first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) + last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) + patient_name = f"{last_name}^{first_name}".strip("^") + sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) + sex = "M" if sex_raw.startswith("L") else ("F" if sex_raw else "") + order_ts = ( + getattr(order, "rtglast", None).strftime('%Y%m%d%H%M%S') + if getattr(order, "rtglast", None) + else timestamp + ) + + print( + f"[GENEXPERT-ASTM-ORDER] rnoreg={sample_id}, ip={ip_addr}, patient_id={patient_id}, " + f"assay_code={assay_code}, assay_source={assay_source}, capability_match={capability_match}" + ) + + records.append(f"P|{index}|{patient_id}||{patient_id}|{patient_name}|||{sex}") + records.append(f"O|1|{sample_id}||^^^{assay_code}|R|{order_ts}|||||||||ORH||||||||||A") + + records.append("L|1|N") + message = "\r".join(records) + "\r" + print(f"[GENEXPERT-ASTM-ORDER] ip={ip_addr}, query_tag={query_tag}, records={len(records)}, visible={_visible_bytes(message.encode('latin-1'))}") + return message + +def send_all_orders(conn, ip_addr, hl7_msg, msg_id, response_framing="mllp"): + orders = get_genexpert_query_orders(ip_addr, hl7_msg) + scheduled_orders = [] + if not orders: + print(f"[GENEXPERT] Tidak ada order pending untuk {ip_addr}") + rsp = create_genexpert_rsp_z02_response([], hl7_msg, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, rsp, response_framing, label="qbp-empty") + return + + qpd_segment = extract_segment(hl7_msg, "QPD") + print( + f"[GENEXPERT-DEBUG] QBP diproses untuk ip={ip_addr}, msg_id={msg_id}, " + f"qpd='{qpd_segment}', selected_rnoreg={[str(order.rnoreg or '').strip() for order in orders]}" + ) + print(f"[GENEXPERT] Mengirim {len(orders)} order ke {ip_addr}") + rsp = create_genexpert_rsp_z02_response(orders, hl7_msg, ip_addr=ip_addr) + first_accnumber = str(orders[0].rnoreg or "").strip() if orders else "" + debug_genexpert_order_message(rsp, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, rsp, response_framing, label=f"qbp-order:{first_accnumber}") + + for order in orders: + print(f"[GENEXPERT] Order ditawarkan ke {ip_addr}: {order.rnoreg}") + scheduled_orders.append({ + "accnumber": str(order.rnoreg or "").strip(), + "register_no": str(order.rnoreg or "").strip(), + "target_ip": ip_addr, + }) + + for scheduled_order in scheduled_orders: + schedule_result_query_for_order( + accnumber=scheduled_order["accnumber"], + register_no=scheduled_order["register_no"], + target_ip=scheduled_order["target_ip"], + ) + +def send_all_orders_astm(conn, ip_addr, astm_msg, response_framing="astm"): + query = parse_genexpert_astm_query(astm_msg) + requested_sample_id = str(query.get("query_sample_id") or "").strip() + query_tag = str(query.get("query_tag") or "").strip() + flag = get_flag_by_device(ip_addr) + if not flag: + print(f"[GENEXPERT] ASTM query diabaikan, flag untuk {ip_addr} tidak ditemukan.") + return + + session = SessionLocal() + try: + flag_attr = getattr(PaslabOrder, flag, None) + if flag_attr is None: + print(f"[GENEXPERT] ASTM query diabaikan, atribut flag {flag} tidak ada.") + return + + base_orders = session.query(PaslabOrder).filter( + (flag_attr == False) | (flag_attr == None) + ).order_by(PaslabOrder.urut.asc()).all() + + selected_orders = [] + if requested_sample_id and requested_sample_id.upper() != "ALL": + for order in base_orders: + if str(order.rnoreg or "").strip() != requested_sample_id: + continue + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if assay_code: + selected_orders = [order] + break + else: + for order in base_orders: + assay_code, _, _ = resolve_genexpert_assay(order, ip_addr) + if assay_code: + selected_orders = [order] + break + + print( + f"[GENEXPERT-ASTM-QUERY] ip={ip_addr}, query_tag={query_tag}, requested_sample_id='{requested_sample_id}', " + f"selected_rnoreg={[str(order.rnoreg or '').strip() for order in selected_orders]}" + ) + + if not selected_orders: + reply = f"H|\\^&|||{sanitize_astm_field(get_genexpert_host_application(ip_addr), max_len=20)}|||||GeneXpert Host||P|1394-97|{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}\rL|1|N\r" + send_genexpert_response(conn, ip_addr, reply, response_framing, label="astm-q-empty") + return + + reply = create_genexpert_astm_order_message(selected_orders, ip_addr=ip_addr, query_tag=query_tag) + send_genexpert_response(conn, ip_addr, reply, response_framing, label=f"astm-q-order:{selected_orders[0].rnoreg}") + for order in selected_orders: + print(f"[GENEXPERT] Order ASTM ditawarkan ke {ip_addr}: {order.rnoreg}") + finally: + session.close() + +def extract_msg_control_id(hl7_message): + try: + segments = hl7_message.split('\r') + msh = segments[0].split('|') + if len(msh) > 9: + return msh[9].strip() + return None + except: + return None + +def extract_message_type(hl7_message): + try: + segments = hl7_message.split('\r') + msh = segments[0].split('|') + if len(msh) > 8: + return msh[8].strip() + return "" + except: + return "" + +def build_hl7_preview(hl7_message, max_segments=4): + try: + segments = [segment.strip() for segment in str(hl7_message or "").split('\r') if segment.strip()] + preview = " | ".join(segments[:max_segments]) + return preview[:800] + except Exception: + return str(hl7_message or "")[:800] + +def log_genexpert_hl7(direction, ip_addr, hl7_message, label=""): + message_type = extract_message_type(hl7_message) or "UNKNOWN" + control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" + suffix = f", label={label}" if label else "" + preview = build_hl7_preview(hl7_message) + logging.info( + f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" + ) + print( + f"[GENEXPERT-HL7-{direction}] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={preview}" + ) + +def log_genexpert_hl7_full(direction, ip_addr, hl7_message, label=""): + message_type = extract_message_type(hl7_message) or "UNKNOWN" + control_id = extract_msg_control_id(hl7_message) or "UNKNOWN" + suffix = f", label={label}" if label else "" + payload = str(hl7_message or "").replace("\r", "\\r\n") + logging.info( + f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" + ) + print( + f"[GENEXPERT-HL7-{direction}-FULL] ip={ip_addr}, type={message_type}, control_id={control_id}{suffix}, payload={payload}" + ) + +def frame_genexpert_response(hl7_message, framing): + message = str(hl7_message or "") + if framing == "astm": + frame_body = f"1{message}\x03" + chk = calculate_astm_checksum(frame_body) + return f"\x02{frame_body}{chk}\r\n".encode("latin-1") + if framing == "mllp": + return f"\x0b{message}\x1c\r".encode("utf-8") + return message.encode("utf-8") + +def build_genexpert_astm_frames(hl7_message, max_text_bytes=240): + message = str(hl7_message or "") + text_bytes = message.encode("latin-1") + chunks = [text_bytes[i:i + max_text_bytes] for i in range(0, len(text_bytes), max_text_bytes)] or [b""] + frames = [] + frame_number = 1 + + for index, chunk in enumerate(chunks): + is_last = index == len(chunks) - 1 + terminator = b"\x03" if is_last else b"\x17" + frame_no_byte = str(frame_number).encode("ascii") + frame_core_bytes = frame_no_byte + chunk + terminator + checksum = calculate_astm_checksum(frame_core_bytes.decode("latin-1", errors="ignore")).encode("ascii") + full_frame = b"\x02" + frame_core_bytes + checksum + b"\x0D\x0A" + frames.append({ + "frame_number": frame_number, + "is_last": is_last, + "chunk_len": len(chunk), + "payload": full_frame, + "checksum": checksum.decode("ascii", errors="ignore"), + }) + frame_number = (frame_number + 1) % 8 + return frames + +def recv_genexpert_control_char(conn, timeout_seconds=5): + previous_timeout = conn.gettimeout() + try: + conn.settimeout(timeout_seconds) + return conn.recv(1) + finally: + conn.settimeout(previous_timeout) + +def send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=""): + try: + frames = build_genexpert_astm_frames(hl7_message) + print(f"[GENEXPERT-ASTM-TX] ip={ip_addr}, label={label}, total_frames={len(frames)}, mode=astm_active") + conn.sendall(b"\x05") + log_genexpert_handshake(ip_addr, "ENQ-TX", detail=f"label={label}") + + ctrl = recv_genexpert_control_char(conn, timeout_seconds=5) + if ctrl == b"\x06": + log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=pre-frame,label={label}") + elif ctrl == b"\x15": + log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=pre-frame,label={label}") + return False + else: + log_genexpert_handshake(ip_addr, "CTRL-RX", detail=f"phase=pre-frame,label={label},hex={ctrl.hex() if ctrl else 'timeout'}") + return False + + for frame in frames: + payload = frame["payload"] + debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=f"{label}:frame{frame['frame_number']}") + conn.sendall(payload) + log_genexpert_handshake( + ip_addr, + "FRAME-TX", + detail=( + f"label={label},frame_no={frame['frame_number']},bytes={len(payload)}," + f"chunk_len={frame['chunk_len']},last={frame['is_last']}" + ), + ) + + ctrl = recv_genexpert_control_char(conn, timeout_seconds=15) + if ctrl == b"\x06": + log_genexpert_handshake(ip_addr, "ACK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") + continue + if ctrl == b"\x15": + log_genexpert_handshake(ip_addr, "NAK-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") + conn.sendall(b"\x04") + log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=nak,frame_no={frame['frame_number']}") + return False + if ctrl == b"\x04": + log_genexpert_handshake(ip_addr, "EOT-RX", detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']}") + conn.sendall(b"\x04") + log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after-peer-eot,frame_no={frame['frame_number']}") + return False + + log_genexpert_handshake( + ip_addr, + "CTRL-RX", + detail=f"phase=post-frame,label={label},frame_no={frame['frame_number']},hex={ctrl.hex() if ctrl else 'timeout'}", + ) + conn.sendall(b"\x04") + log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label},after=unexpected,frame_no={frame['frame_number']}") + return False + + conn.sendall(b"\x04") + log_genexpert_handshake(ip_addr, "EOT-TX", detail=f"label={label}") + return True + except Exception as exc: + log_genexpert_handshake(ip_addr, "ASTM-SEND-ERROR", detail=f"label={label},error={exc}") + return False + +def get_genexpert_response_mode(ip_addr): + ip_addr = str(ip_addr or "").strip() + mode = GENEXPERT_RESPONSE_MODE_BY_IP.get(ip_addr, GENEXPERT_RESPONSE_MODE_DEFAULT) + if mode not in {"hl7_passive", "astm_active"}: + mode = GENEXPERT_RESPONSE_MODE_DEFAULT + return mode + +def send_genexpert_response(conn, ip_addr, hl7_message, framing, label=""): + log_genexpert_hl7("OUT", ip_addr, hl7_message, label=label) + log_genexpert_hl7_full("OUT", ip_addr, hl7_message, label=label) + response_mode = get_genexpert_response_mode(ip_addr) + if framing == "astm" and response_mode == "astm_active": + print( + f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " + f"response_mode={response_mode}, label={label}, bytes=multi-frame" + ) + send_genexpert_astm_frame(conn, ip_addr, hl7_message, label=label) + return + + payload = frame_genexpert_response(hl7_message, framing) + if framing == "astm": + debug_genexpert_astm_frame(ip_addr, payload, direction="TX", label=label) + else: + print( + f"[GENEXPERT-FRAME-TX] ip={ip_addr}, label={label}, framing={framing}, " + f"hex={_hex_bytes(payload)}, visible={_visible_bytes(payload)}" + ) + print( + f"[GENEXPERT-DEBUG] Send response ip={ip_addr}, framing={framing}, " + f"response_mode={response_mode}, label={label}, bytes={len(payload)}" + ) + conn.sendall(payload) + +def send_genexpert_transport_ack(conn, ip_addr, framing, reason="frame-received"): + if framing != "astm": + return + try: + conn.sendall(b"\x06") + print(f"[GENEXPERT-DEBUG] Transport ACK sent ip={ip_addr}, framing={framing}, reason={reason}") + except Exception as exc: + print(f"[GENEXPERT-DEBUG] Transport ACK gagal ip={ip_addr}, framing={framing}, reason={reason}, error={exc}") + +def log_genexpert_handshake(ip_addr, event, detail=""): + suffix = f", detail={detail}" if detail else "" + print(f"[GENEXPERT-HANDSHAKE] ip={ip_addr}, event={event}{suffix}") + +def debug_genexpert_astm_frame(ip_addr, frame_bytes, direction="RX", label=""): + raw = frame_bytes or b"" + suffix = f", label={label}" if label else "" + if not raw: + print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, empty-frame") + return + + detail_parts = [ + f"len={len(raw)}", + f"hex={_hex_bytes(raw)}", + f"visible={_visible_bytes(raw)}", + ] + + if raw.startswith(b"\x02") and len(raw) >= 6: + frame_no = raw[1:2] + etx_pos = raw.find(b"\x03") + etb_pos = raw.find(b"\x17") + end_pos = etx_pos if etx_pos != -1 else etb_pos + end_name = "ETX" if etx_pos != -1 else ("ETB" if etb_pos != -1 else "NONE") + if end_pos != -1 and len(raw) >= end_pos + 5: + checksum_rx = raw[end_pos + 1:end_pos + 3] + trailer = raw[end_pos + 3:end_pos + 5] + checksum_basis = raw[1:end_pos + 1].decode("latin-1", errors="ignore") + checksum_calc = calculate_astm_checksum(checksum_basis).encode("ascii") + checksum_ok = checksum_rx.upper() == checksum_calc.upper() + trailer_ok = trailer == b"\r\n" + detail_parts.extend([ + f"frame_no={frame_no.decode('ascii', errors='ignore')}", + f"terminator=<{end_name}>", + f"checksum_rx={checksum_rx.decode('ascii', errors='ignore')}", + f"checksum_calc={checksum_calc.decode('ascii', errors='ignore')}", + f"checksum_ok={checksum_ok}", + f"trailer={_visible_bytes(trailer)}", + f"trailer_ok={trailer_ok}", + ]) + else: + detail_parts.append(f"terminator=<{end_name}>") + + print(f"[GENEXPERT-ASTM-{direction}] ip={ip_addr}{suffix}, " + ", ".join(detail_parts)) + +def debug_genexpert_order_message(hl7_message, ip_addr=None): + segments = parse_hl7_segments(hl7_message) + current_pid = "" + current_patient_name = "" + order_index = 0 + + for segment in segments: + fields = segment.split("|") + seg_type = fields[0] if fields else "" + + if seg_type == "PID": + current_pid = fields[3] if len(fields) > 3 else "" + current_patient_name = fields[5] if len(fields) > 5 else "" + dob = fields[7] if len(fields) > 7 else "" + sex = fields[8] if len(fields) > 8 else "" + address = fields[11] if len(fields) > 11 else "" + print( + f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=PID, patient_id='{current_pid}', " + f"patient_name='{current_patient_name}', dob='{dob}', sex='{sex}', " + f"address='{address}', mode='minimal', raw='{segment}'" + ) + elif seg_type == "ORC": + order_index = fields[2] if len(fields) > 2 else "" + order_time = fields[9] if len(fields) > 9 else "" + print( + f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=ORC, placer_order='{order_index}', " + f"order_time='{order_time}', patient_id='{current_pid}', " + f"patient_name='{current_patient_name}', raw='{segment}'" + ) + elif seg_type == "OBR": + assay_code = fields[4] if len(fields) > 4 else "" + result_status = fields[11] if len(fields) > 11 else "" + assay_parts = assay_code.split("^") + assay_id = assay_parts[0] if len(assay_parts) > 0 else "" + assay_name = assay_parts[1] if len(assay_parts) > 1 else "" + print( + f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=OBR, placer_order='{order_index}', " + f"assay_code='{assay_id}', assay_name='{assay_name}', result_status='{result_status}', " + f"patient_id='{current_pid}', patient_name='{current_patient_name}', raw='{segment}'" + ) + elif seg_type == "TQ1": + priority = fields[9] if len(fields) > 9 else "" + print( + f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=TQ1, placer_order='{order_index}', " + f"priority='{priority}', patient_id='{current_pid}', " + f"patient_name='{current_patient_name}', raw='{segment}'" + ) + elif seg_type == "SPM": + specimen_id = fields[2] if len(fields) > 2 else "" + specimen_type = fields[4] if len(fields) > 4 else "" + print( + f"[GENEXPERT-ORDER-DEBUG] ip={ip_addr}, seg=SPM, placer_order='{order_index}', " + f"specimen_id='{specimen_id}', specimen_type='{specimen_type}', patient_id='{current_pid}', " + f"patient_name='{current_patient_name}', raw='{segment}'" + ) + +def build_genexpert_result_query(accnumber, msg_control_id): + ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + # Query hasil berbasis accession number di QRD-8. + query_msg = ( + f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ts}||QRY^Q02|{msg_control_id}|P|2.5\r" + f"QRD|{ts}|R|I|{msg_control_id}|||1^RD|{accnumber}|OTH|||T\r" + ) + return query_msg + +def get_active_genexpert_ips(): + with connection_lock: + return list(active_genexpert_connections.keys()) + +def clear_genexpert_inflight_for_ip(ip_addr, reason="cleared"): + ip_addr = str(ip_addr or "").strip() + if not ip_addr: + return False + + with genexpert_query_inflight_lock: + state = genexpert_query_inflight_by_ip.pop(ip_addr, None) + + if state: + logging.info( + f"[GENEXPERT-QUERY] Clear inflight ip={ip_addr}, reason={reason}, accnumber={state.get('accnumber')}" + ) + print( + f"[GENEXPERT-QUERY] Clear inflight ip={ip_addr}, reason={reason}, accnumber={state.get('accnumber')}" + ) + return True + return False + +def stop_all_scheduled_result_queries(reason="no-active-genexpert"): + with scheduled_result_query_lock: + if not scheduled_result_queries: + return 0 + + stopped_accnumbers = list(scheduled_result_queries.keys()) + for accnumber in stopped_accnumbers: + state = scheduled_result_queries.get(accnumber) + if not state: + continue + state["status"] = reason + stop_event = state.get("stop_event") + if stop_event: + stop_event.set() + + scheduled_result_queries.clear() + + logging.info( + f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" + ) + print( + f"[GENEXPERT-SCHEDULER] Stop semua jadwal, reason={reason}, total={len(stopped_accnumbers)}" + ) + with genexpert_query_inflight_lock: + genexpert_query_inflight_by_ip.clear() + return len(stopped_accnumbers) + +def stop_scheduled_result_query(accnumber, reason="completed"): + accnumber = str(accnumber or "").strip() + if not accnumber: + return False + + with scheduled_result_query_lock: + state = scheduled_result_queries.get(accnumber) + if not state: + return False + state["status"] = reason + stop_event = state.get("stop_event") + if stop_event: + stop_event.set() + scheduled_result_queries.pop(accnumber, None) + + logging.info(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") + print(f"[GENEXPERT-SCHEDULER] Stop accnumber={accnumber}, reason={reason}") + return True + +def scheduled_result_query_worker(accnumber): + if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: + stop_scheduled_result_query(accnumber, reason="disabled") + return + + while True: + if not get_active_genexpert_ips(): + stop_all_scheduled_result_queries(reason="no-active-genexpert") + return + + with scheduled_result_query_lock: + state = scheduled_result_queries.get(accnumber) + if not state: + return + stop_event = state["stop_event"] + target_ip = state.get("target_ip") + register_no = state.get("register_no") or accnumber + attempt = int(state.get("attempt", 0)) + created_at = state.get("created_at") or datetime.datetime.now() + max_duration_seconds = max( + int(state.get("max_duration_seconds", GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS)), + 1 + ) + age_seconds = max(int((datetime.datetime.now() - created_at).total_seconds()), 0) + if age_seconds >= max_duration_seconds: + state["status"] = "expired" + state["expired_at"] = datetime.datetime.now() + state["age_seconds"] = age_seconds + stop_event.set() + scheduled_result_queries.pop(accnumber, None) + logging.info( + f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " + f"age={age_seconds}s, max_duration={max_duration_seconds}s" + ) + print( + f"[GENEXPERT-SCHEDULER] Expired accnumber={accnumber}, " + f"age={age_seconds}s, max_duration={max_duration_seconds}s" + ) + return + next_delay = max( + int( + state.get( + "initial_delay_seconds" if attempt == 0 else "interval_seconds", + GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS if attempt == 0 else GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS + ) + ), + 1 + ) + + if stop_event.wait(next_delay): + return + + with scheduled_result_query_lock: + state = scheduled_result_queries.get(accnumber) + if not state: + return + state["attempt"] = int(state.get("attempt", 0)) + 1 + state["last_requested_at"] = datetime.datetime.now() + attempt_no = state["attempt"] + + print( + f"[GENEXPERT-SCHEDULER] Trigger query hasil accnumber={accnumber}, " + f"register_no={register_no}, target_ip={target_ip}, attempt={attempt_no}" + ) + + result = trigger_result_query_to_genexpert( + accnumber=accnumber, + register_no=register_no, + target_ip=target_ip, + wait_seconds=0, + ) + + with scheduled_result_query_lock: + state = scheduled_result_queries.get(accnumber) + if not state: + return + state["last_result"] = result + if result.get("ok"): + state["last_successful_query_at"] = datetime.datetime.now() + else: + state["last_error"] = result.get("message") + +def schedule_result_query_for_order( + accnumber, + register_no, + target_ip=None, + initial_delay_seconds=GENEXPERT_RESULT_QUERY_INITIAL_DELAY_SECONDS, + interval_seconds=GENEXPERT_RESULT_QUERY_INTERVAL_SECONDS, + max_duration_seconds=GENEXPERT_RESULT_QUERY_MAX_DURATION_SECONDS, +): + if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: + print( + f"[GENEXPERT-SCHEDULER] Nonaktif. Jadwal query hasil dilewati untuk accnumber={accnumber}" + ) + return False + + accnumber = str(accnumber or "").strip() + register_no = str(register_no or accnumber).strip() + target_ip = str(target_ip or "").strip() or None + + if not accnumber: + print("[GENEXPERT-SCHEDULER] Jadwal query hasil dilewati karena accnumber kosong.") + return False + + with scheduled_result_query_lock: + existing = scheduled_result_queries.get(accnumber) + if existing: + existing["register_no"] = register_no + existing["target_ip"] = target_ip + existing["initial_delay_seconds"] = initial_delay_seconds + existing["interval_seconds"] = interval_seconds + existing["max_duration_seconds"] = max_duration_seconds + existing["status"] = "active" + print(f"[GENEXPERT-SCHEDULER] Jadwal sudah aktif untuk accnumber={accnumber}") + return True + + stop_event = threading.Event() + worker = threading.Thread( + target=scheduled_result_query_worker, + args=(accnumber,), + name=f"GeneXpertResultQuery-{accnumber}", + daemon=True, + ) + scheduled_result_queries[accnumber] = { + "register_no": register_no, + "target_ip": target_ip, + "initial_delay_seconds": initial_delay_seconds, + "interval_seconds": interval_seconds, + "max_duration_seconds": max_duration_seconds, + "attempt": 0, + "status": "active", + "created_at": datetime.datetime.now(), + "stop_event": stop_event, + "thread_name": worker.name, + } + + worker.start() + print( + f"[GENEXPERT-SCHEDULER] Jadwal dibuat accnumber={accnumber}, " + f"register_no={register_no}, target_ip={target_ip}, " + f"initial_delay={initial_delay_seconds}s, interval={interval_seconds}s, " + f"max_duration={max_duration_seconds}s" + ) + return True + +def trigger_result_query_to_genexpert(accnumber, register_no, target_ip=None, wait_seconds=20): + if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: + return { + "ok": False, + "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", + } + + active_ips = get_active_genexpert_ips() + if not active_ips: + return {"ok": False, "message": "Tidak ada koneksi GeneXpert aktif."} + + if target_ip: + target_ips = [target_ip] if target_ip in active_ips else [] + if not target_ips: + return {"ok": False, "message": f"Koneksi GeneXpert {target_ip} tidak aktif."} + else: + target_ips = active_ips + + ts = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + msg_control_id = f"LISQRY{ts}{int(time.time() * 1000) % 1000:03d}" + query_message = build_genexpert_result_query(accnumber, msg_control_id) + mllp_payload = f"\x0b{query_message}\x1c\r".encode("utf-8") + + pending_event = threading.Event() + with pending_query_lock: + pending_result_queries[accnumber] = { + "register_no": register_no, + "target_ips": list(target_ips), + "msg_control_id": msg_control_id, + "requested_at": datetime.datetime.now(), + "event": pending_event, + "status": "requested", + } + + sent_ips = [] + failed_ips = [] + for ip in target_ips: + now = datetime.datetime.now() + with genexpert_query_inflight_lock: + inflight = genexpert_query_inflight_by_ip.get(ip) + if inflight: + inflight_age = max( + (now - inflight.get("requested_at", now)).total_seconds(), + 0 + ) + if inflight_age < GENEXPERT_RESULT_QUERY_INFLIGHT_TIMEOUT_SECONDS: + failed_ips.append({ + "ip": ip, + "error": f"query-inflight:{inflight.get('accnumber')}", + }) + print( + f"[GENEXPERT-QUERY] Skip query accnumber={accnumber} ke {ip} " + f"karena masih menunggu accnumber={inflight.get('accnumber')} " + f"({int(inflight_age)}s)" + ) + continue + genexpert_query_inflight_by_ip.pop(ip, None) + + with connection_lock: + conn = active_genexpert_connections.get(ip) + if not conn: + failed_ips.append({"ip": ip, "error": "connection-not-active"}) + continue + try: + log_genexpert_hl7("OUT", ip, query_message, label=f"result-query:{accnumber}") + log_genexpert_hl7_full("OUT", ip, query_message, label=f"result-query:{accnumber}") + conn.sendall(mllp_payload) + with genexpert_query_inflight_lock: + genexpert_query_inflight_by_ip[ip] = { + "accnumber": accnumber, + "register_no": register_no, + "requested_at": now, + "msg_control_id": msg_control_id, + } + sent_ips.append(ip) + print(f"[GENEXPERT-QUERY] Kirim query hasil accnumber={accnumber} ke {ip}") + except Exception as e: + failed_ips.append({"ip": ip, "error": str(e)}) + clear_genexpert_inflight_for_ip(ip, reason="send-failed") + + if not sent_ips: + with pending_query_lock: + pending_result_queries.pop(accnumber, None) + return {"ok": False, "message": "Gagal kirim query ke semua GeneXpert aktif.", "failures": failed_ips} + + if wait_seconds and wait_seconds > 0: + pending_event.wait(wait_seconds) + with pending_query_lock: + state = pending_result_queries.get(accnumber) + if state and state.get("status") == "found": + pending_result_queries.pop(accnumber, None) + return { + "ok": True, + "message": "Hasil ditemukan dan disimpan ke LisPhoenix.", + "target_ips": sent_ips, + "accnumber": accnumber, + "source_ip": state.get("source_ip"), + } + # Timeout/hasil belum masuk, biarkan state tetap ada agar response telat tetap bisa diproses. + return { + "ok": True, + "message": "Query terkirim. Menunggu hasil dari GeneXpert.", + "target_ips": sent_ips, + "accnumber": accnumber, + "failures": failed_ips, + } + + return { + "ok": True, + "message": "Query terkirim.", + "target_ips": sent_ips, + "accnumber": accnumber, + "failures": failed_ips, + } + +@app.route("/api/genexpert/query-result", methods=["POST"]) +def api_query_genexpert_result(): + if not GENEXPERT_ENABLE_RESULT_QUERY_SCHEDULER: + return jsonify({ + "ok": False, + "message": "Mode GeneXpert pasif aktif. Host hanya menjawab request dari alat.", + }), 409 + + payload = request.get_json(silent=True) or {} + accnumber = str(payload.get("accnumber") or "").strip() + register_no = str(payload.get("register_no") or payload.get("nomor_register") or "").strip() + target_ip = str(payload.get("target_ip") or "").strip() or None + + try: + wait_seconds = int(payload.get("wait_seconds", 20)) + except Exception: + wait_seconds = 20 + + if not accnumber: + return jsonify({"ok": False, "message": "Field 'accnumber' wajib diisi."}), 400 + if not register_no: + return jsonify({"ok": False, "message": "Field 'register_no' (nomor register pasien) wajib diisi."}), 400 + + result = trigger_result_query_to_genexpert( + accnumber=accnumber, + register_no=register_no, + target_ip=target_ip, + wait_seconds=wait_seconds, + ) + status_code = 200 if result.get("ok") else 409 + return jsonify(result), status_code + +def parse_hl7_result(conn, msg_id, hl7_message, device_name="GeneXpert"): + session = SessionLocal() + clean_hl7 = hl7_message + try: + # 1. Bersihkan dan Split Pesan + # HL7 dipisahkan oleh \r (Carriage Return) + if "MSH|" not in hl7_message: + logging.warning("[HL7] Data tidak mengandung segmen MSH valid.") + print("[HL7] Data tidak mengandung segmen MSH valid.") + return + start_idx = hl7_message.find("MSH|") + hl7_message = hl7_message[start_idx:] + + segments = hl7_message.strip().split('\r') + + # Variabel penampung + sample_id = "" # no_id + patient_id = "" # seq_no + patient_name = "" # rnmpas + result_date = None # tgl_data + results_list = [] # untuk organisme + message_type = "" + + # Waktu default jika tidak ada di pesan + result_date = datetime.datetime.now() + + # 2. Loop setiap segmen + for segment in segments: + fields = segment.split('|') + if not fields: continue + + seg_type = fields[0] + + # --- MSH (Header) --- + if seg_type == 'MSH': + if len(fields) > 8 and fields[8]: + message_type = fields[8].strip() + # Ambil tanggal pesan (Field 7) Format: YYYYMMDDHHMMSS + if len(fields) > 6 and fields[6]: + try: + raw_date = fields[6][:14] # Ambil 14 digit pertama + result_date = datetime.datetime.strptime(raw_date, "%Y%m%d%H%M%S") + except ValueError: + pass # Gunakan default datetime.now() jika format salah + + # --- PID (Patient ID) --- + elif seg_type == 'PID': + # PID|3 = Patient ID (seq_no) + if len(fields) > 3: + patient_id = fields[3].replace('^', '') + + # PID|5 = Patient Name (rnmpas) + if len(fields) > 5: + # Ganti caret ^ dengan spasi (Family^Name -> Family Name) + patient_name = fields[5].replace('^', ' ').strip() + + # --- OBR (Observation Request - Info Sample) --- + elif seg_type == 'OBR': + # OBR|2 atau OBR|3 biasanya berisi Sample ID / Accession No + # Kita coba ambil field 3 (Filler Order Number) dulu, kalau kosong field 2 + if len(fields) > 3 and fields[3]: + sample_id = fields[3].replace('^', '') + elif len(fields) > 2 and fields[2]: + sample_id = fields[2].replace('^', '') + + # --- OBX (Observation Result - Hasil Tes) --- + elif seg_type == 'OBX': + # OBX|3 = Test Name/Code (Misal: MTB, RIF) + # OBX|5 = Result Value (Misal: DETECTED, NOT DETECTED) + if len(fields) > 5: + test_name = fields[3].split('^')[1] if '^' in fields[3] else fields[3] + test_val = fields[5].replace('^', ' ') + + # Gabungkan nama tes dan hasil + # Contoh: "MTB: DETECTED" + results_list.append(f"{test_name}: {test_val}") + + # 3. Gabungkan semua hasil OBX menjadi satu string + if not results_list: + final_result = "No Result Found" + else: + final_result = "; ".join(results_list) + + # 4. Validasi Data Penting + if not sample_id: + if str(message_type).startswith("ORU^"): + source_ip = "" + try: + source_ip = str(conn.getpeername()[0]).strip() + except Exception: + source_ip = str(device_name).replace("GeneXpert-", "").replace(",", " ").strip() + + if source_ip: + logging.info(f"[HL7 Parser] ORU tanpa sample_id dari {source_ip}. Anggap sebagai request order.") + print(f"[HL7 Parser] ORU tanpa sample_id dari {source_ip}. Anggap sebagai request order.") + send_all_orders(conn, source_ip, clean_hl7, msg_id) + else: + logging.warning("[HL7 Parser] ORU tanpa sample_id diterima, tetapi IP sumber tidak dapat diidentifikasi.") + print("[HL7 Parser] ORU tanpa sample_id diterima, tetapi IP sumber tidak dapat diidentifikasi.") + else: + logging.info(f"[HL7 Parser] Pesan {message_type or 'UNKNOWN'} tanpa sample_id diabaikan.") + print(f"[HL7 Parser] Pesan {message_type or 'UNKNOWN'} tanpa sample_id diabaikan.") + return + + mapped_no_id = sample_id + mapped_seq_no = patient_id + mapped_alat = device_name + pending_event = None + + with pending_query_lock: + pending = pending_result_queries.get(sample_id) + if pending: + source_ip = str(device_name).replace("GeneXpert-", "").strip() + mapped_no_id = pending.get("register_no") or sample_id + mapped_seq_no = sample_id + mapped_alat = f"GeneXpert-{source_ip}" if source_ip else device_name + pending["status"] = "found" + pending["source_ip"] = source_ip + pending["response_at"] = datetime.datetime.now() + pending_event = pending.get("event") + clear_genexpert_inflight_for_ip(source_ip, reason="result-received") + + source_ip = "" + try: + source_ip = str(conn.getpeername()[0]).strip() + except Exception: + source_ip = str(device_name).replace("GeneXpert-", "").replace(",", " ").strip() + + if source_ip: + flag_name = get_flag_by_device(source_ip) + if flag_name: + order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == sample_id).first() + if order: + setattr(order, flag_name, True) + print(f"[GENEXPERT] Hasil diterima, set {flag_name}=TRUE untuk {sample_id}") + + print(f"[HL7 Parser] Menyimpan hasil untuk Sample: {sample_id}") + + # 5. Simpan ke Database + new_data = LisPhoenix( + no_id=mapped_no_id, + seq_no=mapped_seq_no, + rnmpas=patient_name, + tgl_data=result_date, + rawdt=hl7_message, + organisme=final_result, + alat=mapped_alat + ) + + session.add(new_data) + session.commit() + print(f"[DB] Berhasil simpan ke LisPhoenix: {sample_id}") + stop_scheduled_result_query(sample_id, reason="result-received") + if pending_event: + pending_event.set() + + except Exception as e: + logging.error(f"[HL7 Parser] Error menyimpan data: {e}") + print(f"[HL7 Parser] Error menyimpan data: {e}") + session.rollback() + finally: + session.close() + +def create_hl7_dsr_response(order, msg_control_id, qrd_segment): + """ + Membuat pesan balasan DSR^Q03 (Data Response) untuk GeneXpert. + """ + timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + + # --- 1. HEADER (MSH) --- + # Field 9 (Message Type) adalah DSR^Q03 + # Field 10 (Control ID) kita generate baru + # Field 6 (Receiving Fac) harusnya GeneXpert + resp_control_id = f"RESP{timestamp}" + msh = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{timestamp}||DSR^Q03|{resp_control_id}|P|2.5" + + # --- 2. ACKNOWLEDGEMENT (MSA) --- + # AA = Application Accept (Kita mengerti pertanyaannya) + # msg_control_id = ID dari pesan QRY yang dikirim GeneXpert (Supaya dia tahu ini jawaban untuk pertanyaan yg mana) + msa = f"MSA|AA|{msg_control_id}" + + # --- 3. QUERY DEFINITION (QRD) --- + # Kita kembalikan segmen QRD yang dikirim alat (Echo back) + # qrd_segment harus string raw dari pesan masuk + qrd = qrd_segment + + # --- 4. QUERY RESPONSE STATUS (QRF) --- + # Opsional, tapi baik untuk konfirmasi + qrf = f"QRF|LIS|{timestamp}||||" + + # --- 5. DATA PASIEN & ORDER (Jika Order Ditemukan) --- + if order: + # Mapping Data + sample_id = str(order.rnoreg) + pid_norm = order.norm if order.norm else "" + 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() + pid_gender = 'M' if 'LAKI' in raw_gender or raw_gender == 'L' else 'F' + + # Test Code (Pakai Mapping yang tadi kita buat) + nama_tes_db = str(order.tes).strip() if order.tes else "" + test_code = GENEXPERT_TEST_MAPPING.get(nama_tes_db, DEFAULT_GXP_CODE) # Default: MTBRIF + + # Segmen Data + # DSP/PID/ORC/OBR tergantung setting alat. + # 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{pv1}\r{orc}\r{obr}\r" + + else: + # Jika TIDAK ADA ORDER (Not Found) + # Kita kirim QAK (Query Acknowledge) dengan status NF (Not Found) di QRF/QAK + # Atau cukup kirim MSA|AA tapi tanpa segmen Order + return f"{msh}\r{msa}\r{qrd}\r{qrf}\r" + +def parse_myla_result(hl7_message, device_name="MYLA"): + """ + Parser khusus untuk HL7 dari bioMérieux MYLA (Kalibrasi V4.9). + Menangani hasil BACT/ALERT (Kultur Darah) dan VITEK (Identifikasi & AST). + """ + session = SessionLocal() + try: + segments = hl7_message.strip().split('\r') + + sample_id = None + patient_id = "" + patient_name = "" + result_date = datetime.datetime.now() + + kultur_id = [] + ast_results = [] + + for segment in segments: + fields = segment.split('|') + if not fields: continue + seg_type = fields[0] + + if seg_type == 'MSH': + if len(fields) > 6 and fields[6]: + try: + result_date = datetime.datetime.strptime(fields[6][:14], "%Y%m%d%H%M%S") + except: pass + + elif seg_type == 'PID': + if len(fields) > 3: patient_id = fields[3].replace('^', '') + if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() + + elif seg_type == 'OBR': + 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': + if len(fields) > 5: + test_param = fields[3].split('^')[1] if '^' in fields[3] else fields[3] + + 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].split('^')[0].strip().upper() + + if interpretation in ['S', 'I', 'R', 'NS']: + ast_results.append(f"{test_param}: {result_val} ({interpretation})") + else: + kultur_id.append(f"{test_param}: {result_val}") + + final_results = [] + if kultur_id: + final_results.append("ID: " + ", ".join(kultur_id)) + if ast_results: + final_results.append("AST: " + "; ".join(ast_results)) + + final_result_str = " | ".join(final_results) if final_results else "No Data" + + if not sample_id: + sample_id = f"ERR_MYLA_{datetime.datetime.now().strftime('%H%M%S')}" + final_result_str = f"[NO_ID] {final_result_str}" + + + new_entry = LisPhoenix( + no_id=sample_id, + seq_no=patient_id, + rnmpas=patient_name, + tgl_data=result_date, + rawdt=hl7_message, + organisme=final_result_str[:255], + alat=device_name + ) + session.add(new_entry) + session.commit() + + except Exception as e: + logging.error(f"[MYLA Parser] Error: {e}") + session.rollback() + finally: + session.close() + +def get_pending_myla_orders(limit=10): + session = SessionLocal() + try: + return session.query(PaslabOrder).filter( + (PaslabOrder.flg_vitek3 == False) | (PaslabOrder.flg_vitek3 == None) + ).order_by(PaslabOrder.urut.asc()).limit(limit).all() + finally: + session.close() + +def mark_myla_order_sent(order_id): + session = SessionLocal() + try: + order = session.query(PaslabOrder).filter(PaslabOrder.urut == order_id).first() + if order: + order.flg_vitek3 = True + session.commit() + return True + return False + except Exception: + session.rollback() + raise + finally: + session.close() + +def create_myla_astm_order_message(order): + """ + ASTM E1394/LIS2-A2 style: + H, P, C, O, R, L + """ + pid = sanitize_astm_field(order.norm, max_len=32) + sid = sanitize_astm_field(order.rnoreg, max_len=32) + + first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) + first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) + last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) + p_name = f"{last_name}^{first_name}" + + sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) + sex = "M" if sex_raw.startswith("L") else "F" + + raw_location = sanitize_astm_field(getattr(order, 'ruangan', "UT"), uppercase=True, max_len=40) + location = re.sub(r"[^A-Z0-9]", "", raw_location)[:10] or "UT" + + diagnosis = sanitize_astm_field(getattr(order, 'diagnosa', "Unspecified"), max_len=60) or "Unspecified" + specimen_type = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) if order.kd_spesimen else "BLOOD" + specimen_field = f"{specimen_type}^VENA^^BAIK" + test_code = sanitize_astm_field(order.tes, uppercase=True, max_len=40) or "GENERAL" + + head = r"H|\^&|||LIS|||||||||P|1" + + p_rec = [""] * 36 + p_rec[0] = "P" + p_rec[1] = "1" + p_rec[2] = pid + p_rec[3] = pid + p_rec[5] = p_name + p_rec[8] = sex + p_rec[25] = location + pat_str = "|".join(p_rec[:36]) + + com_str = f"C|1|L|{diagnosis}|G" + + o_rec = [""] * 32 + o_rec[0] = "O" + o_rec[1] = "1" + o_rec[2] = sid + o_rec[4] = f"^^^{test_code}" + o_rec[5] = "R" + o_rec[11] = "A" + o_rec[15] = specimen_field + ord_str = "|".join(o_rec[:32]) + + # Placeholder R record agar struktur record lengkap H,P,C,O,R,L. + rcd_str = "R|1|^^^ORDER_STATUS|PENDING|||N|||||F" + term = "L|1|N" + + message_content = f"{head}\r{pat_str}\r{com_str}\r{ord_str}\r{rcd_str}\r{term}\r" + seq = "1" + frame_body = f"{seq}{message_content}\x03" + chk = calculate_astm_checksum(frame_body) + full_frame = f"\x02{frame_body}{chk}\r\n" + return [full_frame.encode("latin-1")] + +def parse_myla_astm_records(raw_message, device_name="MYLA"): + session = SessionLocal() + try: + sample_id = "" + patient_id = "" + patient_name = "" + results = [] + + records = [r for r in raw_message.split('\r') if r.strip()] + for rec in records: + row = rec[1:] if rec and rec[0].isdigit() else rec + fields = row.split('|') + if not fields: + continue + + rtype = fields[0] + if rtype == "P": + if len(fields) > 3: + patient_id = fields[3].strip() + if len(fields) > 5: + patient_name = fields[5].replace("^", " ").strip() + elif rtype == "O": + if len(fields) > 2: + sample_id = fields[2].replace("^", "").strip() + elif rtype == "R": + test_name = fields[2].strip() if len(fields) > 2 else "" + test_value = fields[3].strip() if len(fields) > 3 else "" + if test_name or test_value: + results.append(f"{test_name}: {test_value}".strip(": ")) + + if sample_id and results: + final_result_str = " | ".join(results)[:255] + new_entry = LisPhoenix( + no_id=sample_id, + seq_no=patient_id, + rnmpas=patient_name, + tgl_data=datetime.datetime.now(), + rawdt=raw_message, + organisme=final_result_str, + alat=device_name + ) + session.add(new_entry) + session.commit() + else: + print("[MYLA-ASTM] ASTM message diterima (tanpa hasil R untuk disimpan).") + except Exception as e: + logging.error(f"[MYLA-ASTM] Error parse/simpan: {e}") + session.rollback() + finally: + session.close() + +def _read_until_lf(conn, timeout_seconds=5): + deadline = time.time() + timeout_seconds + payload = b"" + while time.time() < deadline: + conn.settimeout(max(0.1, deadline - time.time())) + chunk = conn.recv(1) + if not chunk: + break + payload += chunk + if payload.endswith(b"\n"): + break + return payload + +def receive_myla_astm_transmission(conn, peer_ip, first_control=ENQ): + """ + Menerima transmisi ASTM dari server: + ENQ -> ACK, lalu STX frame(s) -> ACK/NAK tiap frame, diakhiri EOT. + """ + if first_control == ENQ: + conn.sendall(ACK) + + assembled = [] + pending_ctrl = first_control if first_control == STX else None + while True: + if pending_ctrl is not None: + ctrl = pending_ctrl + pending_ctrl = None + else: + conn.settimeout(5) + ctrl = conn.recv(1) + if not ctrl: + return + if ctrl == EOT: + break + if ctrl == ENQ: + conn.sendall(ACK) + continue + if ctrl != STX: + continue + + payload = _read_until_lf(conn, timeout_seconds=5) + if not payload: + conn.sendall(NAK) + continue + + frame = ctrl + payload + try: + frame_text = frame.decode("latin-1", errors="ignore") + body_start = frame_text.find("\x02") + etx_pos = frame_text.find("\x03") + if body_start == -1 or etx_pos == -1 or etx_pos <= body_start: + conn.sendall(NAK) + continue + + frame_body = frame_text[body_start + 1:etx_pos + 1] + recv_chk = frame_text[etx_pos + 1:etx_pos + 3].upper() + calc_chk = calculate_astm_checksum(frame_body).upper() + if recv_chk != calc_chk: + logging.warning(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") + print(f"[MYLA-ASTM] Checksum mismatch dari {peer_ip}: recv={recv_chk}, calc={calc_chk}") + conn.sendall(NAK) + continue + + # Drop seq(1 char) dan ETX + data_part = frame_body[1:-1] + assembled.append(data_part) + conn.sendall(ACK) + except Exception as e: + logging.error(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") + print(f"[MYLA-ASTM] Gagal parse frame dari {peer_ip}: {e}") + conn.sendall(NAK) + + if assembled: + raw_message = "".join(assembled) + parse_myla_astm_records(raw_message, device_name=f"MYLA-{peer_ip}") + +def wait_for_astm_control(conn, expected_controls, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS): + deadline = time.time() + timeout_seconds + while time.time() < deadline: + try: + conn.settimeout(max(0.1, deadline - time.time())) + b = conn.recv(1) + if not b: + return None + if b in expected_controls: + return b + if b == ENQ: + receive_myla_astm_transmission(conn, peer_ip, first_control=ENQ) + elif b == STX: + receive_myla_astm_transmission(conn, peer_ip, first_control=STX) + except socket.timeout: + continue + except Exception as e: + logging.error(f"[MYLA-ASTM] Error wait control: {e}") + print(f"[MYLA-ASTM] Error wait control: {e}") + return None + return None + +def send_order_to_myla_astm(conn, order, peer_ip): + frames = create_myla_astm_order_message(order) + conn.sendall(ENQ) + hs = wait_for_astm_control(conn, {ACK}, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS) + if hs != ACK: + logging.warning(f"[MYLA-ASTM] Handshake gagal untuk rnoreg={order.rnoreg}, respon={hs}") + print(f"[MYLA-ASTM] Handshake gagal untuk rnoreg={order.rnoreg}, respon={hs}") + return False + + for i, frame in enumerate(frames, start=1): + frame_ok = False + for attempt in range(1, 4): + conn.sendall(frame) + resp = wait_for_astm_control(conn, {ACK, NAK}, peer_ip, timeout_seconds=MYLA_CONTROL_TIMEOUT_SECONDS) + if resp == ACK: + frame_ok = True + break + if resp == NAK: + logging.warning(f"[MYLA-ASTM] Frame {i} NAK rnoreg={order.rnoreg}, retry={attempt}") + print(f"[MYLA-ASTM] Frame {i} NAK rnoreg={order.rnoreg}, retry={attempt}") + time.sleep(0.5) + continue + logging.warning(f"[MYLA-ASTM] Frame {i} timeout rnoreg={order.rnoreg}, retry={attempt}") + print(f"[MYLA-ASTM] Frame {i} timeout rnoreg={order.rnoreg}, retry={attempt}") + if not frame_ok: + conn.sendall(EOT) + return False + + conn.sendall(EOT) + return True + +def create_myla_hl7_order_message(order, msg_control_id): + timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + sample_id = sanitize_astm_field(order.rnoreg, uppercase=True, max_len=32) or "000000" + pid_norm = sanitize_astm_field(order.norm, uppercase=True, max_len=32) or f"PID{sample_id}" + test_code = sanitize_astm_field(order.tes, uppercase=True, max_len=40) or "ID" + specimen_code = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) or "URC" + + # Sesuaikan pattern ID seperti contoh vendor (SPE/AWOS + accession). + compact_id = re.sub(r"[^A-Z0-9]", "", sample_id) or "000000" + spm_id = f"SPE{compact_id}"[:30] + obr_order_id = f"AWOS{compact_id}"[:30] + + msh = ( + f"MSH|^~\\&|LIS|LAB|MYLA|BMX|{timestamp}||OML^O33^OML_O33|{msg_control_id}|P|2.5.1" + f"|||NE|AL||UNICODE UTF-8" + ) + pid = f"PID|||{pid_norm}" + pv1 = "PV1||O" + spm = f"SPM|1|{spm_id}||{specimen_code}^{specimen_code}^99BMx|||||||P^^HL70369||||||{timestamp}" + sac = f"SAC|||{spm_id}" + # ORC.9 wajib ada; ikuti contoh MyLA: ORC|NW|||||||| + orc = f"ORC|NW||||||||{timestamp}" + tq1 = "TQ1|||||||||R^^HL7048" + obr = f"OBR|1|{obr_order_id}||{test_code}^{test_code}^99BMx" + return f"{msh}\r{pid}\r{pv1}\r{spm}\r{sac}\r{orc}\r{tq1}\r{obr}\r" + +def process_myla_hl7_message(conn, hl7_str, peer_ip): + incoming_control_id = "" + message_type = "" + ack_code = "" + ack_for_control_id = "" + err_text = "" + orc_control = "" + orc_status = "" + orc_ref = "" + + try: + msh_fields = hl7_str.split('\r')[0].split('|') + if len(msh_fields) > 8: + message_type = msh_fields[8].strip().upper() + if len(msh_fields) > 9: + incoming_control_id = msh_fields[9].strip() + except Exception: + pass + + if "ORU^" in message_type or message_type.startswith("OUL^R22"): + print(f"[MYLA-HL7] Menerima hasil dari {peer_ip} (type={message_type}, ID: {incoming_control_id})") + parse_myla_result(hl7_str, device_name=f"MYLA-{peer_ip}") + if incoming_control_id: + try: + ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ack_msg = ( + f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\r" + f"MSA|AA|{incoming_control_id}\r" + ) + conn.sendall(f"\x0b{ack_msg}\x1c\r".encode("utf-8")) + except Exception as e: + logging.warning(f"[MYLA-HL7] Gagal kirim ACK hasil {incoming_control_id}: {e}") + elif message_type.startswith("ACK") or message_type.startswith("ORL^O34"): + for seg in hl7_str.split('\r'): + if seg.startswith("MSA|"): + parts = seg.split('|') + if len(parts) > 1: + ack_code = parts[1].strip().upper() + if len(parts) > 2: + ack_for_control_id = parts[2].strip() + break + for seg in hl7_str.split('\r'): + if seg.startswith("ERR|"): + err_parts = seg.split('|') + if len(err_parts) > 2: + err_text = err_parts[2].strip() + if len(err_parts) > 3 and err_parts[3]: + err_text = f"{err_text} {err_parts[3].strip()}".strip() + break + for seg in hl7_str.split('\r'): + if seg.startswith("ORC|"): + o = seg.split('|') + if len(o) > 1: + orc_control = o[1].strip().upper() + if len(o) > 5: + orc_status = o[5].strip().upper() + if len(o) > 6: + orc_ref = o[6].strip() + break + + return { + "message_type": message_type, + "control_id": incoming_control_id, + "ack_code": ack_code, + "ack_for_control_id": ack_for_control_id, + "err_text": err_text, + "orc_control": orc_control, + "orc_status": orc_status, + "orc_ref": orc_ref, + } + +def wait_for_myla_hl7_ack(conn, expected_control_id, peer_ip, timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS): + deadline = time.time() + timeout_seconds + buffer = b"" + + while time.time() < deadline: + try: + conn.settimeout(max(0.1, deadline - time.time())) + data = conn.recv(4096) + if not data: + return False + buffer += data + + if b"\x1c\r" not in buffer: + continue + + chunks = buffer.split(b"\x1c\r") + for raw_msg in chunks[:-1]: + clean_msg = raw_msg.replace(b"\x0b", b"") + hl7_str = clean_msg.decode("latin-1", errors="ignore") + if "MSH|" not in hl7_str: + continue + + hl7_str = hl7_str[hl7_str.find("MSH|"):] + parsed = process_myla_hl7_message(conn, hl7_str, peer_ip) + if parsed["message_type"].startswith("ACK") or parsed["message_type"].startswith("ORL^O34"): + ack_for = parsed.get("ack_for_control_id", "") + ack_code = parsed.get("ack_code", "") + err_text = parsed.get("err_text", "") + orc_control = parsed.get("orc_control", "") + orc_status = parsed.get("orc_status", "") + orc_ref = parsed.get("orc_ref", "") + if ack_for == expected_control_id: + logging.info( + f"[MYLA-HL7] Response diterima untuk {expected_control_id} " + f"(type={parsed.get('message_type')}, code={ack_code or '-'}, " + f"orc1={orc_control or '-'}, orc5={orc_status or '-'}, orc6={orc_ref or '-'}, " + f"err={err_text or '-'})" + ) + app_ok = True + if parsed["message_type"].startswith("ORL^O34"): + app_ok = (orc_control == "OK") + return (ack_code in ("AA", "CA")) and app_ok + logging.info( + f"[MYLA-HL7] Response bukan untuk pesan ini " + f"(expected={expected_control_id}, msa2={ack_for or '-'}, " + f"type={parsed.get('message_type')}, code={ack_code or '-'})" + ) + + buffer = chunks[-1] + except socket.timeout: + continue + except Exception as e: + logging.error(f"[MYLA-HL7] Error wait ACK: {e}") + print(f"[MYLA-HL7] Error wait ACK: {e}") + return False + + return False + +def pump_myla_incoming(conn, peer_ip, buffer, wait_seconds=0.2): + """ + Proses pesan masuk dari MyLA saat idle (mis. ORU hasil) pada koneksi client yang sama. + """ + deadline = time.time() + max(0.05, wait_seconds) + while time.time() < deadline: + try: + conn.settimeout(max(0.05, deadline - time.time())) + data = conn.recv(4096) + if not data: + raise ConnectionError("Koneksi ditutup oleh MyLA") + buffer += data + + if b"\x1c\r" not in buffer: + continue + + chunks = buffer.split(b"\x1c\r") + for raw_msg in chunks[:-1]: + clean_msg = raw_msg.replace(b"\x0b", b"") + hl7_str = clean_msg.decode("latin-1", errors="ignore") + if "MSH|" not in hl7_str: + continue + hl7_str = hl7_str[hl7_str.find("MSH|"):] + process_myla_hl7_message(conn, hl7_str, peer_ip) + + buffer = chunks[-1] + except socket.timeout: + break + + return buffer + +def send_order_to_myla_hl7(conn, order, peer_ip): + msg_control_id = f"MYLA{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}{order.urut}" + hl7_message = create_myla_hl7_order_message(order, msg_control_id) + mllp_payload = f"\x0b{hl7_message}\x1c\r".encode("utf-8") + msh_line = hl7_message.split('\r')[0] + print(f"[MYLA-HL7] Kirim rnoreg={order.rnoreg}, MSH={msh_line}") + + conn.sendall(mllp_payload) + ack_ok = wait_for_myla_hl7_ack( + conn, + expected_control_id=msg_control_id, + peer_ip=peer_ip, + timeout_seconds=MYLA_ACK_TIMEOUT_SECONDS + ) + return ack_ok +# ========================================== +# 4. NETWORK & COMMUNICATION LOGIC +# ========================================== +def handle_myla_client(conn, addr): + """ + TCP Handler untuk bioMérieux MYLA. + Hanya menerima HL7 berbungkus MLLP (\x0b ... \x1c\r). + """ + print(f"[MYLA-TCP] Koneksi baru dari {addr}") + buffer = b"" + + try: + while True: + data = conn.recv(4096) + if not data: break + + buffer += data + + # Cek penutup MLLP (\x1c\r) + if b'\x1c\r' in buffer: + # Pisahkan pesan jika ada beberapa pesan yang nempel + messages = buffer.split(b'\x1c\r') + + for msg in messages[:-1]: # Abaikan yang terakhir (karena string kosong atau pesan belum selesai) + # Hapus pembuka MLLP (\x0b) + clean_msg_bytes = msg.replace(b'\x0b', b'') + hl7_str = clean_msg_bytes.decode('latin-1', errors='ignore') + + if "MSH|" in hl7_str: + # Potong tepat dari MSH + hl7_str = hl7_str[hl7_str.find("MSH|"):] + + # Ambil Control ID untuk ACK + incoming_control_id = "" + try: + msh_fields = hl7_str.split('\r')[0].split('|') + if len(msh_fields) > 9: + incoming_control_id = msh_fields[9] + except: pass + + # --- PROSES HASIL (ORU / OUL^R22) --- + if "ORU^" in hl7_str or "OUL^R22" in hl7_str: + print(f"[MYLA] Menerima hasil (ORU/OUL) ID: {incoming_control_id}") + parse_myla_result(hl7_str, device_name=f"MYLA-{addr[0]}") + + # --- PROSES QUERY (QRY/QBP) - JIKA MYLA BERTANYA ORDER --- + elif "QRY^" in hl7_str or "QBP^" in hl7_str: + client_ip = addr[0] + print(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() + print(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')) + print(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}") + print(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: + ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ack_msg = f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\rMSA|AA|{incoming_control_id}\r" + full_ack = f"\x0b{ack_msg}\x1c\r" + conn.sendall(full_ack.encode('utf-8')) + print(f"[MYLA ACK] Terkirim untuk ID {incoming_control_id}") + + # Sisakan bagian terakhir di buffer (kalau ada pesan yang terpotong) + buffer = messages[-1] + + except Exception as e: + logging.error(f"[MYLA-TCP] Error koneksi {addr}: {e}") + print(f"[MYLA-TCP] Error koneksi {addr}: {e}") + finally: + conn.close() + logging.info(f"[MYLA-TCP] Koneksi {addr} ditutup.") + print(f"[MYLA-TCP] Koneksi {addr} ditutup.") + +def start_myla_server(host, port): + """ + Listener berperan sebagai TCP client: + - Konek ke MYLA server + - Poll order yang belum terkirim (flg_vitek3 = False/NULL) + - Kirim order via HL7 MLLP + - Tandai terkirim jika ACK diterima + """ + while True: + conn = None + incoming_buffer = b"" + last_idle_log_at = 0.0 + try: + print(f"[MYLA-CLIENT] Mencoba konek ke MYLA {host}:{port} ...") + conn = socket.create_connection((host, port), timeout=10) + conn.settimeout(1.0) + print(f"[MYLA-CLIENT] Terhubung ke MYLA {host}:{port}") + + while True: + incoming_buffer = pump_myla_incoming(conn, host, incoming_buffer, wait_seconds=0.15) + pending_orders = get_pending_myla_orders(limit=10) + if not pending_orders: + now_ts = time.time() + if (now_ts - last_idle_log_at) >= MYLA_IDLE_LOG_INTERVAL_SECONDS: + print( + "[MYLA-CLIENT] Polling aktif: tidak ada order pending " + "(flg_vitek3 = FALSE/NULL)." + ) + last_idle_log_at = now_ts + time.sleep(MYLA_POLL_INTERVAL_SECONDS) + continue + + print(f"[MYLA-CLIENT] Ditemukan {len(pending_orders)} order pending untuk MYLA") + last_idle_log_at = 0.0 + + for order in pending_orders: + send_ok = send_order_to_myla_hl7(conn, order, host) + if send_ok: + mark_myla_order_sent(order.urut) + print(f"[MYLA-HL7] Order sukses, set flg_vitek3=TRUE rnoreg={order.rnoreg}") + else: + logging.warning( + f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " + f"akan dicoba ulang di polling berikutnya." + ) + print( + f"[MYLA-HL7] Pengiriman gagal/ACK timeout untuk rnoreg={order.rnoreg}, " + f"akan dicoba ulang di polling berikutnya." + ) + break + + time.sleep(1) + + except Exception as e: + logging.error(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") + print(f"[MYLA-CLIENT] Koneksi/pengiriman error ke {host}:{port}: {e}") + finally: + if conn: + try: + conn.close() + except Exception: + pass + + time.sleep(MYLA_CONNECT_RETRY_SECONDS) + +def start_myla_inbound_server(host, port): + """ + Listener TCP inbound untuk menerima hasil dari connector AI_to_LIS_MyLis (BCI/MyLA). + """ + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + server.bind((host, port)) + server.listen(5) + print(f"[MYLA-INBOUND] Listening di {host}:{port}") + + while True: + client_socket, addr = server.accept() + client_thread = threading.Thread( + target=handle_myla_client, + args=(client_socket, addr), + daemon=True + ) + client_thread.start() + except Exception as e: + logging.critical(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") + print(f"[MYLA-INBOUND] Gagal start listener {host}:{port}: {e}") + finally: + try: + server.close() + except Exception: + pass + +# ========================================== +# HL7 TCP LISTENER FOR GENEXPERT +# ========================================== +def parse_genexpert_astm_records(astm_string, device_name): + """ + Parser khusus untuk membaca hasil ASTM dari instrumen GeneXpert + dan menyimpannya ke tabel LisPhoenix dengan aman (mencegah VARCHAR limit error). + """ + try: + records = astm_string.split('\r') + no_id = "" + rnmpas = "" + seq_no = "" + hasil_list = [] + + for rec in records: + # 1. Ambil Data Pasien (P Record) + if rec.startswith("P|"): + parts = rec.split('|') + if len(parts) > 3: + # Ambil Patient ID (Bisa di index 3 atau 4 tergantung setting alat) + no_id = parts[3].strip() or (parts[4].strip() if len(parts) > 4 else "") + if len(parts) > 5: + # Ambil Nama Pasien, ganti ^ dengan spasi + rnmpas = parts[5].replace('^', ' ').strip() + + # 2. Ambil Nomor Order / Registrasi (O Record) + elif rec.startswith("O|"): + parts = rec.split('|') + if len(parts) > 2: + seq_no = parts[2].strip() + + # 3. Ambil Hasil Tes (R Record) + elif rec.startswith("R|"): + parts = rec.split('|') + if len(parts) > 3: + test_info = parts[2] # Contoh: ^^^MTB-RIF_ULTRA 2^^^MTB^ + result_val = parts[3].replace('^', '').strip() # Contoh: DETECTED atau INVALID + + # [KUNCI]: Abaikan kurva analitik agar teks tidak kepanjangan + if "Ct|" not in rec and "EndPt|" not in rec and result_val: + # Ekstrak nama targetnya saja (misal "MTB" atau "RIF Resistance") + target_match = test_info.split('^^^') + target_name = target_match[-1].strip('^') if len(target_match) > 1 else "" + + if target_name and result_val: + hasil_list.append(f"{target_name}: {result_val}") + + # Gabungkan hasil-hasil penting menjadi 1 string + kesimpulan = " | ".join(hasil_list) + + # [PATCH KRITIS]: Potong string agar TIDAK CRASH di database + no_id_safe = no_id[:50] + seq_no_safe = seq_no[:50] + rnmpas_safe = rnmpas[:100] + kesimpulan_safe = kesimpulan[:100] # Membatasi maksimal 100 karakter untuk kolom 'organisme' + + if not seq_no_safe: + print("[GENEXPERT-PARSER] Warning: seq_no (No Order) tidak ditemukan dalam pesan hasil.") + return + + # Simpan ke Database + with SessionLocal() as session: + new_result = LisPhoenix( + no_id=no_id_safe, + seq_no=seq_no_safe, + rnmpas=rnmpas_safe, + tgl_data=datetime.datetime.now().date(), + rawdt=astm_string, # Simpan data mentahnya (utuh) ke TEXT untuk jaga-jaga/bisa dibaca ulang + organisme=kesimpulan_safe, # Hasil yang sudah bersih dan muat + alat=device_name, + processed='N' # Atau menyesuaikan default sistem Anda + ) + session.add(new_result) + session.commit() + print(f"[GENEXPERT-DB-SUCCESS] Hasil lab untuk Order {seq_no_safe} berhasil disimpan ke LisPhoenix!") + + except Exception as e: + print(f"[GENEXPERT-PARSER-ERROR] Gagal memparsing/menyimpan hasil: {e}") + traceback.print_exc() + +def manage_tcp_server(): + """Thread Server Utama untuk GeneXpert""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Allow reuse address agar tidak error 'Address already in use' saat restart + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + server.bind((SERVER_HOST, TCP_LISTENER_PORT)) + server.listen(5) # Bisa antri 5 koneksi + print(f"[TCP-SERVER] Listening GeneXpert di port {TCP_LISTENER_PORT}...") + while True: + # Accept koneksi baru (Blocking, tapi aman karena di thread sendiri) + client_sock, addr = server.accept() + + # Buat thread kecil untuk handle client tersebut (agar server bisa terima client lain) + client_thread = threading.Thread( + target=handle_genexpert_client, + args=(client_sock, addr), + daemon=True + ) + client_thread.start() + + except Exception as e: + logging.critical(f"[TCP-SERVER] Gagal Start: {e}") + print(f"[TCP-SERVER] Gagal Start: {e}") + +def run_http_api_server(): + print(f"[HTTP-API] Listening di port {HTTP_API_PORT}...") + app.run(host=SERVER_HOST, port=HTTP_API_PORT, debug=False, use_reloader=False, threaded=True) + +def process_genexpert_hl7_message(conn, ip_addr, clean_hl7, response_framing): + # ========================================================== + # 1. BLOK PENANGANAN ASTM (Karena tidak diawali "MSH|") + # ========================================================== + if not str(clean_hl7 or "").startswith("MSH|"): + records = parse_astm_records(clean_hl7) + record_types = [rec.split("|", 1)[0] for rec in records if rec] + print(f"[GENEXPERT-ASTM] ip={ip_addr}, record_types={record_types}") + + # A. Cek Jika Alat Meminta Order (Query) + if any(rec.startswith("Q|") for rec in records): + print(f"[GENEXPERT-ASTM] Alat meminta ORDER (Q Record)") + send_all_orders_astm(conn, ip_addr, clean_hl7, response_framing="astm") + return + + # B. Cek Jika Alat Mengirim Hasil Lab (Result) + if any(rec.startswith("R|") for rec in records): + print(f"[RESULT] Menerima Hasil Lab ASTM dari {ip_addr}.") + # Memanggil fungsi parser Anda untuk menyimpan hasil ke DB + parse_genexpert_astm_records(clean_hl7, device_name=f"GeneXpert-{ip_addr}") + return + + # C. [PERBAIKAN] Cek Jika Alat Mengirim Komentar/Penolakan (Comment) + if any(rec.startswith("C|") for rec in records): + # 1. Ekstrak teks komentar untuk ditampilkan di log + comments = [rec for rec in records if rec.startswith("C|")] + for c in comments: + parts = c.split('|') + comment_text = parts[3] if len(parts) > 3 else c + print(f"[GENEXPERT-ASTM-INFO] Komentar dari Alat: {comment_text}") + + # 2. Ekstrak NoReg (Nomor Order) dari record 'O' + rnoreg = None + for rec in records: + if rec.startswith("O|"): + o_parts = rec.split('|') + if len(o_parts) > 2: + rnoreg = o_parts[2].strip() + break + + # 3. Update Database PaslabOrder + if rnoreg: + # Cari kolom flag yang cocok dengan IP yang sedang terkoneksi + target_flag_col = None + for flag_col, mapped_ip in TARGET_MAPPING.items(): + if mapped_ip == ip_addr: + target_flag_col = flag_col + break + + if target_flag_col: + try: + # Buka sesi database dan update + with SessionLocal() as session: + order = session.query(PaslabOrder).filter(PaslabOrder.rnoreg == rnoreg).first() + if order: + # Set flag mesin tersebut menjadi True agar tidak dikirim ulang + setattr(order, target_flag_col, True) + session.commit() + print(f"[GENEXPERT-DB] Order {rnoreg} ditolak alat. Flag {target_flag_col} di-set True (Selesai).") + else: + print(f"[GENEXPERT-DB] Order {rnoreg} tidak ditemukan di database saat memproses penolakan.") + except Exception as e: + print(f"[GENEXPERT-DB-ERROR] Gagal update order duplikat {rnoreg}: {e}") + + print(f"[GENEXPERT-ASTM] Transaksi penolakan order selesai diproses.") + return + + # D. Jika hanya berisi H dan L tanpa ada transaksi berarti (Status Echo) + if set(record_types).issubset({'H', 'L'}): + print(f"[GENEXPERT-ASTM] Menerima Heartbeat / Sesi Kosong dari alat.") + return + + print(f"[GENEXPERT-ASTM] Pesan ASTM tidak dikenali dari {ip_addr}. Isi: {clean_hl7[:50]}") + return + + # ========================================================== + # 2. BLOK PENANGANAN HL7 (Fallback / Cadangan) + # ========================================================== + log_genexpert_hl7("IN", ip_addr, clean_hl7) + lines = clean_hl7.split('\r') + msh_fields = lines[0].split('|') + incoming_control_id = msh_fields[9] if len(msh_fields) > 9 else "UNKNOWN" + msg_id = incoming_control_id + + if "QRY^" in clean_hl7 or "QRY|" in clean_hl7: + print( + f"[GENEXPERT] Legacy QRY diterima dari {ip_addr} tetapi diabaikan. " + "Host hanya mendukung QBP^Z01/QBP^Z03 untuk order query." + ) + return + + if "ORU^" in clean_hl7: + # --- [PATCH] CEK APAKAH INI PESAN ERROR / PENOLAKAN --- + if "|Error^" in clean_hl7 or "|X\r" in clean_hl7 or "\rTE|" in clean_hl7: + print(f"[GENEXPERT-ERROR] Mesin {ip_addr} menolak tes (Test Unknown/Disabled).") + + # Coba ekstrak NoReg dari segmen SPM agar kita bisa mengunci ordernya (set Flag = True) + rnoreg_error = None + for line in clean_hl7.split('\r'): + if line.startswith('SPM|'): + parts = line.split('|') + if len(parts) > 2: + rnoreg_error = parts[2].replace('^', '').strip() + break + + if rnoreg_error: + print(f"[GENEXPERT-ERROR] Mengunci/Membatalkan Order {rnoreg_error} agar tidak terjadi Infinite Loop.") + # (OPSIONAL: Jalankan fungsi update ke DB untuk mengubah flag PaslabOrder menjadi True di sini) + # ... + + # Kirim ACK agar alat berhenti mengirim error + ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") + return # BERHENTI DI SINI. Jangan lanjut ke parse_hl7_result! + # ----------------------------------------------------- + + print(f"[RESULT] Menerima Hasil Lab.") + parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}") + ack_msg = create_genexpert_ack_r01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="oru-ack") + print(f"[ACK SENT] Untuk hasil ID {incoming_control_id}") + return + + if "QBP^Z01" in clean_hl7 or "QBP^Z03" in clean_hl7: + print("[GENEXPERT] Alat meminta ORDER") + msg_id = extract_msg_control_id(clean_hl7) + send_all_orders(conn, ip_addr, clean_hl7, msg_id, response_framing=response_framing) + return + + if "QCN^J01" in clean_hl7: + clear_genexpert_inflight_for_ip(ip_addr, reason="query-confirmation") + ack_msg = create_genexpert_ack_j01_response(clean_hl7, ip_addr=ip_addr) + send_genexpert_response(conn, ip_addr, ack_msg, response_framing, label="qcn-ack") + print(f"[GENEXPERT] Menerima konfirmasi query dari {ip_addr}.") + return + + print(f"[GenExpert_TCP] Pesan Lengkap Diterima: {clean_hl7[:50]}...") + parse_hl7_result(conn, msg_id, clean_hl7, device_name=f"GeneXpert-{ip_addr}, ") + + try: + if len(msh_fields) > 9: + msg_control_id = msh_fields[9] + ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ack_msg = f"MSH|^~\\&|LIS|LAB|GeneXpert|Cepheid|{ack_time}||ACK|{msg_control_id}|P|2.5\rMSA|AA|{msg_control_id}\r" + full_ack = f"\x0b{ack_msg}\x1c\r" + log_genexpert_hl7("OUT", ip_addr, ack_msg, label="generic-ack") + conn.sendall(full_ack.encode('utf-8')) + print(f"[ACK] Terkirim untuk ID {msg_control_id}") + except Exception as e: + print(f"Gagal kirim ACK: {e}") + +def handle_genexpert_client(conn, addr): + print(f"[GenExpert_TCP] Koneksi baru dari {addr}") + buffer = b"" + pending_astm_hl7 = None + pending_astm_framing = None + conn.settimeout(60) + client_ip = addr[0] + with connection_lock: + active_genexpert_connections[client_ip] = conn + print(f"[GenExpert_TCP] Register koneksi aktif {client_ip}") + + try: + while True: + try: + data = conn.recv(4096) + if not data: + if pending_astm_hl7: + log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail="reason=connection-close") + process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") + pending_astm_hl7 = None + pending_astm_framing = None + print(f"[GenExpert_TCP] Client {addr} menutup koneksi.") + break + + buffer += data + if b"\x02" in data: + log_genexpert_handshake(addr[0], "STX-RX", detail=f"bytes={len(data)}") + if b"\x03" in data: + log_genexpert_handshake(addr[0], "ETX-RX", detail=f"bytes={len(data)}") + if b"\x04" in data: + log_genexpert_handshake(addr[0], "EOT-RX", detail=f"bytes={len(data)}") + if b"\x06" in data: + log_genexpert_handshake(addr[0], "ACK-RX", detail=f"bytes={len(data)}") + if b"\x15" in data: + log_genexpert_handshake(addr[0], "NAK-RX", detail=f"bytes={len(data)}") + + # --- 1. HANDLE HANDSHAKE (ENQ) --- + # Jika alat kirim ENQ (\x05/♣), langsung balas ACK (\x06) + if b'\x05' in buffer: + log_genexpert_handshake(addr[0], "ENQ-RX", detail=f"buffer_len={len(buffer)}") + conn.sendall(b'\x06') + log_genexpert_handshake(addr[0], "ACK-TX", detail="reason=enq") + + # [PERBAIKAN KURSIS 2]: KOSONGKAN TOTAL BUFFER SAAT ENQ! + # Alat meminta sesi baru, pastikan tidak ada sisa pesan lama yang nyangkut + buffer = b"" + pending_astm_hl7 = "" + continue # Langsung lanjut ke recv() berikutnya + if b'\x15' in buffer: + log_genexpert_handshake(addr[0], "NAK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") + buffer = buffer.replace(b'\x15', b'') + if b'\x06' in buffer: + log_genexpert_handshake(addr[0], "ACK-BUFFER-CLEAR", detail=f"buffer_len={len(buffer)}") + buffer = buffer.replace(b'\x06', b'') + + # --- 2. CEK APAKAH PESAN SUDAH LENGKAP? --- + # Kita cari tanda akhir pesan umum: + # - \x1c (End Block MLLP) + # - \x03 (ETX - End Text ASTM) + # - \x04 (EOT - End Transmission ASTM) + + msg_complete = False + end_marker_pos = -1 + + if b'\x1c' in buffer: # Pola MLLP Standard + end_marker_pos = buffer.find(b'\x1c') + msg_complete = True + elif b'\x03' in buffer or b'\x17' in buffer: + # Cari di index mana letak ETX atau ETB + pos_etx = buffer.find(b'\x03') + pos_etb = buffer.find(b'\x17') + + # Tentukan mana yang muncul lebih dulu di buffer + pos = -1 + if pos_etx != -1 and pos_etb != -1: + pos = min(pos_etx, pos_etb) + else: + pos = max(pos_etx, pos_etb) + + # Pastikan kita menerima 5 bytes penuh (ETB/ETX + C1 + C2 + CR + LF) + if pos != -1 and len(buffer) >= pos + 5: + end_marker_pos = pos + 5 + msg_complete = True + + elif b'\x04' in buffer: # Pola EOT (Putus Koneksi/Selesai) + end_marker_pos = buffer.find(b'\x04') + msg_complete = True + + # --- 3. PROSES JIKA LENGKAP --- + if msg_complete: + if end_marker_pos == 0 and buffer[:1] == b'\x04': + log_genexpert_handshake(addr[0], "EOT-CLEAR", detail="standalone-eot") + buffer = buffer[1:].lstrip(b'\r').lstrip(b'\n') + if pending_astm_hl7: + log_genexpert_handshake(addr[0], "ASTM-MSG-PROCESS", detail=f"framing={pending_astm_framing}") + process_genexpert_hl7_message(conn, addr[0], pending_astm_hl7, pending_astm_framing or "astm") + pending_astm_hl7 = None + pending_astm_framing = None + continue + + # Ambil pesan dari awal sampai marker + # (Gunakan slice sampai end_marker_pos+1 agar karakter penutup ikut terambil/dibuang) + if end_marker_pos == -1: end_marker_pos = len(buffer) + + full_message_bytes = buffer[:end_marker_pos] + response_framing = detect_genexpert_message_framing(full_message_bytes) + if response_framing == "astm": + debug_genexpert_astm_frame(addr[0], full_message_bytes, direction="RX") + log_genexpert_handshake( + addr[0], + "FRAME-COMPLETE", + detail=f"framing={response_framing}, frame_len={len(full_message_bytes)}" + ) + + send_genexpert_transport_ack( + conn, + addr[0], + response_framing, + reason="incoming-frame-complete" + ) + + # Sisa buffer (jika ada paket nempel di belakangnya) disimpan untuk loop berikutnya + buffer = buffer[end_marker_pos:] + + # Jangan buang EOT di sini; jika EOT datang menempel setelah frame ASTM, + # ia harus diproses pada iterasi berikutnya agar pending ASTM message dijalankan. + buffer = buffer.lstrip(b'\r').lstrip(b'\n') + + # Decode ke string + temp_str = full_message_bytes.decode('latin-1', errors='ignore') + astm_text = extract_astm_frame_text(full_message_bytes) if response_framing == "astm" else "" + + # --- SANITIZING (PEMBERSIHAN) --- + # Cari MSH pertama + if "MSH|" in temp_str: + msh_index = temp_str.find("MSH|") + clean_hl7 = temp_str[msh_index:] + if response_framing == "astm": + pending_astm_hl7 = clean_hl7 + pending_astm_framing = response_framing + log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(clean_hl7)}") + continue + process_genexpert_hl7_message(conn, addr[0], clean_hl7, response_framing) + elif response_framing == "astm" and astm_text: + pending_astm_hl7 = (pending_astm_hl7 or "") + astm_text + pending_astm_framing = response_framing + log_genexpert_handshake(addr[0], "ASTM-MSG-STORED", detail=f"len={len(pending_astm_hl7)}, mode=records") + continue + else: + # Jika pesan lengkap tapi tidak ada MSH (misal cuma EOT doang) + pass + else: + if buffer: + head_hex = buffer[:12].hex() + log_genexpert_handshake( + addr[0], + "BUFFER-WAIT", + detail=f"buffer_len={len(buffer)}, head_hex={head_hex}" + ) + + except ConnectionResetError: + logging.warning(f"[GenExpert_TCP] Connection reset by peer: {addr}") + break + except OSError as e: + if getattr(e, "winerror", None) == 10054: + logging.warning(f"[GenExpert_TCP] WinError 10054 dari {addr}") + break + raise + except socket.timeout: + continue + except Exception as e: + print(f"[Loop Error] {e}") + logging.exception(f"[Loop Error] Unexpected error from {addr}: {e}") + break + + except Exception as e: + logging.error(f"[GenExpert_TCP Error] Koneksi {addr} terputus: {e}") + finally: + with connection_lock: + if active_genexpert_connections.get(client_ip) is conn: + del active_genexpert_connections[client_ip] + remaining_connections = len(active_genexpert_connections) + clear_genexpert_inflight_for_ip(client_ip, reason="connection-closed") + if remaining_connections == 0: + stop_all_scheduled_result_queries(reason="no-active-genexpert") + try: + conn.close() + except Exception: + pass + logging.info(f"[GenExpert_TCP] Koneksi {addr} ditutup.") + +def send_order_via_active_connection(target_ip, hl7_message): + conn = None + with connection_lock: + conn = active_genexpert_connections.get(target_ip) + + if not conn: + logging.warning(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") + print(f"Gagal kirim Order: GeneXpert dengan IP {target_ip} BELUM TERKONEKSI ke Listener.") + return False + + try: + # Bungkus pesan dengan MLLP (Minimal Lower Layer Protocol) standard HL7 + # Format: message + mllp_msg = f"\x0b{hl7_message}\x1c\r" + + logging.info(f"Mengirim Order ke {target_ip}...") + print(f"Mengirim Order ke {target_ip}...") + conn.sendall(mllp_msg.encode('utf-8')) + + # Opsi: Jika ingin menunggu ACK balasan untuk Order + # Namun hati-hati ini bisa blocking jika alat lambat + ack = conn.recv(1024) + logging.info(f"Dapat ACK Order dari {target_ip}: {ack}") + print(f"Dapat ACK Order dari {target_ip}: {ack}") + return True + except Exception as e: + logging.error(f"Error mengirim ke {target_ip}: {e}") + print(f"Error mengirim ke {target_ip}: {e}") + # Jika error saat kirim, anggap koneksi rusak + with connection_lock: + if target_ip in active_genexpert_connections: + del active_genexpert_connections[target_ip] + return False + +# ========================================== +# VITEK PARSER +# ========================================== + +def calculate_vitek_checksum(data_str): + """ + Menghitung Checksum Vitek (Sum of bytes % 256). + """ + total = sum(ord(c) for c in data_str) + return f"{total % 256:02X}" + +def parse_and_save_vitek_result(raw_data, port_name="VITEK"): + session = SessionLocal() + try: + # --- 1. CLEANING DATA --- + # Hapus karakter kontrol STX(02), ETX(03), RS(1e/30), GS(1d/29), CR, LF + # Perhatikan: Log Anda menunjukkan RS () muncul di tengah kata, jadi harus dihapus total. + clean_data = raw_data.replace('\x02', '').replace('\x03', '').replace('\x1e', '').replace('\x1d', '').replace('\r', '').replace('\n', '') + + # Pisahkan field berdasarkan pipa '|' + fields = clean_data.split('|') + + # Cek apakah ini pesan result (mtrsl) + if not fields or fields[0] != 'mtrsl': + return # Abaikan jika bukan result + + # --- 2. VARIABLE INIT --- + sample_id = None # ci (No Lab / No Container) + patient_id = "" # pi (No RM) + patient_name = "" # pn + organism_name = "" # o2 + card_barcode = "" # is + antibiotics = [] # List penampung hasil AB + result_date = datetime.datetime.now() + + # Variabel sementara untuk looping antibiotik + curr_ab_name = "" + curr_ab_mic = "" + curr_ab_int = "" + + # --- 3. PARSING LOOP --- + for field in fields: + if not field: continue + + # --- HEADER INFO --- + if field.startswith("ci") and len(field) > 2: + sample_id = field[2:].strip() + elif field.startswith("pi") and len(field) > 2: + patient_id = field[2:].strip() + elif field.startswith("pn") and len(field) > 2: + patient_name = field[2:].strip() + elif field.startswith("is") and len(field) > 2: + card_barcode = field[2:].strip() + # --- ORGANISME --- + elif field.startswith("o2") and len(field) > 2: + organism_name = field[2:].strip() + + # --- ANTIBIOTIK BLOCK (Mulai Pesan Kedua) --- + # Tag 'ra' adalah pemisah antar obat + elif field == "ra": + # Jika ada data obat sebelumnya di memori, simpan dulu + if curr_ab_name: + ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" + antibiotics.append(ab_str) + # Reset untuk obat berikutnya + curr_ab_name = ""; curr_ab_mic = ""; curr_ab_int = "" + + # Detail Obat + elif field.startswith("a2") and len(field) > 2: # Nama Obat (mis: Cefoxitin) + curr_ab_name = field[2:].strip() + elif field.startswith("a3") and len(field) > 2: # MIC (mis: >=4) + curr_ab_mic = field[2:].strip() + elif field.startswith("a4") and len(field) > 2: # Interpretasi (R/S/I) + curr_ab_int = field[2:].strip() + elif field.startswith("an") and len(field) > 2: # Interpretasi Alternatif + if not curr_ab_int: curr_ab_int = field[2:].strip() + + # Jangan lupa simpan obat terakhir yang tersisa di buffer + if curr_ab_name: + ab_str = f"{curr_ab_name} {curr_ab_mic} ({curr_ab_int})" + antibiotics.append(ab_str) + + # --- 4. FORMAT FINAL STRING --- + # Format: "Staphylococcus hominis | Cefoxitin >=4 (R), Gentamicin 4 (S)..." + final_res_string = organism_name + #if antibiotics: + # final_res_string += " | " + ", ".join(antibiotics) + + # Fallback jika negatif (biasanya tidak ada o2, tapi ada teks neg) + #if not final_res_string and "neg" in raw_data.lower(): + # final_res_string = "NEGATIVE / NO GROWTH" + + # --- 5. LOGIKA DATABASE (UPSERT: UPDATE or INSERT) --- + if sample_id: + # Cari data berdasarkan No Lab (Sample ID) + final_seq_no = card_barcode if card_barcode else patient_id + existing_data = session.query(LisPhoenix).filter( + LisPhoenix.seq_no == final_seq_no + ).first() + + if existing_data: + # === SKENARIO PESAN KEDUA (UPDATE) === + print(f"[{port_name}] UPDATE Data -> ID: {sample_id} (Hasil Lengkap)") + new_entry = LisPhoenix( + no_id=sample_id, + seq_no=final_seq_no, + rnmpas=patient_name, + tgl_data=result_date, + rawdt=raw_data, + organisme=final_res_string, + alat=port_name + ) + session.add(new_entry) + + else: + # === SKENARIO PESAN PERTAMA (INSERT) === + print(f"[{port_name}] INSERT Data -> ID: {sample_id} (Identifikasi Awal)") + + new_entry = LisPhoenix( + no_id=sample_id, + seq_no=final_seq_no, + rnmpas=patient_name, + tgl_data=result_date, + rawdt=raw_data, + organisme=final_res_string, + alat=port_name + ) + session.add(new_entry) + + session.commit() + else: + logging.warning(f"[{port_name}] Pesan diabaikan (Tanpa Sample ID): {clean_data[:30]}...") + + except Exception as e: + logging.error(f"Error Parsing Vitek: {e}") + print(f"Error Parsing Vitek: {e}") + session.rollback() + finally: + session.close() + +def split_patient_name(full_name): + """ + Return: last_name, first_name + ASTM format: LAST^FIRST + """ + + if not full_name: + return "NAME", "NO" + + raw = str(full_name).strip() + + if not raw: + return "NAME", "NO" + + first_name = "" + last_name = "" + + # Hapus karakter ASTM berbahaya + raw = ( + raw.replace("\x00", "") + .replace("\r", " ") + .replace("\n", " ") + .replace("|", " ") + .replace("&", " ") + ) + + if "^" in raw: + parts = [p.strip() for p in raw.split("^")] + + last_name = parts[0] if len(parts) > 0 and parts[0] else "NAME" + first_name = parts[1] if len(parts) > 1 and parts[1] else "NO" + + else: + tokens = raw.split() + + if len(tokens) >= 2: + first_name = " ".join(tokens[:-1]) + last_name = tokens[-1] + elif len(tokens) == 1: + first_name = tokens[0] + last_name = "NAME" + + first_name = first_name.upper()[:20] + last_name = last_name.upper()[:20] + + return last_name, first_name + +def sanitize_astm_field(value, *, uppercase=False, max_len=None, allow_component_sep=False): + """ + Sanitasi field agar aman untuk ASTM: + - Buang karakter NULL/control (termasuk CR/LF) + - Hilangkan delimiter ASTM pada field biasa + - Trim dan optional uppercase/truncate + """ + if value is None: + text = "" + else: + text = str(value) + + # Hilangkan NULL byte yang sering muncul dari CHAR/VARCHAR bermasalah. + text = text.replace("\x00", "") + # Buang karakter kontrol non-printable. + text = re.sub(r"[\x00-\x1f\x7f]", " ", text) + + if allow_component_sep: + text = text.replace("|", " ") + else: + text = text.replace("|", " ").replace("^", " ") + + text = re.sub(r"\s+", " ", text).strip() + + # ASTM payload dikirim dengan latin-1; karakter di luar rentang ini diganti aman. + text = text.encode("latin-1", errors="replace").decode("latin-1") + + if uppercase: + text = text.upper() + if max_len is not None: + text = text[:max_len] + return text + +def create_vitek_order_message(order): + """ + Membuat Frame Order Vitek sesuai Manual Ref 514937. + Format: [DATA] [CS] + """ + # --- 1. ISI PESAN (CONTENT) --- + # Field Delimiter menggunakan Pipe '|' + pid = str(order.norm).strip() if order.norm else "" + sid = str(order.rnoreg).strip() if order.rnoreg else "" + 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() + # VITEK: Patient Location Code menggunakan tag "pl" (maks 40 char pada spec yang dipakai) + room = room.replace("|", " ").replace("^", " ")[:40] + + now = datetime.datetime.now() + date_str = now.strftime("%m/%d/%Y") + time_str = now.strftime("%H:%M") + + # Struktur mtmpr sesuai Table 1-3 & Contoh Manual + # Penting: Tidak ada Sequence Number '1' di dalam data + content_body = ( + f"mtmpr|pi{pid}|pn{p_name}" + f"|si|ss{specimen}" + f"|pl{room}" + f"|s1{date_str}|s2{time_str}" + f"|ci{sid}|t11|zz" + ) + + # --- 2. FRAMING & CHECKSUM (Section 2.2 & 2.5) --- + # Frame dimulai dengan STX, lalu Record dimulai dengan RS + STX = b'\x02' + RS = b'\x1e' + GS = b'\x1d' + ETX = b'\x03' + CRLF = b'\r\n' + + # Data yang dihitung Checksumnya: [RS] + [Body] + [GS] + # Manual Hal 2-10: "calculated by adding the value of all characters beginning with the first ... and ending with the " + payload_for_checksum = RS + content_body.encode('latin-1') + GS + + # Hitung Checksum + total_sum = sum(payload_for_checksum) + chk_val = total_sum % 256 + checksum_str = f"{chk_val:02X}".encode('latin-1') # Hex 2 digit uppercase + + # Rakit Frame Utuh + # [PAYLOAD+GS] [CHECKSUM] + # Perhatikan: Payload di atas sudah mengandung RS dan GS + full_frame = STX + payload_for_checksum + checksum_str + ETX + CRLF + + return [full_frame] + +def manage_vitek_port(config): + port_name = config['port'] + flag_col = config.get('flag_column') + alat_name = config.get('alat_name', 'VITEK') + + print(f"[{port_name}] START VITEK SERVICE (Relaxed Mode)...") + STUCK_WINDOW_SEC = 120 + stuck_count = 0 + stuck_window_start = None + while True: + try: + with serial.Serial( + port=port_name, + baudrate=config['baud_rate'], + timeout=2, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + xonxoff=False, + rtscts=False, + dsrdtr=False + ) as ser: + + ser.reset_input_buffer() + print(f"[{port_name}] Ready & Listening.") + + while True: + # ========================================== + # PHASE 1: LISTENING + # ========================================== + if ser.in_waiting > 0: + header = ser.read(1) + + # --- HANDSHAKE --- + if header == b'\x05': + print(f"[{port_name}] Got ENQ -> Reply ACK") + ser.write(b'\x06') + ser.reset_input_buffer() + + # --- DATA FRAME --- + elif header == b'\x02': + print(f"[{port_name}] Frame Start. Reading...") + original_timeout = ser.timeout + ser.timeout = 8 + body = ser.read_until(b'\x03') + ser.timeout = original_timeout + + full_frame = header + body + + + is_valid = False + + if full_frame.endswith(b'\x03'): + is_valid = True + elif b'\x1d' in full_frame[-20:]: + is_valid = True + print(f"[{port_name}] Frame tanpa ETX tapi ada Checksum. Menerima paksa...") + if is_valid: + print(f"[{port_name}] Frame OK -> ACK Sent.") + # 1. KIRIM ACK (WAJIB) + ser.write(b'\x06') + stuck_count = 0 + stuck_window_start = None + + # 2. Proses Data + try: + full_str = full_frame.decode('latin-1', errors='ignore') + # Debug + # print(f"[{port_name}] CONTENT: {full_str}") + parse_and_save_vitek_result(full_str, alat_name) + except Exception as e: + logging.error(f"[{port_name}] Parse Err: {e}") + print(f"[{port_name}] Parse Err: {e}") + else: + logging.warning(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") + print(f"[{port_name}] Frame Corrupt/Timeout: {full_frame}") + ser.write(b'\x15') # NAK + + # --- EOT --- + elif header == b'\x04': + logging.info(f"[{port_name}] Session End (EOT).") + print(f"[{port_name}] Session End (EOT).") + ser.reset_input_buffer() + stuck_count = 0 + stuck_window_start = None + + else: + pass + + # ========================================== + # PHASE 2: SENDING ORDER (JIKA IDLE) + # ========================================== + else: + # Kita masuk sini jika ser.in_waiting == 0 (Sepi) + # Pastikan kolom flag diset di config + if flag_col: + session = None + try: + session = SessionLocal() + # Cari order yang belum dikirim + pending_order = session.query(PaslabOrder).filter( + getattr(PaslabOrder, flag_col) == False + ).first() + + if pending_order: + print(f"[{port_name}] Ada Order: {pending_order.rnoreg}...") + + # --- LOGIC HANDSHAKE DENGAN RETRY --- + handshake_success = False + + # Coba kirim ENQ max 3 kali + for attempt in range(3): + ser.reset_input_buffer() + ser.write(b'\x05') # Kirim ENQ + time.sleep(0.5) # Tunggu balasan + + if ser.in_waiting: + resp = ser.read(1) + if resp == b'\x06': # Dapat ACK + handshake_success = True + break + elif resp == b'\x15': # Dapat NAK + time.sleep(1) + else: + # Timeout, alat diam saja + pass + + if handshake_success: + stuck_count = 0 + stuck_window_start = None + # === KIRIM DATA ORDER === + print(f"[{port_name}] Handshake OK. Kirim Frames...") + frames = create_vitek_order_message(pending_order) + all_sent = True + + for frame in frames: + ser.write(frame) + # Tunggu ACK per frame + got_ack = False + wait_start = time.time() + while time.time() - wait_start < 3: + if ser.in_waiting: + if ser.read(1) == b'\x06': + got_ack = True + break + + if not got_ack: + all_sent = False + break + + # Tutup Sesi + ser.write(b'\x04') # EOT + + if all_sent: + print(f"[{port_name}] Order SELESAI Terkirim.") + setattr(pending_order, flag_col, True) + session.commit() + stuck_count = 0 + 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 + print(f"[{port_name}] Alat Sibuk/Stuck. Kirim Force EOT.") + ser.write(b'\x04') + time.sleep(2.0) + now_ts = time.time() + if not stuck_window_start or (now_ts - stuck_window_start) > STUCK_WINDOW_SEC: + stuck_window_start = now_ts + stuck_count = 1 + else: + stuck_count += 1 + if stuck_count >= 3: + # Jika sudah stuck berulang dalam window waktu, restart koneksi serial + logging.critical( + f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." + ) + print( + f"[{port_name}] Stuck berulang ({stuck_count}x/{STUCK_WINDOW_SEC}s). Restart koneksi serial." + ) + try: + ser.reset_input_buffer() + ser.reset_output_buffer() + except Exception: + pass + raise RuntimeError("Vitek stuck berulang, restart port.") + + except Exception as e: + logging.error(f"[{port_name}] Sending Error: {e}") + print(f"[{port_name}] Sending Error: {e}") + finally: + if session: session.close() + + # Sleep penting agar CPU tidak 100% saat idle + time.sleep(0.1) + + + except Exception as e: + logging.critical(f"[{port_name}] Serial Crash: {e}") + print(f"[{port_name}] Serial Crash: {e}") + time.sleep(5) + +# ========================================== +# BECTON DICKINSON (BD) BACTEC PARSER +# ========================================== + +def parse_and_save_bd_result(raw_data, port_name="BD Bactec"): + session = SessionLocal() + try: + # --- LANGKAH 1: REASSEMBLY (JAHIT FRAME) --- + raw_frames = raw_data.split('\x02') + full_content = "" + + for frame in raw_frames: + if not frame: continue + content_chunk = frame + # Hapus Checksum & ETX/ETB + if '\x03' in frame: content_chunk = frame.split('\x03')[0] + elif '\x17' in frame: content_chunk = frame.split('\x17')[0] + + # Hapus Sequence Number di awal + if content_chunk and content_chunk[0].isdigit(): + content_chunk = content_chunk[1:] + + full_content += content_chunk + + # --- LANGKAH 2: PARSING FIELD --- + lines = full_content.split('\r') + + sample_id = None + patient_id = "" + patient_name = "" + specimen_type = "" + result_val = "" + result_date = datetime.datetime.now() + + for line in lines: + line = line.strip() + if not line: continue + fields = line.split('|') + record_type = fields[0] + + # --- PATIENT (P) --- + if record_type == 'P': + # P|Seq|?|PID||Name + if len(fields) > 3: patient_id = fields[3].strip() + if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip() + + # --- ORDER (O) --- + elif record_type == 'O': + # O|Seq|SampleID|...|...|...|...|...|...|...|...|...|...|...|Specimen + if len(fields) > 2: + sample_id = fields[2].replace('^', '').strip() + + # Ambil Spesimen (Biasanya di index 15 / Field 16) + if len(fields) > 15: + specimen_type = fields[15].strip() + + # --- RESULT (R) --- + elif record_type == 'R': + # R|Seq|Test|Result|...|...|...|...|...|...|StartDate|EndDate + if len(fields) > 3: + raw_res = fields[3].strip() + # Bersihkan hasil + if "NEGATIVE" in raw_res: result_val = "NEGATIVE" + elif "POSITIVE" in raw_res: result_val = "POSITIVE" + else: result_val = raw_res.split('^')[0] # Ambil kode depan saja + + # Ambil Tanggal Hasil Selesai (Index 12 / Field 13) + if len(fields) > 12 and len(fields[12]) >= 14: + try: + # Format BD: YYYYMMDDHHMMSS (20251006100257) + res_dt_str = fields[12][:14] + result_date = datetime.datetime.strptime(res_dt_str, "%Y%m%d%H%M%S") + except: + pass # Gunakan default jika gagal parse + + # --- LANGKAH 3: FORMAT FINAL & SAVE --- + if sample_id and result_val: + + final_res_string = result_val + if specimen_type: + final_res_string += f" ({specimen_type})" + + print(f"[{port_name}] Save DB -> ID: {sample_id}, Pasien: {patient_name}, Hasil: {final_res_string}") + new_entry = LisPhoenix( + no_id=sample_id, + seq_no=patient_id, + rnmpas=patient_name, + tgl_data=result_date, + rawdt=raw_data, + organisme=final_res_string, + alat=port_name + ) + session.add(new_entry) + session.commit() + else: + logging.warning(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") + print(f"[{port_name}] Data tidak lengkap. ID: {sample_id}, Res: {result_val}") + + except Exception as e: + logging.error(f"Error Parsing BD: {e}") + print(f"Error Parsing BD: {e}") + session.rollback() + finally: + session.close() + +def calculate_astm_checksum(frame_content): + data_bytes = frame_content.encode('latin-1') + checksum = sum(data_bytes) % 256 + return f"{checksum:02X}" + +def create_astm_order_message(order): + """ + Membuat 1 Frame ASTM Single Block (H, P, O, L) dengan Mapping Index PRESISI. + Menghindari pergeseran kolom (shifting error). + """ + # --- 1. PERSIAPAN DATA --- + pid = sanitize_astm_field(order.norm, max_len=32) + sid = sanitize_astm_field(order.rnoreg, max_len=32) + # Nama pasien dipisah agar mengikuti format ASTM: Last^First + first_name, last_name = split_patient_name(sanitize_astm_field(order.nama, max_len=80)) + first_name = sanitize_astm_field(first_name, uppercase=True, max_len=20) + last_name = sanitize_astm_field(last_name, uppercase=True, max_len=20) + p_name = f"{last_name}^{first_name}" + sex_raw = sanitize_astm_field(order.rjenis, uppercase=True, max_len=10) + sex = "M" if sex_raw.startswith("L") else "F" + + # Lokasi / Ruangan (Field 26) sebaiknya berupa kode singkat agar tidak ditrunkasi LIS. + raw_location = sanitize_astm_field(getattr(order, 'ruangan', "UT"), uppercase=True, max_len=10) + location = re.sub(r"[^A-Z0-9]", "", raw_location)[:10] + if not location: + location = "UT" + + # Diagnosis (Clinical Info - Field 14 di ASTM standar atau 13 di beberapa varian) + # Kita pasang di Index 13 (Field 14) agar aman + diagnosis = sanitize_astm_field(getattr(order, 'diagnosa', "Unspecified"), max_len=60) + if not diagnosis: diagnosis = "Unspecified" + + # Specimen Info (Field 16) + # Format: SpecimenType^BodySite^Container^Condition + specimen_type = sanitize_astm_field(order.kd_spesimen, uppercase=True, max_len=20) if order.kd_spesimen else "BLOOD" + body_site = "VENA" # Site + condition = "BAIK" # Condition + specimen_field = f"{specimen_type}^{body_site}^^{condition}" + + # --- 2. KONSTRUKSI RECORD DENGAN INDEX PASTI --- + + # --- RECORD HEADER (H) --- + h_rec = [""] * 14 + + h_rec[0] = "H" # H,1 Record Type + h_rec[1] = r"\^&" # H,2 Delimiter + h_rec[4] = "MyLIS" # H,5 Sender Name + h_rec[12] = "V1.00" # H,13 Version + h_rec[13] = datetime.datetime.now().strftime("%Y%m%d%H%M%S") # H,14 Message DateTime + + head = "|".join(h_rec) + # --- RECORD PATIENT (P) --- + # Kita buat array kosong sebanyak 30 kolom dulu + p_rec = [""] * 35 + p_rec[0] = "P" # Field 1: Record Type + p_rec[1] = "1" # Field 2: Sequence + p_rec[2] = pid # Field 3: Patient ID (Practice) + p_rec[3] = pid # Field 4: Lab ID (Kosong) + p_rec[4] = "" # Field 5: ID 3 (Kosong) + p_rec[5] = p_name # Field 6: Patient Name (Index 5) <--- SEBELUMNYA SALAH DISINI + p_rec[7] = "" # Field 8: Birthdate + p_rec[8] = sex # Field 9: Sex (Index 8) + # ... Field 10-25 biarkan kosong ... + p_rec[25] = "1" # Field 26: Location (Index 25) + p_rec[32] = "MIKRO" # Hospital Service + p_rec[33] = raw_location# Hospital Client (Raw, untuk referensi internal) + + # Potong array sampai index 26 saja (sisanya buang) lalu gabung + pat_str = "|".join(p_rec[:35]) + + # --- RECORD ORDER (O) --- + o_rec = [""] * 30 + o_rec[0] = "O" # Field 1 + o_rec[1] = "1" # Field 2 + o_rec[2] = sid # Field 3: Sample ID + o_rec[3] = "" # Field 4: Instrument Specimen ID + o_rec[4] = "^^^" # Field 5: Universal Test ID + o_rec[5] = "R" # Field 6: Priority + # ... Field 7-11 ... + o_rec[11] = "A" # Field 12: Action Code (A=Add, N=New) (Index 11) + o_rec[12] = diagnosis # Field 13: Clinical Info / Diagnosis (Index 12) + # ... Field 14-15 ... + o_rec[15] = specimen_field # Field 16: Specimen Source (Index 15) + + # Potong array sampai index 16 (atau lebih jika BD butuh field belakang) + # Kita ambil aman sampai field 20 + ord_str = "|".join(o_rec[:20]) + + # --- RECORD TERMINATOR (L) --- + term = "L|1|N" + + # --- 3. GABUNG FRAME --- + # Gunakan \r (Carriage Return) sebagai pemisah record + message_content = f"{head}\r{pat_str}\r{ord_str}\r{term}" + + # Sequence Frame = 1 + seq = "1" + + # Isi Frame: [Seq] [Data] [ETX] + frame_body = f"{seq}{message_content}\r\x03" + + # Hitung Checksum + chk = calculate_astm_checksum(frame_body) + + # Full Frame + full_frame = f"\x02{frame_body}{chk}\r\n" + + return [full_frame.encode('latin-1')] + +def manage_bd_port(config): + port_name = config['port'] + flag_col = config.get('flag_column') + alat_name = config.get('alat_name', 'BD') + print(f"[{port_name}] Membuka port untuk alat {alat_name}...") + + # Buffer untuk menampung pecahan data + rx_buffer = "" + + try: + with serial.Serial( + port=port_name, + baudrate=config['baud_rate'], + timeout=1 + ) as ser: + + while True: + has_activity = False # Penanda agar kita sleep kalau sepi + + # ========================================== + # PHASE 1: LISTENING (PRIORITAS UTAMA) + # ========================================== + try: + if ser.in_waiting > 0: + has_activity = True + data_chunk = ser.read(ser.in_waiting or 1024) + + if data_chunk: + try: + # 1. Handle Handshake Awal (ENQ) + if b'\x05' in data_chunk: + print(f"[{port_name}] Terima ENQ (Alat mau kirim data). Kirim ACK.") + ser.write(b'\x06') # ACK + rx_buffer = "" # Reset buffer untuk data baru + data_chunk = data_chunk.replace(b'\x05', b'') + + # 2. Simpan semua byte data yang tersisa. + # Chunk lanjutan frame bisa datang tanpa STX, jadi tidak boleh dibuang. + chunk_without_eot = data_chunk.replace(b'\x04', b'') + if chunk_without_eot: + rx_buffer += chunk_without_eot.decode('latin-1', errors='ignore') + + # 3. Handle Data Frame Normal (STX ... ETX/ETB) + # ASTM butuh ACK setiap frame baru agar alat lanjut kirim frame berikutnya. + if b'\x02' in data_chunk: + ser.write(b'\x06') + # logging.debug(f"[{port_name}] Frame diterima, ACK dikirim.") + print(f"[{port_name}] Frame diterima, ACK dikirim.") + + # 4. Handle Akhir Transmisi (EOT) + if b'\x04' in data_chunk: + print(f"[{port_name}] Terima EOT (Selesai). Memproses data...") + parse_and_save_bd_result(rx_buffer, alat_name) + rx_buffer = "" # Kosongkan buffer setelah save + + except Exception as e: + logging.error(f"Error decode frame: {e}") + print(f"Error decode frame: {e}") + + continue # Loop lagi untuk ambil sisa data + + except Exception as e: + logging.error(f"[{port_name}] Error Reading: {e}") + print(f"[{port_name}] Error Reading: {e}") + rx_buffer = "" # Reset jika error parah + + + # ========================================== + # PHASE 2: SENDING ORDER (JIKA BUFFER KOSONG) + # ========================================== + # Kita hanya kirim order jika sedang tidak menerima data (buffer kosong) + if not rx_buffer and flag_col: + try: + session = SessionLocal() + # Cari order yang belum dikirim + pending_order = session.query(PaslabOrder).filter( + getattr(PaslabOrder, flag_col) == False + ).first() + + if pending_order: + has_activity = True # Jangan sleep lama-lama + print(f"[{port_name}] Menemukan Order: {pending_order.rnoreg}. Memulai Handshake...") + # --- STEP 1: HANDSHAKE (ENQ) --- + ser.reset_input_buffer() + ser.write(b'\x05') + time.sleep(0.5) + + ack_response = ser.read(1) + + if ack_response == b'\x06': + print(f"[{port_name}] Handshake Sukses (Dapat ACK). Menunggu alat siap...") + # --- PERBAIKAN 1: BERI JEDA SETELAH HANDSHAKE --- + # Mesin butuh napas sebelum terima data panjang + time.sleep(1.5) # Jeda 1.5 detik + + frames = create_astm_order_message(pending_order) + all_frames_sent = True + + # --------------------------------------------------- + # STEP 2: SEND FRAMES WITH RETRY + # --------------------------------------------------- + for i, frame in enumerate(frames): + retry_count = 0 + max_retries = 3 + frame_success = False + + while retry_count < max_retries: + ser.reset_input_buffer() + + print(f"[{port_name}] Kirim Frame {i+1} (Percobaan {retry_count+1})...") + ser.write(frame) + + # Tunggu ACK + original_timeout = ser.timeout + ser.timeout = 3 # Beri waktu agak lama (3 detik) untuk alat memproses data + frame_ack = ser.read(1) + ser.timeout = original_timeout + + if frame_ack == b'\x06': # ACK (Sukses) + frame_success = True + 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) + 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 --- + print(f"[{port_name}] Timeout (Alat diam). Menunggu sebelum retry...") + time.sleep(2) # Tunggu 2 detik agar alat recover + retry_count += 1 + + else: + logging.warning(f"[{port_name}] Respon aneh: {frame_ack}") + print(f"[{port_name}] Respon aneh: {frame_ack}") + time.sleep(1) + retry_count += 1 + + if not frame_success: + logging.error(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") + print(f"[{port_name}] Gagal kirim frame ke-{i+1}. Batal.") + all_frames_sent = False + break + # --------------------------------------------------- + # STEP 3: FINALIZE + # --------------------------------------------------- + if all_frames_sent: + ser.write(b'\x04') # EOT (End of Transmission) + print(f"[{port_name}] Order {pending_order.rnoreg} SUKSES Terkirim.") + # Update Database + setattr(pending_order, flag_col, True) + session.commit() + else: + ser.write(b'\x04') # EOT (Putus paksa karena error) + logging.error(f"[{port_name}] Pengiriman Order GAGAL.") + print(f"[{port_name}] Pengiriman Order GAGAL.") + else: + # Jika Handshake gagal (Dibalas NAK, atau Timeout) + logging.warning(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") + print(f"[{port_name}] Handshake Gagal. Respon alat: {ack_response}") + # Jangan update flag DB, biarkan coba lagi nanti + + + session.close() + + except Exception as e: + logging.error(f"[{port_name}] Error Sending Logic: {e}") + print(f"[{port_name}] Error Sending Logic: {e}") + if 'session' in locals(): session.close() + + # ========================================== + # PHASE 3: IDLE MANAGEMENT + # ========================================== + # Jika tidak ada data masuk dan tidak ada order keluar, tidur sebentar + # Ini penting agar CPU tidak 100% dan DB tidak jebol + if not has_activity: + time.sleep(1.0) + + except Exception as e: + logging.critical(f"[{port_name}] Gagal connect Serial: {e}") + print(f"[{port_name}] Gagal connect Serial: {e}") + time.sleep(5) + +# ========================================== +# 5. Serial Manager +# ========================================== + +def manage_serial_port(config): + """Fungsi router yang memilih manajer yang tepat berdasarkan tipe alat.""" + device_type = config.get('device_type') + if device_type == 'vitek': + manage_vitek_port(config) + elif device_type in ['bd_mgit', 'bd_bactec', 'bd']: + manage_bd_port(config) + else: + print(f"Tipe alat tidak diketahui: '{device_type}' untuk port {config.get('port')}. Thread dihentikan.") + +# ========================================== +# 6. MAIN EXECUTION +# ========================================== +if __name__ == "__main__": + print("--- MEMULAI GENE XPERT ONLY LISTENER ---") + + all_threads = [] + t_tcp = threading.Thread(target=manage_tcp_server, name="Manager-TCP-GeneXpert", daemon=True) + t_tcp.start() + all_threads.append(t_tcp) + + try: + while True: + print(f"--- Monitoring {len(all_threads)} Threads ---") + alive_count = 0 + for t in all_threads: + if t.is_alive(): + alive_count += 1 + else: + print(f"!!! THREAD MATI: {t.name} !!!") + + if alive_count == 0: + print("Semua thread GeneXpert mati. System Shutdown.") + break + + time.sleep(10) + + except KeyboardInterrupt: + print("Mematikan GeneXpert Listener (Ctrl+C)...")