초기화

This commit is contained in:
2026-07-15 18:37:19 +09:00
commit 94abc5461d
1268 changed files with 380198 additions and 0 deletions
+288
View File
@@ -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()