초기화
This commit is contained in:
+288
@@ -0,0 +1,288 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user