Ver código fonte

更新设备自检功能

liuq 3 meses atrás
pai
commit
4360b5c23f
14 arquivos alterados com 874 adições e 7 exclusões
  1. 33 2
      api/door.py
  2. 37 1
      api/ha.py
  3. 34 1
      api/led.py
  4. 39 1
      api/main.py
  5. 25 2
      api/mitv.py
  6. 25 0
      api/pc.py
  7. 26 0
      api/utils.py
  8. 184 0
      application/scheduler_service.py
  9. 166 0
      application/self_check_service.py
  10. 10 0
      email_config.yaml
  11. 5 0
      flask_api.py
  12. 1 0
      requirements.txt
  13. 3 0
      templates/base.html
  14. 286 0
      templates/self_check.html

+ 33 - 2
api/door.py

@@ -1,7 +1,7 @@
 from flask import Blueprint, jsonify, request
-from hardware.door_module import set_emergency_control, open_door_control
+from hardware.door_module import set_emergency_control, open_door_control, load_config
 from utils.logger_config import logger
-from api.utils import login_required
+from api.utils import login_required, ping_ip
 import threading
 
 door_bp = Blueprint('door', __name__)
@@ -95,3 +95,34 @@ def door_open_api():
             "message": f"远程开门失败: {str(e)}"
         }), 500
 
+
+from application.self_check_service import check_door_status
+
+@door_bp.route('/api/door/self_check', methods=['POST'])
+@login_required
+def self_check_api():
+    """门禁自检"""
+    try:
+        results = check_door_status()
+        
+        # 检查是否有错误返回
+        if results and "error" in results[0]:
+             return jsonify({
+                "success": False,
+                "message": results[0]["error"],
+                "data": []
+            }), 500
+
+        return jsonify({
+            "success": True,
+            "message": "门禁自检完成",
+            "data": results
+        })
+
+    except Exception as e:
+        logger.error(f"门禁自检异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"门禁自检失败: {str(e)}"
+        }), 500
+

+ 37 - 1
api/ha.py

@@ -6,11 +6,13 @@ from hardware.ha_devices_module import (
     turn_on_exhibition_desktop_switch, turn_off_exhibition_desktop_switch,
     turn_on_exhibition_3d_fan, turn_off_exhibition_3d_fan,
     turn_on_exhibition_stand_light_strip, turn_off_exhibition_stand_light_strip,
-    turn_on_all, turn_off_all
+    turn_on_all, turn_off_all,
+    ha_device_controller
 )
 from utils.logger_config import logger
 from api.utils import login_required
 import threading
+import concurrent.futures
 
 ha_bp = Blueprint('ha', __name__)
 
@@ -365,3 +367,37 @@ def ha_turn_off_all_api():
             "message": f"关闭所有HA设备失败: {str(e)}"
         }), 500
 
+
+from application.self_check_service import check_ha_status
+
+@ha_bp.route('/api/ha/self_check', methods=['POST'])
+@login_required
+def self_check_api():
+    """
+    HA设备自检
+    检查所有配置的HA设备是否可用(通过调用 HA API 获取状态)
+    """
+    try:
+        results = check_ha_status()
+        
+        # 检查是否有错误返回
+        if results and "error" in results[0]:
+             return jsonify({
+                "success": False,
+                "message": results[0]["error"],
+                "data": []
+            }), 500
+
+        return jsonify({
+            "success": True,
+            "message": "HA设备自检完成",
+            "data": results
+        })
+
+    except Exception as e:
+        logger.error(f"HA设备自检异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"HA设备自检失败: {str(e)}"
+        }), 500
+

+ 34 - 1
api/led.py

@@ -1,7 +1,9 @@
 from flask import Blueprint, jsonify, request
 from application.wled_thread import start_exhibit_led_effect, stop_led_effect, is_effect_running
 from utils.logger_config import logger
-from api.utils import login_required
+from api.utils import login_required, ping_ip
+import yaml
+import os
 
 led_bp = Blueprint('led', __name__)
 
@@ -93,3 +95,34 @@ def stop_led_effect_api():
             "message": f"停止灯效失败: {str(e)}"
         }), 500
 
+
+from application.self_check_service import check_led_status
+
+@led_bp.route('/api/led/self_check', methods=['POST'])
+@login_required
+def self_check_api():
+    """LED控制器自检"""
+    try:
+        results = check_led_status()
+        
+        # 检查是否有错误返回
+        if results and "error" in results[0]:
+             return jsonify({
+                "success": False,
+                "message": results[0]["error"],
+                "data": []
+            }), 500
+
+        return jsonify({
+            "success": True,
+            "message": "LED自检完成",
+            "data": results
+        })
+
+    except Exception as e:
+        logger.error(f"LED自检异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"LED自检失败: {str(e)}"
+        }), 500
+

+ 39 - 1
api/main.py

@@ -1,5 +1,8 @@
-from flask import Blueprint, render_template, send_from_directory, current_app, redirect, url_for
+from flask import Blueprint, render_template, send_from_directory, current_app, redirect, url_for, request, jsonify
 from api.utils import login_required, load_led_config
+from application.scheduler_service import scheduler_service
+from application.self_check_service import run_all_checks
+from utils.logger_config import logger
 
 main_bp = Blueprint('main', __name__)
 
@@ -34,6 +37,41 @@ def ha_page():
     """HA 灯光控制页面"""
     return render_template('ha/index.html', active_page='ha')
 
+@main_bp.route('/self_check')
+@login_required
+def self_check_page():
+    """设备自检页面"""
+    return render_template('self_check.html', active_page='self_check')
+
+@main_bp.route('/api/send_report', methods=['POST'])
+@login_required
+def send_report_api():
+    """手动发送自检报告"""
+    try:
+        data = request.get_json()
+        target_email = data.get('email')
+        
+        # 执行全量自检
+        results = run_all_checks()
+        
+        # 生成 HTML 报告
+        html_report = scheduler_service.format_report_html(results)
+        
+        # 发送邮件
+        subject = "手动触发设备自检报告"
+        scheduler_service.send_email(subject, html_report, receivers=target_email)
+        
+        return jsonify({
+            "success": True,
+            "message": f"自检报告已发送至 {target_email if target_email else '默认邮箱'}"
+        })
+    except Exception as e:
+        logger.error(f"发送报告失败: {e}")
+        return jsonify({
+            "success": False,
+            "message": f"发送报告失败: {str(e)}"
+        }), 500
+
 # 提供上传文件的访问接口
 @main_bp.route('/uploads/<filename>')
 def uploaded_file(filename):

+ 25 - 2
api/mitv.py

@@ -1,8 +1,9 @@
 from flask import Blueprint, jsonify, request
-from hardware.mitvs_module import turn_on_display, turn_off_display, turn_on_all_displays, turn_off_all_displays
+from hardware.mitvs_module import turn_on_display, turn_off_display, turn_on_all_displays, turn_off_all_displays, mitv_controller
 from utils.logger_config import logger
-from api.utils import login_required
+from api.utils import login_required, ping_ip
 import threading
+import concurrent.futures
 
 mitv_bp = Blueprint('mitv', __name__)
 
@@ -137,3 +138,25 @@ def turn_off_all_displays_api():
             "message": f"息屏所有电视失败: {str(e)}"
         }), 500
 
+from application.self_check_service import check_mitv_status
+
+# 电视进行自检
+# 通过ping 所有电视ip进行,返回一个数组,数组中包括是否正常、电视IP。
+@mitv_bp.route('/api/mitv/self_check', methods=['POST'])
+@login_required
+def self_check_api():
+    """电视进行自检"""
+    try:
+        results = check_mitv_status()
+        return jsonify({
+            "success": True,
+            "message": "电视自检完成",
+            "data": results
+        })
+    except Exception as e:
+        logger.error(f"电视进行自检异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"电视进行自检失败: {str(e)}"
+        }), 500
+

+ 25 - 0
api/pc.py

@@ -0,0 +1,25 @@
+from flask import Blueprint, jsonify
+from api.utils import login_required
+from application.self_check_service import check_pc_status
+from utils.logger_config import logger
+
+pc_bp = Blueprint('pc', __name__)
+
+@pc_bp.route('/api/pc/self_check', methods=['POST'])
+@login_required
+def self_check_api():
+    """展厅PC自检"""
+    try:
+        results = check_pc_status()
+        return jsonify({
+            "success": True,
+            "message": "展厅PC自检完成",
+            "data": results
+        })
+    except Exception as e:
+        logger.error(f"展厅PC自检异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"展厅PC自检失败: {str(e)}"
+        }), 500
+

+ 26 - 0
api/utils.py

@@ -3,6 +3,8 @@ from flask import session, request, jsonify, redirect, url_for
 import socket
 import os
 import yaml
+import subprocess
+import platform
 from utils.logger_config import logger
 
 # 配置上传文件夹
@@ -59,3 +61,27 @@ def load_led_config():
         logger.error(f"读取LED配置文件失败: {e}")
         return []
 
+def ping_ip(ip):
+    """
+    Ping IP地址,返回是否通畅
+    """
+    if not ip:
+        return False
+        
+    system_type = platform.system().lower()
+    if system_type == 'windows':
+        # -n 1: 发送1个包
+        # -w 1000: 超时1000ms
+        command = ['ping', '-n', '1', '-w', '1000', ip]
+    else:
+        # -c 1: 发送1个包
+        # -W 1: 超时1s
+        command = ['ping', '-c', '1', '-W', '1', ip]
+    
+    try:
+        # 这里的 stdout/stderr=subprocess.DEVNULL 防止输出干扰日志
+        return subprocess.call(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
+    except Exception as e:
+        logger.error(f"Ping IP {ip} 异常: {e}")
+        return False
+

+ 184 - 0
application/scheduler_service.py

@@ -0,0 +1,184 @@
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from email.header import Header
+import yaml
+import datetime
+from apscheduler.schedulers.background import BackgroundScheduler
+from application.self_check_service import run_all_checks
+from utils.logger_config import logger
+
+class SelfCheckScheduler:
+    def __init__(self):
+        self.scheduler = BackgroundScheduler()
+        self.scheduler.add_job(self.run_daily_check, 'cron', hour=7, minute=50)
+        
+    def start(self):
+        try:
+            self.scheduler.start()
+            logger.info("自检定时任务调度器已启动 (每天 07:50)")
+        except Exception as e:
+            logger.error(f"启动定时任务失败: {e}")
+
+    def load_email_config(self):
+        try:
+            with open('email_config.yaml', 'r', encoding='utf-8') as f:
+                return yaml.safe_load(f).get('email', {})
+        except Exception as e:
+            logger.error(f"读取邮件配置失败: {e}")
+            return {}
+
+    def format_report_html(self, results):
+        """生成HTML格式的检测报告"""
+        html = """
+        <html>
+        <head>
+            <style>
+                body { font-family: Arial, sans-serif; }
+                .module { margin-bottom: 20px; border: 1px solid #ddd; padding: 15px; border-radius: 5px; }
+                .module-title { font-weight: bold; font-size: 1.1em; color: #333; margin-bottom: 10px; background: #f9f9f9; padding: 5px; }
+                .device-table { width: 100%; border-collapse: collapse; }
+                .device-table th, .device-table td { border: 1px solid #eee; padding: 8px; text-align: left; }
+                .online { color: green; font-weight: bold; }
+                .offline { color: red; font-weight: bold; }
+                .summary { margin-bottom: 20px; padding: 15px; background: #f0f8ff; border-radius: 5px; }
+            </style>
+        </head>
+        <body>
+            <h2>🛡️ 系统设备自检报告</h2>
+            <p>生成时间: """ + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """</p>
+        """
+
+        total_devices = 0
+        total_online = 0
+        total_offline = 0
+
+        modules = {
+            "kodi": "电视系统 (Kodi)",
+            "door": "门禁系统",
+            "led": "展品灯座",
+            "ha": "展厅灯光 (HA)",
+            "pc": "展厅PC"
+        }
+
+        details_html = ""
+
+        for key, name in modules.items():
+            device_list = results.get(key, [])
+            module_online = 0
+            module_total = len(device_list)
+            
+            rows = ""
+            for device in device_list:
+                is_online = device.get('is_online', False)
+                if is_online:
+                    module_online += 1
+                    total_online += 1
+                else:
+                    total_offline += 1
+                total_devices += 1
+
+                status_cls = "online" if is_online else "offline"
+                status_text = "在线" if is_online else "离线"
+                
+                extra_info = []
+                if device.get('ip'): extra_info.append(f"IP: {device.get('ip')}")
+                if device.get('entity_id'): extra_info.append(f"Entity: {device.get('entity_id')}")
+                if device.get('error'): extra_info.append(f"Error: {device.get('error')}")
+
+                rows += f"""
+                <tr>
+                    <td>{device.get('name', 'Unknown')} (ID: {device.get('id')})</td>
+                    <td class="{status_cls}">{status_text}</td>
+                    <td>{', '.join(extra_info)}</td>
+                </tr>
+                """
+            
+            if not rows:
+                rows = "<tr><td colspan='3'>未配置设备或检测失败</td></tr>"
+
+            details_html += f"""
+            <div class="module">
+                <div class="module-title">{name} - 在线: {module_online}/{module_total}</div>
+                <table class="device-table">
+                    <tr>
+                        <th>设备名称</th>
+                        <th>状态</th>
+                        <th>详细信息</th>
+                    </tr>
+                    {rows}
+                </table>
+            </div>
+            """
+
+        html += f"""
+            <div class="summary">
+                <strong>总览:</strong> 总设备数 {total_devices},<span class="online">在线 {total_online}</span>,<span class="offline">离线/异常 {total_offline}</span>
+            </div>
+            {details_html}
+        </body>
+        </html>
+        """
+        return html
+
+    def send_email(self, subject, content, receivers=None):
+        config = self.load_email_config()
+        if not config:
+            logger.error("缺少邮件配置,取消发送")
+            return
+
+        smtp_server = config.get('smtp_server')
+        smtp_port = config.get('smtp_port')
+        username = config.get('username')
+        password = config.get('password')
+        sender = config.get('sender')
+        
+        # 如果未传入接收者,则使用配置中的默认接收者
+        if not receivers:
+            receivers = config.get('receivers', [])
+        
+        # 确保 receivers 是列表
+        if isinstance(receivers, str):
+            receivers = [receivers]
+
+        use_ssl = config.get('use_ssl', True)
+
+        if not all([smtp_server, username, password, sender, receivers]):
+            logger.error("邮件配置不完整或无接收者")
+            return
+
+        message = MIMEMultipart()
+        message['From'] = Header(sender, 'utf-8')
+        message['To'] =  Header(",".join(receivers), 'utf-8')
+        message['Subject'] = Header(subject, 'utf-8')
+        
+        message.attach(MIMEText(content, 'html', 'utf-8'))
+
+        try:
+            if use_ssl:
+                server = smtplib.SMTP_SSL(smtp_server, smtp_port)
+            else:
+                server = smtplib.SMTP(smtp_server, smtp_port)
+            
+            server.login(username, password)
+            server.sendmail(sender, receivers, message.as_string())
+            server.quit()
+            logger.info(f"自检报告邮件已发送至: {receivers}")
+        except Exception as e:
+            logger.error(f"发送邮件失败: {e}")
+
+    def run_daily_check(self):
+        logger.info("开始执行每日自检任务...")
+        try:
+            results = run_all_checks()
+            html_report = self.format_report_html(results)
+            self.send_email("每日设备自检报告", html_report)
+        except Exception as e:
+            logger.error(f"每日自检任务异常: {e}")
+
+# 单例
+scheduler_service = SelfCheckScheduler()
+
+def start_scheduler():
+    scheduler_service.start()
+

+ 166 - 0
application/self_check_service.py

@@ -0,0 +1,166 @@
+import concurrent.futures
+import yaml
+import os
+from api.utils import ping_ip
+from hardware.mitvs_module import mitv_controller
+from hardware.ha_devices_module import ha_device_controller
+from hardware.door_module import load_config as load_door_config
+from utils.logger_config import logger
+
+def check_mitv_status():
+    """检查电视(Kodi)状态"""
+    try:
+        servers = mitv_controller.kodi_servers
+        results = []
+        
+        with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
+            future_to_server = {
+                executor.submit(ping_ip, server.get('ip')): server 
+                for server in servers 
+                if server.get('ip')
+            }
+            
+            for future in concurrent.futures.as_completed(future_to_server):
+                server = future_to_server[future]
+                try:
+                    is_online = future.result()
+                    results.append({
+                        "id": server.get('id'),
+                        "ip": server.get('ip'),
+                        "name": server.get('name', f"TV-{server.get('id')}"),
+                        "is_online": is_online
+                    })
+                except Exception as e:
+                    logger.error(f"检查电视状态异常 (ID: {server.get('id')}): {e}")
+                    results.append({
+                        "id": server.get('id'),
+                        "ip": server.get('ip'),
+                        "is_online": False,
+                        "error": str(e)
+                    })
+        
+        results.sort(key=lambda x: x.get('id', 0))
+        return results
+    except Exception as e:
+        logger.error(f"电视自检异常: {str(e)}")
+        return [{"error": str(e)}]
+
+def check_door_status():
+    """检查门禁状态"""
+    try:
+        config = load_door_config()
+        if not config:
+            return [{"error": "无法加载门禁配置"}]
+        
+        ip = config.get('ip')
+        if ip:
+            is_online = ping_ip(ip)
+            return [{
+                "id": 0,
+                "ip": ip,
+                "name": "DoorController",
+                "is_online": is_online
+            }]
+        else:
+            return [{"error": "门禁配置中缺少IP地址"}]
+    except Exception as e:
+        logger.error(f"门禁自检异常: {str(e)}")
+        return [{"error": str(e)}]
+
+def check_led_status():
+    """检查LED控制器状态"""
+    try:
+        config_path = 'led_config.yaml'
+        if not os.path.exists(config_path):
+            return [{"error": "LED配置文件不存在"}]
+
+        with open(config_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f)
+        
+        if not config:
+            return [{"error": "LED配置为空"}]
+            
+        ip = config.get('wled_ip')
+        
+        if ip:
+            is_online = ping_ip(ip)
+            return [{
+                "id": 0,
+                "ip": ip,
+                "name": "WLEDController",
+                "is_online": is_online
+            }]
+        else:
+            return [{"error": "LED配置中缺少IP地址"}]
+    except Exception as e:
+        logger.error(f"LED自检异常: {str(e)}")
+        return [{"error": str(e)}]
+
+def check_ha_status():
+    """检查HA设备状态"""
+    try:
+        devices = ha_device_controller.devices
+        results = []
+        
+        with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
+            future_to_device = {}
+            for key, device_info in devices.items():
+                entity_id = device_info.get('entity_id')
+                if entity_id:
+                    future = executor.submit(ha_device_controller._get_device_state, entity_id)
+                    future_to_device[future] = (key, device_info)
+            
+            for future in concurrent.futures.as_completed(future_to_device):
+                key, device_info = future_to_device[future]
+                try:
+                    state = future.result()
+                    is_online = state is not None
+                    
+                    results.append({
+                        "id": key,
+                        "name": device_info.get('name', key),
+                        "entity_id": device_info.get('entity_id'),
+                        "is_online": is_online,
+                        "state": state if is_online else "unknown"
+                    })
+                except Exception as e:
+                    logger.error(f"检查HA设备状态异常 ({key}): {e}")
+                    results.append({
+                        "id": key,
+                        "name": device_info.get('name', key),
+                        "entity_id": device_info.get('entity_id'),
+                        "is_online": False,
+                        "error": str(e)
+                    })
+
+        results.sort(key=lambda x: x.get('id', ''))
+        return results
+    except Exception as e:
+        logger.error(f"HA设备自检异常: {str(e)}")
+        return [{"error": str(e)}]
+
+def check_pc_status():
+    """检查展厅PC状态"""
+    try:
+        ip = "192.168.189.250"
+        is_online = ping_ip(ip)
+        return [{
+            "id": 0,
+            "ip": ip,
+            "name": "Exhibition-PC",
+            "is_online": is_online
+        }]
+    except Exception as e:
+        logger.error(f"PC自检异常: {str(e)}")
+        return [{"error": str(e)}]
+
+def run_all_checks():
+    """运行所有自检并返回汇总结果"""
+    return {
+        "kodi": check_mitv_status(),
+        "door": check_door_status(),
+        "led": check_led_status(),
+        "ha": check_ha_status(),
+        "pc": check_pc_status()
+    }
+

+ 10 - 0
email_config.yaml

@@ -0,0 +1,10 @@
+email:
+  smtp_server: "smtp.163.com"
+  smtp_port: 465
+  use_ssl: true
+  username: "machine08@163.com"
+  password: "ML2C2E4GzbLFyKQY"
+  sender: "machine08@163.com"
+  receivers:
+    - "m_liuqiang@163.com"
+

+ 5 - 0
flask_api.py

@@ -3,6 +3,7 @@ from flask_cors import CORS
 import os
 from application.kodi_alive_thread import start_kodi_alive_check
 from application.kodi_free_time_thread import start_kodi_free_time_play
+from application.scheduler_service import start_scheduler
 from utils.logger_config import logger
 
 # 导入所有蓝图
@@ -13,6 +14,7 @@ from api.kodi import kodi_bp
 from api.mitv import mitv_bp
 from api.ha import ha_bp
 from api.door import door_bp
+from api.pc import pc_bp
 from api.utils import UPLOAD_FOLDER
 
 app = Flask(__name__)
@@ -35,6 +37,7 @@ app.register_blueprint(kodi_bp)
 app.register_blueprint(mitv_bp)
 app.register_blueprint(ha_bp)
 app.register_blueprint(door_bp)
+app.register_blueprint(pc_bp)
 
 if __name__ == '__main__':
     # 创建templates目录
@@ -44,6 +47,8 @@ if __name__ == '__main__':
     start_kodi_alive_check()
     logger.info("启动Kodi空闲时间播放")
     start_kodi_free_time_play()
+    logger.info("启动每日自检定时任务")
+    start_scheduler()
     logger.info("Flask API服务器启动中...")
     logger.info("访问 http://localhost:5050 查看HTML页面")
     logger.info("API端点已通过蓝图注册")

+ 1 - 0
requirements.txt

@@ -4,3 +4,4 @@ Werkzeug==2.3.7
 requests==2.32.5
 PyYAML==6.0.3
 loguru==0.7.3
+APScheduler==3.11.2

+ 3 - 0
templates/base.html

@@ -288,6 +288,9 @@
             <a href="{{ url_for('main.ha_page') }}" class="nav-item {% if active_page == 'ha' %}active{% endif %}">
                 <span>💡</span> 展厅灯光
             </a>
+            <a href="{{ url_for('main.self_check_page') }}" class="nav-item {% if active_page == 'self_check' %}active{% endif %}">
+                <span>🛡️</span> 设备自检
+            </a>
         </nav>
         <div class="sidebar-footer">
             <a href="/logout" class="logout-btn">退出登录</a>

+ 286 - 0
templates/self_check.html

@@ -0,0 +1,286 @@
+{% extends "base.html" %}
+
+{% block title %}系统设备自检{% endblock %}
+
+{% block content %}
+    <div class="module-header">
+        <h2>🛡️ 系统设备自检</h2>
+    </div>
+
+    <div class="sub-section">
+        <div style="background: white; padding: 25px; border-radius: 10px; border: 1px solid #eee; text-align: center;">
+            <p style="color: #666; margin-bottom: 20px; font-size: 1.1em;">
+                点击下方按钮开始对所有连接的设备进行状态检查。<br>
+                检查项目包括:电视(Kodi)连通性、门禁控制器、展品LED控制器、以及Home Assistant设备状态。
+            </p>
+            <button id="btnStartCheck" class="btn btn-primary" style="font-size: 1.2em; padding: 15px 40px; border-radius: 50px; box-shadow: 0 4px 15px rgba(78, 205, 196, 0.4);" onclick="startFullSelfCheck()">
+                🚀 开始全面自检
+            </button>
+            <div id="progressArea" style="display: none; margin-top: 20px;">
+                <div style="color: #2c3e50; font-weight: bold; margin-bottom: 10px;">正在检测中...</div>
+                <div style="width: 100%; height: 10px; background: #eee; border-radius: 5px; overflow: hidden; max-width: 500px; margin: 0 auto;">
+                    <div id="progressBar" style="width: 0%; height: 100%; background: var(--primary-color); transition: width 0.3s;"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div id="reportArea" style="display: none;">
+        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
+            <h3 style="color: #2c3e50; margin: 0;">📋 自检报告</h3>
+            <span id="checkTime" style="color: #999; font-size: 0.9em;"></span>
+        </div>
+
+        <!-- 概览卡片 -->
+        <div style="display: flex; gap: 20px; margin-bottom: 30px; flex-wrap: wrap;">
+            <div class="summary-card" id="summaryTotal">
+                <div class="label">总设备数</div>
+                <div class="value">0</div>
+            </div>
+            <div class="summary-card" id="summaryOnline">
+                <div class="label">在线</div>
+                <div class="value" style="color: #2ecc71;">0</div>
+            </div>
+            <div class="summary-card" id="summaryOffline">
+                <div class="label">离线/异常</div>
+                <div class="value" style="color: #e74c3c;">0</div>
+            </div>
+        </div>
+
+        <!-- 邮件发送区域 -->
+        <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 25px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
+            <div style="flex: 1; min-width: 250px;">
+                <label for="reportEmail" style="font-weight: bold; color: #555; margin-right: 10px;">📧 发送报告到邮箱:</label>
+                <input type="email" id="reportEmail" placeholder="留空则发送给默认接收者" style="padding: 8px; border: 1px solid #ddd; border-radius: 4px; width: 250px;">
+            </div>
+            <button class="btn btn-info" onclick="sendReport()">📤 发送报告</button>
+        </div>
+
+        <!-- 详细结果 -->
+        <div id="detailResults">
+            <!-- 动态生成 -->
+        </div>
+    </div>
+{% endblock %}
+
+{% block extra_styles %}
+    .summary-card {
+        flex: 1;
+        min-width: 150px;
+        background: white;
+        padding: 20px;
+        border-radius: 10px;
+        box-shadow: 0 4px 6px rgba(0,0,0,0.05);
+        text-align: center;
+        border: 1px solid #eee;
+    }
+    .summary-card .label { color: #888; margin-bottom: 5px; font-size: 0.9em; }
+    .summary-card .value { font-size: 2em; font-weight: bold; color: #333; }
+    
+    .module-report {
+        background: white;
+        border-radius: 10px;
+        box-shadow: 0 4px 6px rgba(0,0,0,0.05);
+        margin-bottom: 25px;
+        overflow: hidden;
+    }
+    .module-report-header {
+        padding: 15px 20px;
+        background: #f8f9fa;
+        border-bottom: 1px solid #eee;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+    }
+    .module-title { font-weight: bold; font-size: 1.1em; color: #2c3e50; }
+    .module-stats { font-size: 0.9em; color: #666; }
+    
+    .device-list { padding: 0; }
+    .device-item {
+        padding: 12px 20px;
+        border-bottom: 1px solid #f0f0f0;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+    }
+    .device-item:last-child { border-bottom: none; }
+    .device-info { flex: 1; }
+    .device-name { font-weight: 600; color: #444; }
+    .device-meta { font-size: 0.85em; color: #999; margin-top: 2px; }
+    .device-status { font-weight: bold; display: flex; align-items: center; gap: 6px; font-size: 0.9em; }
+    
+    .status-online { color: #2ecc71; }
+    .status-offline { color: #e74c3c; }
+{% endblock %}
+
+{% block scripts %}
+<script>
+    async function startFullSelfCheck() {
+        const btn = document.getElementById('btnStartCheck');
+        const progressArea = document.getElementById('progressArea');
+        const progressBar = document.getElementById('progressBar');
+        const reportArea = document.getElementById('reportArea');
+        
+        btn.disabled = true;
+        btn.style.opacity = '0.7';
+        btn.innerHTML = '⏳ 检测进行中...';
+        progressArea.style.display = 'block';
+        reportArea.style.display = 'none';
+        progressBar.style.width = '10%';
+
+        // 定义检测任务
+        const tasks = [
+            { id: 'kodi', name: '电视系统 (Kodi)', url: '/api/mitv/self_check' },
+            { id: 'door', name: '门禁系统', url: '/api/door/self_check' },
+            { id: 'led', name: '展品灯座', url: '/api/led/self_check' },
+            { id: 'ha', name: '展厅灯光 (HA)', url: '/api/ha/self_check' },
+            { id: 'pc', name: '展厅PC', url: '/api/pc/self_check' }
+        ];
+
+        let results = {};
+        let completed = 0;
+
+        for (let i = 0; i < tasks.length; i++) {
+            const task = tasks[i];
+            try {
+                const response = await fetch(task.url, {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' }
+                });
+                const json = await response.json();
+                results[task.id] = {
+                    success: json.success,
+                    data: json.data || [],
+                    error: json.success ? null : json.message
+                };
+            } catch (e) {
+                results[task.id] = {
+                    success: false,
+                    data: [],
+                    error: e.message
+                };
+            }
+            
+            completed++;
+            progressBar.style.width = `${10 + (completed / tasks.length) * 90}%`;
+        }
+
+        // 渲染报告
+        renderReport(results, tasks);
+        
+        btn.disabled = false;
+        btn.style.opacity = '1';
+        btn.innerHTML = '🔄 重新开始自检';
+        progressArea.style.display = 'none';
+        reportArea.style.display = 'block';
+        
+        // 滚动到报告区域
+        reportArea.scrollIntoView({ behavior: 'smooth' });
+    }
+
+    function renderReport(results, tasks) {
+        const detailContainer = document.getElementById('detailResults');
+        detailContainer.innerHTML = '';
+        
+        let totalDevices = 0;
+        let totalOnline = 0;
+        let totalOffline = 0;
+        
+        document.getElementById('checkTime').textContent = '检测时间: ' + new Date().toLocaleString();
+
+        tasks.forEach(task => {
+            const result = results[task.id];
+            const deviceList = result.data;
+            
+            let moduleOnline = 0;
+            let moduleTotal = deviceList.length;
+            let contentHtml = '';
+
+            if (!result.success) {
+                 contentHtml = `<div style="padding: 20px; color: #e74c3c; text-align: center;">检测失败: ${result.error || '未知错误'}</div>`;
+                 totalOffline++; // 整个模块失败算一个异常
+            } else if (moduleTotal === 0) {
+                 contentHtml = `<div style="padding: 20px; color: #999; text-align: center;">未配置设备</div>`;
+            } else {
+                contentHtml = '<div class="device-list">';
+                deviceList.forEach(device => {
+                    const isOnline = device.is_online;
+                    if (isOnline) {
+                        moduleOnline++;
+                        totalOnline++;
+                    } else {
+                        totalOffline++;
+                    }
+                    totalDevices++;
+
+                    let extraInfo = [];
+                    if (device.ip) extraInfo.push(`IP: ${device.ip}`);
+                    if (device.entity_id) extraInfo.push(`Entity: ${device.entity_id}`);
+                    if (device.state && device.state !== 'unknown') extraInfo.push(`State: ${device.state}`);
+                    if (device.error) extraInfo.push(`<span style="color: #e74c3c;">Err: ${device.error}</span>`);
+
+                    contentHtml += `
+                        <div class="device-item">
+                            <div class="device-info">
+                                <div class="device-name">${device.name || 'Unknown Device'}</div>
+                                <div class="device-meta">ID: ${device.id} ${extraInfo.length ? '| ' + extraInfo.join(' | ') : ''}</div>
+                            </div>
+                            <div class="device-status ${isOnline ? 'status-online' : 'status-offline'}">
+                                ${isOnline ? '✅ 在线' : '❌ 离线'}
+                            </div>
+                        </div>
+                    `;
+                });
+                contentHtml += '</div>';
+            }
+
+            const moduleHtml = `
+                <div class="module-report">
+                    <div class="module-report-header">
+                        <div class="module-title">${task.name}</div>
+                        <div class="module-stats">
+                            ${result.success ? `在线: <span style="color: ${moduleOnline === moduleTotal && moduleTotal > 0 ? '#2ecc71' : '#666'}">${moduleOnline}</span> / ${moduleTotal}` : '<span style="color: #e74c3c;">检测失败</span>'}
+                        </div>
+                    </div>
+                    ${contentHtml}
+                </div>
+            `;
+            detailContainer.innerHTML += moduleHtml;
+        });
+
+        // 更新概览
+        document.getElementById('summaryTotal').querySelector('.value').textContent = totalDevices;
+        document.getElementById('summaryOnline').querySelector('.value').textContent = totalOnline;
+        document.getElementById('summaryOffline').querySelector('.value').textContent = totalOffline;
+    }
+
+    async function sendReport() {
+        const email = document.getElementById('reportEmail').value.trim();
+        const btn = document.querySelector('button[onclick="sendReport()"]');
+        
+        try {
+            btn.disabled = true;
+            btn.textContent = '发送中...';
+            
+            const response = await fetch('/api/send_report', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ email: email })
+            });
+            
+            const result = await response.json();
+            
+            if (result.success) {
+                showMessage(result.message);
+            } else {
+                showMessage(result.message, 'error');
+            }
+        } catch (error) {
+            showMessage('网络错误: ' + error.message, 'error');
+        } finally {
+            btn.disabled = false;
+            btn.textContent = '📤 发送报告';
+        }
+    }
+</script>
+{% endblock %}