import logging import sys import os import asyncio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from contextlib import asynccontextmanager # --- Logging Configuration --- logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] (%(name)s) %(message)s', handlers=[ logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger("main") # --- Import sub-routers --- from customer import router as customer_router from supplier import router as supplier_router from setting import router as setting_router from inventory import router as inventory_router from equipment import router as equipment_router from plan import router as plan_router # --- Import MQTT lifecycle controller --- from communication import start_mqtt_client, stop_mqtt_client async def monitor_timeouts_and_heartbeats(): """ACCEPTED/RECEIVED 제어 명령 타임아웃(30초) 및 하트비트 타임아웃(15초)을 검사하는 백그라운드 태스크""" from database.connection import get_db_connection from communication.mqtt_client import _publish_hmi_update import logging loop_logger = logging.getLogger("timeout_monitor") loop_logger.info("[TIMEOUT MONITOR] Background monitor task started.") while True: try: await asyncio.sleep(5) with get_db_connection() as conn: with conn.cursor() as cursor: # 1. 30초 초과 ACCEPTED or RECEIVED 상태인 명령 검색 및 TIMEOUT 업데이트 & WebSocket 브로드캐스트 cursor.execute(""" SELECT command_id FROM control_commands WHERE command_status IN ('ACCEPTED', 'RECEIVED') AND created_at < DATE_SUB(NOW(), INTERVAL 30 SECOND) """) timeout_cmds = [row[0] for row in cursor.fetchall()] if timeout_cmds: for cmd_id in timeout_cmds: cursor.execute(""" UPDATE control_commands SET command_status = 'TIMEOUT', applied_at = CURRENT_TIMESTAMP WHERE command_id = %s """, (cmd_id,)) conn.commit() loop_logger.info(f"[TIMEOUT MONITOR] Marked {len(timeout_cmds)} commands as TIMEOUT.") # WebSocket으로 프론트엔드에 전달하여 무한 대기 스피너 방지 from communication.websocket_manager import ws_manager import time loop = getattr(ws_manager, 'loop', None) if loop and loop.is_running(): for cmd_id in timeout_cmds: try: asyncio.run_coroutine_threadsafe( ws_manager.broadcast({ "type": "command_ack", "command_id": cmd_id, "status": "TIMEOUT", "applied_value": None, "timestamp": int(time.time() * 1000) }), loop ) except Exception as ws_err: loop_logger.error(f"[TIMEOUT WS BROADCAST ERROR] {ws_err}") # 2. 15초 초과 heartbeat 부재 시 gateway/plc/device_state를 OFFLINE으로 전환 cursor.execute(""" SELECT equipment_id FROM machine_status WHERE gateway_state = 'ONLINE' AND timestamp < DATE_SUB(NOW(), INTERVAL 15 SECOND) """) offline_ids = [row[0] for row in cursor.fetchall()] if offline_ids: for eq_id in offline_ids: cursor.execute(""" UPDATE machine_status SET gateway_state = 'OFFLINE', plc_state = 'OFFLINE', device_state = 'OFFLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s """, (eq_id,)) conn.commit() loop_logger.warning(f"[TIMEOUT MONITOR] Gateway offline detected for equipment IDs: {offline_ids}. Updated statuses.") # HMI 웹소켓 브로드캐스트 위임 for eq_id in offline_ids: try: _publish_hmi_update(eq_id) except Exception as ws_err: loop_logger.error(f"[TIMEOUT MONITOR WS ERROR] {ws_err}") except asyncio.CancelledError: loop_logger.info("[TIMEOUT MONITOR] Stopped.") break except Exception as e: loop_logger.error(f"[TIMEOUT MONITOR ERROR] {e}", exc_info=True) # --- Lifespan Context Manager --- @asynccontextmanager async def lifespan(app: FastAPI): # Startup 이벤트: MQTT 백그라운드 구동 및 DB 기초 데이터 시딩 logger.info("Starting up FastAPI application...") # 필수 설비 데이터(ID=8, 양산 1호기) 자동 생성 (DB가 비어있을 때 FK 에러 방지) try: from database.connection import get_db_connection with get_db_connection() as conn: with conn.cursor() as cursor: # control_commands 테이블 자동 생성 logger.info("[SEED] Ensuring control_commands table exists...") cursor.execute(""" CREATE TABLE IF NOT EXISTS control_commands ( id INT AUTO_INCREMENT PRIMARY KEY, equipment_id INT NOT NULL, command_id VARCHAR(36) NOT NULL UNIQUE, type VARCHAR(10) NOT NULL, requested_value JSON NOT NULL, applied_value JSON NULL, command_status VARCHAR(15) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, applied_at TIMESTAMP NULL, FOREIGN KEY (equipment_id) REFERENCES equipment(id) ) """) conn.commit() # machine_status 테이블 컬럼 확인 및 추가 (장애 상태 분리) cursor.execute("SHOW COLUMNS FROM machine_status") column_rows = cursor.fetchall() cols = [col[0] for col in column_rows] column_types = {col[0]: str(col[1]).lower() for col in column_rows} if "gateway_state" not in cols: logger.info("[SEED] Adding gateway_state to machine_status table...") cursor.execute("ALTER TABLE machine_status ADD COLUMN gateway_state VARCHAR(10) DEFAULT 'OFFLINE'") if "plc_state" not in cols: logger.info("[SEED] Adding plc_state to machine_status table...") cursor.execute("ALTER TABLE machine_status ADD COLUMN plc_state VARCHAR(10) DEFAULT 'OFFLINE'") if "sync_state" not in cols: logger.info("[SEED] Adding sync_state to machine_status table...") cursor.execute("ALTER TABLE machine_status ADD COLUMN sync_state VARCHAR(10) DEFAULT 'IDLE'") # D read groups 1~4 and 6~9 are PLC REAL values and must retain decimals. for group_id in (1, 2, 3, 4, 6, 7, 8, 9): column_name = f"d_read_g{group_id}" if column_types.get(column_name) != "double": cursor.execute( f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0" ) for group_id in range(4): column_name = f"d_write_g{group_id}" if column_types.get(column_name) != "double": cursor.execute( f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0" ) conn.commit() cursor.execute("SELECT id FROM equipment WHERE id = 8") if not cursor.fetchone(): logger.info("[SEED] Seeding default equipment with ID 8...") cursor.execute( "INSERT INTO equipment (id, name, status) VALUES (8, '양산 1호기 (CC_MC_TYPE_A)', 'available')" ) import json initial_m_read = json.dumps({str(i): 0 for i in range(208)}) initial_m_write = json.dumps({str(i): 0 for i in range(188)}) cursor.execute( "INSERT INTO machine_status (equipment_id, device_state, m_read_raw, m_write_raw) VALUES (8, 'OFFLINE', %s, %s)", (initial_m_read, initial_m_write) ) conn.commit() logger.info("[SEED] Seeding complete with explicit 0-initialized M-bit fields.") except Exception as se: logger.error(f"[SEED] Database seeding failed (check if database is running): {se}") # 비동기 이벤트 루프 캡처하여 웹소켓 매니저에 보관 (타 스레드 호출 목적) import asyncio from communication.websocket_manager import ws_manager ws_manager.loop = asyncio.get_event_loop() start_mqtt_client() # 타임아웃 및 하트비트 감시 백그라운드 태스크 기동 timeout_task = asyncio.create_task(monitor_timeouts_and_heartbeats()) yield # Shutdown 이벤트: MQTT 연결 정상 종료 및 백그라운드 태스크 정리 logger.info("Shutting down FastAPI application...") timeout_task.cancel() try: await timeout_task except asyncio.CancelledError: pass stop_mqtt_client() # --- FastAPI Initialization --- app = FastAPI( title="CC MC Carbon Cutting MC MES API Server", description="카본절단 MC 설비 제어 및 MES 통합 백엔드 REST API & MQTT 서버", version="1.0.0", lifespan=lifespan ) # --- CORS Middleware --- app.add_middleware( CORSMiddleware, allow_origins=["*"], # 모바일 브라우저 및 웹앱 개발 편의를 위해 모든 오리진 허용 (배포 시 운영 사양에 맞춤) allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Sub-routers Mapping --- app.include_router(customer_router) app.include_router(supplier_router) app.include_router(setting_router) app.include_router(inventory_router) app.include_router(equipment_router, prefix="/api") app.include_router(plan_router) @app.get("/api/status") def read_root(): return { "status": "online", "service": "ChemiFactory MQTT & REST API server", "docs_url": "/docs" } # --- WebSocket Endpoint for Real-time HMI --- from fastapi import WebSocket, WebSocketDisconnect from communication.websocket_manager import ws_manager @app.websocket("/ws/equipment") async def websocket_endpoint(websocket: WebSocket): await ws_manager.connect(websocket) try: while True: # 클라이언트로부터 메시지 수신 대기 (연결 유지 목적) data = await websocket.receive_text() # 필요 시 클라이언트 메시지 핸들링 가능 (여기서는 단순 에코 또는 무시) except WebSocketDisconnect: ws_manager.disconnect(websocket) except Exception as e: logger.error(f"[WS] Connection error: {e}") ws_manager.disconnect(websocket) # --- Frontend Static Files Serving --- # server/frontend 디렉토리가 없으면 자동 생성하여 에러 방지 frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend") if not os.path.exists(frontend_dir): os.makedirs(frontend_dir) # 캐시 제어 미들웨어: theme.js와 base.css는 항상 최신 버전을 로드하도록 설정 @app.middleware("http") async def cache_control_middleware(request, call_next): response = await call_next(request) # 테마 및 CSS 파일은 캐시하지 않음 (개발/배포 환경에서 변경 즉시 반영) if request.url.path.endswith((".js", ".css")): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") if __name__ == "__main__": import uvicorn # Synology 포트 포워딩 간섭 및 충돌 방지를 위해 미사용 포트인 8999 포트 사용 # NAS 운영에서는 reload=False (Uvicorn 재로더 자식 프로세스가 포트를 점유하는 문제 방지) uvicorn.run("main:app", host="0.0.0.0", port=8999, reload=False) #from fastapi import FastAPI # #app = FastAPI()