Files
Control_Panel/server/main.py
T
2026-07-15 18:37:19 +09:00

288 lines
13 KiB
Python

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()