liuq 4 ヶ月 前
コミット
9e4115b3c0

+ 220 - 0
API_DOCUMENTATION.md

@@ -0,0 +1,220 @@
+# API 接口文档
+
+本文档基于 `flask_api.py` 整理,描述了 TV Show 控制系统的后端接口。
+
+**基础信息**
+- **默认端口**: `5050`
+- **认证方式**: 基于 Session 的登录认证 (装饰器 `@login_required`)
+  - 未登录访问 API 接口返回 HTTP 401 及 `{"success": False, "message": "未登录"}`
+  - 未登录访问页面会重定向到 `/login`
+- **数据格式**: 请求和响应主要使用 JSON 格式 (图片上传接口除外)
+
+---
+
+## 1. 认证与基础页面
+
+### 登录
+- **接口地址**: `/login`
+- **请求方式**: `POST`
+- **请求类型**: `application/x-www-form-urlencoded`
+- **请求参数**:
+  - `username`: 用户名 (默认: admin)
+  - `password`: 密码 (默认: HNYZ0821)
+- **响应**: 登录成功重定向至首页,失败返回登录页并提示错误。
+
+### 登出
+- **接口地址**: `/logout`
+- **请求方式**: `GET`
+- **说明**: 清除 Session 并重定向到登录页。
+
+---
+
+## 2. LED 灯效控制
+
+### 获取 LED 状态
+- **接口地址**: `/api/led/status`
+- **请求方式**: `GET`
+- **响应示例**:
+  ```json
+  {
+      "success": true,
+      "data": {
+          "is_running": true,
+          "message": "灯效正在运行"
+      }
+  }
+  ```
+
+### 启动展品灯效
+- **接口地址**: `/api/led/start`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "exhibit_id": 1  // (必填) 展品ID,整数 >= 0
+  }
+  ```
+- **响应示例**:
+  ```json
+  {
+      "success": true,
+      "message": "展品 1 的灯效已启动",
+      "data": { "exhibit_id": 1, "is_running": true }
+  }
+  ```
+
+### 停止灯效
+- **接口地址**: `/api/led/stop`
+- **请求方式**: `POST`
+- **说明**: 停止当前正在运行的灯效。
+- **响应示例**:
+  ```json
+  {
+      "success": true,
+      "message": "灯效已停止",
+      "data": { "is_running": false }
+  }
+  ```
+
+---
+
+## 3. Kodi 播放控制
+
+### 获取 Kodi 播放线程状态
+- **接口地址**: `/api/kodi/status`
+- **请求方式**: `GET`
+- **说明**: 检查后台 Kodi 控制线程是否存活。
+
+### 获取 Kodi 客户端列表
+- **接口地址**: `/api/kodi/clients`
+- **请求方式**: `GET`
+- **响应**: 返回所有配置的 Kodi 客户端详情列表。
+
+### 获取视频列表
+- **接口地址**: `/api/kodi/videos`
+- **请求方式**: `GET`
+- **响应**: 返回可播放的视频列表信息。
+
+### 启动/切换视频播放
+- **接口地址**: `/api/kodi/start`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "video_id": 1,    // (必填) 视频ID,整数 >= 0
+      "volume": 50      // (可选) 音量,0-100 的整数,不填则不改变音量
+  }
+  ```
+- **响应示例**:
+  ```json
+  {
+      "success": true,
+      "message": "Kodi开始/切换播放 视频ID=1 音量=50",
+      "data": { "video_id": 1, "volume": 50 }
+  }
+  ```
+
+### 设置全局音量
+- **接口地址**: `/api/kodi/set_volume`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "volume": 60      // (必填) 音量值,0-100
+  }
+  ```
+
+### 播放图片
+- **接口地址**: `/api/kodi/play_image`
+- **请求方式**: `POST`
+- **说明**: 支持 **文件上传** 或 **URL** 两种方式,同时需要指定客户端索引。
+
+**方式一 (文件上传):**
+- **Content-Type**: `multipart/form-data`
+- **参数**:
+  - `file`: (文件对象) 图片文件 (支持 jpg, png, jpeg, gif, bmp, webp)
+  - `kodi_client_index`: (整数) 客户端索引
+
+**方式二 (JSON URL):**
+- **Content-Type**: `application/json`
+- **参数**:
+  ```json
+  {
+      "image_url": "http://example.com/image.jpg",
+      "kodi_client_index": 0
+  }
+  ```
+
+### 播放 RTSP 视频流
+- **接口地址**: `/api/kodi/play_rtsp`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "rtsp_url": "rtsp://...",  // (必填) RTSP流地址
+      "kodi_client_index": 0,    // (必填) 客户端索引
+      "volume": 0                // (可选) 音量,默认0
+  }
+  ```
+
+### 撤销独立状态
+- **接口地址**: `/api/kodi/revoke_individual_state`
+- **请求方式**: `POST`
+- **说明**: 将所有处于独立播放状态(如播放图片、RTSP)的客户端重置回默认状态。
+
+### 启动所有 Kodi 应用
+- **接口地址**: `/api/kodi/start_all_apps`
+- **请求方式**: `POST`
+- **说明**: 尝试在所有客户端上启动 Kodi 应用程序。
+
+---
+
+## 4. 电视 (MiTV) 电源控制
+
+> **注意**: 以下接口均为异步执行,API 会立即返回成功消息,实际操作在后台进行。
+
+### 唤醒指定电视
+- **接口地址**: `/api/mitv/turn_on`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "kodi_id": 1  // (必填) 对应 Kodi 客户端的 ID/索引
+  }
+  ```
+
+### 息屏指定电视
+- **接口地址**: `/api/mitv/turn_off`
+- **请求方式**: `POST`
+- **请求头**: `Content-Type: application/json`
+- **请求参数**:
+  ```json
+  {
+      "kodi_id": 1
+  }
+  ```
+
+### 唤醒所有电视
+- **接口地址**: `/api/mitv/turn_on_all`
+- **请求方式**: `POST`
+- **说明**: 发送指令唤醒配置中的所有电视。
+
+### 息屏所有电视
+- **接口地址**: `/api/mitv/turn_off_all`
+- **请求方式**: `POST`
+- **说明**: 发送指令息屏配置中的所有电视。
+
+---
+
+## 5. 其他
+
+### 文件访问
+- **接口地址**: `/uploads/<filename>`
+- **请求方式**: `GET`
+- **说明**: 访问通过 `/api/kodi/play_image` 接口上传的图片文件。
+

+ 112 - 7
application/kodi_thread.py

@@ -1,6 +1,6 @@
 import threading
 import time
-from typing import Optional
+from typing import Optional, List, Dict, Any
 
 from hardware.kodi_module import KodiClientManager, VideoInfo
 from utils.logger_config import logger
@@ -35,6 +35,7 @@ class KodiPlayThreadSingleton:
         self._lock_data = threading.Lock()
         self._should_stop: bool = False
         self._incoming_task_id: Optional[int] = None
+        self._incoming_task_volume: Optional[int] = None
 
         self._start_worker_thread()
 
@@ -62,14 +63,16 @@ class KodiPlayThreadSingleton:
             logger.error(f"查找视频信息失败: {e}")
             return None
 
-    def _play_sync_by_video_id(self, video_id: int,loop: bool = False) -> bool:
+    def _play_sync_by_video_id(self, video_id: int, loop: bool = False, volume: int = -1) -> bool:
         video_info = self._lookup_video_info(video_id)
         if video_info is None:
             logger.error(f"无效的视频ID: {video_id}")
             return False
         try:
+            if volume != -1:
+                self.manager.set_volume(volume)
             self.manager.sync_play_video(self.manager.kodi_clients, video_info.video_path, loop=loop)
-            logger.info(f"开始同步播放 视频ID={video_id} 路径={video_info.video_path}")
+            logger.info(f"开始同步播放 视频ID={video_id} 路径={video_info.video_path} 音量={volume}")
             return True
         except Exception as e:
             logger.error(f"同步播放异常: {e}")
@@ -100,18 +103,21 @@ class KodiPlayThreadSingleton:
             try:
                 # 等待直到有新任务
                 task_id: Optional[int] = None
+                task_volume: int = -1
                 while not self._should_stop and task_id is None:
                     with self._lock_data:
                         if self._incoming_task_id is not None:
                             task_id = self._incoming_task_id
+                            task_volume = self._incoming_task_volume
                             self._incoming_task_id = None
+                            self._incoming_task_volume = None
                     if task_id is None:
                         time.sleep(0.05)
                 if self._should_stop:
                     break
 
                 logger.info(f"[主循环] 接到任务,开始播放 视频ID={task_id}")
-                if not self._play_sync_by_video_id(task_id):
+                if not self._play_sync_by_video_id(task_id, volume=task_volume):
                     logger.warning(f"任务 视频ID={task_id} 播放启动失败,跳过")
                     continue
 
@@ -130,6 +136,26 @@ class KodiPlayThreadSingleton:
                 time.sleep(0.5)
         logger.info("KODI任务播放线程结束")
 
+    def _sleep_with_interrupt(self, seconds: int) -> bool:
+        """
+        等待 seconds 秒,如果收到新任务或停止信号,则提前返回 True。
+        否则等待结束返回 False。
+        """
+        end_time = time.time() + seconds
+        while time.time() < end_time:
+            if self._should_stop:
+                return True
+            with self._lock_data:
+                if self._incoming_task_id is not None:
+                    return True
+            time.sleep(0.1)
+        return False
+
+    def stop(self) -> bool:
+        self._should_stop = True
+        self.is_running = False
+        return True
+
     # 撤销所有客户端的独立状态
     def _revoke_individual_state(self):
         try:
@@ -150,12 +176,27 @@ class KodiPlayThreadSingleton:
             logger.error(f"启动所有kodi应用程序异常: {e}")
             return False
 
+    def _set_volume(self, volume: int) -> bool:
+        try:
+            self.manager.set_volume(volume)
+            logger.info(f"设置同步播放音量: {volume}")
+            return True
+        except Exception as e:
+            logger.error(f"设置音量异常: {e}")
+            return False
+
 # 全局单例
 _kodi_thread = KodiPlayThreadSingleton()
 
-def start_kodi_play(video_id: int) -> bool:
+def start_kodi_play(video_id: int, volume: int = -1) -> bool:
     """开始(或切换到)播放指定视频ID(可被新任务打断)。"""
-    return _kodi_thread._play_sync_by_video_id(video_id)
+    with _kodi_thread._lock_data:
+        _kodi_thread._incoming_task_id = video_id
+        _kodi_thread._incoming_task_volume = volume
+    # 如果线程挂了,尝试重启
+    if _kodi_thread.thread is None or not _kodi_thread.thread.is_alive():
+        _kodi_thread._start_worker_thread()
+    return True
 
 def stop_kodi_play() -> bool:
     """停止播放线程与当前播放。"""
@@ -231,4 +272,68 @@ def start_all_kodi_apps() -> bool:
     if _kodi_thread.manager is None:
         logger.error("KodiClientManager 初始化失败")
         return False
-    return _kodi_thread._start_all_kodi_apps()
+    return _kodi_thread._start_all_kodi_apps()
+
+def set_volume(volume: int) -> bool:
+    """设置同步播放音量
+    
+    Args:
+        volume: 音量值 (0-100)
+    
+    Returns:
+        bool: 是否成功设置
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        logger.error("KodiClientManager 初始化失败")
+        return False
+    # 确保音量在有效范围内
+    if volume < 0:
+        volume = 0
+    elif volume > 100:
+        volume = 100
+    return _kodi_thread._set_volume(volume)
+
+def get_kodi_clients() -> List[Dict[str, Any]]:
+    """获取所有 Kodi 客户端列表
+    
+    Returns:
+        list: 包含客户端信息的字典列表
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        return []
+    
+    clients_list = []
+    for index, client in enumerate(_kodi_thread.manager.kodi_clients):
+        # 假设 ID 对应 0, 1, 2...
+        # 用户需求:左边计数起算一号电视 (ID: 0 -> 1号电视)
+        display_id = index + 1
+        name = f"{display_id}号电视 (ID: {client.id}, IP: {client.host})"
+        clients_list.append({
+            "index": index,
+            "id": client.id,
+            "ip": client.host,
+            "name": name
+        })
+    return clients_list
+
+def get_video_list() -> List[Dict[str, Any]]:
+    """获取所有视频列表
+    
+    Returns:
+        list: 包含视频信息的字典列表
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        return []
+        
+    videos_list = []
+    for video in _kodi_thread.manager.video_infos:
+        videos_list.append({
+            "id": video.id,
+            "name": video.name,
+            "description": video.description,
+            "duration": video.video_duration
+        })
+    return videos_list

+ 5 - 0
door_config.yaml

@@ -0,0 +1,5 @@
+door:
+  ip: "192.168.188.205"
+  port: 14460
+  password: ""  # 请填入密码
+

+ 393 - 6
flask_api.py

@@ -1,21 +1,44 @@
-from flask import Flask, jsonify, request, render_template, send_from_directory
+from flask import Flask, jsonify, request, render_template, send_from_directory, session, redirect, url_for, flash
+from functools import wraps
 from flask_cors import CORS
 import json
+import yaml
 import os
 import time
 import uuid
 import socket
+import threading
 from werkzeug.utils import secure_filename
 from application.kodi_alive_thread import start_kodi_alive_check
 from application.kodi_free_time_thread import start_kodi_free_time_play
 from application.wled_thread import start_exhibit_led_effect, stop_led_effect, is_effect_running
-from application.kodi_thread import start_kodi_play, stop_kodi_play, is_kodi_thread_running, play_image, play_rtsp, revoke_individual_state, start_all_kodi_apps
+from application.kodi_thread import start_kodi_play, stop_kodi_play, is_kodi_thread_running, play_image, play_rtsp, revoke_individual_state, start_all_kodi_apps, get_kodi_clients, get_video_list, set_volume
+from hardware.mitvs_module import turn_on_display, turn_off_display, turn_on_all_displays, turn_off_all_displays
+from hardware.door_module import set_emergency_control, open_door_control
 from utils.logger_config import logger
 
 
 app = Flask(__name__)
+app.secret_key = 'your_secret_key_here_hnhz_0821'  # 用于 session 加密,请更改为随机字符串
 CORS(app)  # 允许跨域请求
 
+# 登录账号配置
+ADMIN_USERNAME = 'admin'
+ADMIN_PASSWORD = 'HNYZ0821'
+
+# 登录验证装饰器
+def login_required(f):
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        if 'logged_in' not in session:
+            # 如果是 API 请求,返回 401
+            if request.path.startswith('/api/'):
+                return jsonify({'success': False, 'message': '未登录'}), 401
+            # 如果是页面请求,跳转到登录页
+            return redirect(url_for('login'))
+        return f(*args, **kwargs)
+    return decorated_function
+
 # 配置上传文件夹
 UPLOAD_FOLDER = 'uploads'
 ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
@@ -50,13 +73,56 @@ def get_server_ip():
             return "127.0.0.1"  # 回退到localhost
 
 
+def load_led_config():
+    """读取LED配置文件"""
+    try:
+        config_path = 'led_config.yaml'
+        if os.path.exists(config_path):
+            with open(config_path, 'r', encoding='utf-8') as f:
+                config = yaml.safe_load(f)
+                return config.get('segments', [])
+        return []
+    except Exception as e:
+        logger.error(f"读取LED配置文件失败: {e}")
+        return []
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    """登录页面"""
+    if request.method == 'POST':
+        username = request.form.get('username')
+        password = request.form.get('password')
+        
+        if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
+            session['logged_in'] = True
+            # flash('登录成功', 'success') # 登录成功不需要提示,直接进
+            return redirect(url_for('index'))
+        else:
+            flash('账号或密码错误', 'error')
+            return render_template('login.html')
+            
+    return render_template('login.html')
+
+
+@app.route('/logout')
+def logout():
+    """登出"""
+    session.pop('logged_in', None)
+    flash('已退出登录', 'success')
+    return redirect(url_for('login'))
+
+
 @app.route('/')
+@login_required
 def index():
     """返回HTML页面"""
-    return render_template('index.html')
+    led_segments = load_led_config()
+    return render_template('index.html', led_segments=led_segments)
 
 
 @app.route('/api/led/status', methods=['GET'])
+@login_required
 def get_led_status():
     """获取LED状态"""
     try:
@@ -76,6 +142,7 @@ def get_led_status():
 
 
 @app.route('/api/led/start', methods=['POST'])
+@login_required
 def start_led_effect():
     """启动展品LED灯效控制"""
     try:
@@ -117,6 +184,7 @@ def start_led_effect():
 
 
 @app.route('/api/led/stop', methods=['POST'])
+@login_required
 def stop_led_effect_api():
     """停止当前LED灯效"""
     try:
@@ -144,6 +212,7 @@ def stop_led_effect_api():
 
 # ===== Kodi 播放控制接口 =====
 @app.route('/api/kodi/status', methods=['GET'])
+@login_required
 def get_kodi_status():
     try:
         running = is_kodi_thread_running()
@@ -161,7 +230,40 @@ def get_kodi_status():
         }), 500
 
 
+@app.route('/api/kodi/clients', methods=['GET'])
+@login_required
+def get_kodi_clients_api():
+    """获取所有Kodi客户端列表"""
+    try:
+        clients = get_kodi_clients()
+        return jsonify({
+            "success": True,
+            "data": clients
+        })
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"获取Kodi客户端列表失败: {str(e)}"
+        }), 500
+
+@app.route('/api/kodi/videos', methods=['GET'])
+@login_required
+def get_kodi_videos_api():
+    """获取所有视频列表"""
+    try:
+        videos = get_video_list()
+        return jsonify({
+            "success": True,
+            "data": videos
+        })
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"获取视频列表失败: {str(e)}"
+        }), 500
+
 @app.route('/api/kodi/start', methods=['POST'])
+@login_required
 def start_kodi_play_api():
     try:
         data = request.get_json()
@@ -171,17 +273,27 @@ def start_kodi_play_api():
                 "message": "缺少视频ID参数"
             }), 400
         video_id = data['video_id']
+        volume = data.get('volume', -1)
         if not isinstance(video_id, int) or video_id < 0:
             return jsonify({
                 "success": False,
                 "message": "视频ID必须是大于等于0的整数"
             }), 400
-        ok = start_kodi_play(video_id)
+        
+        # 验证 volume 参数
+        if volume != -1:
+            if not isinstance(volume, int) or not (0 <= volume <= 100):
+                 return jsonify({
+                    "success": False,
+                    "message": "音量必须是 0-100 之间的整数"
+                }), 400
+
+        ok = start_kodi_play(video_id, volume)
         if ok:
             return jsonify({
                 "success": True,
-                "message": f"Kodi开始/切换播放 视频ID={video_id}",
-                "data": {"video_id": video_id}
+                "message": f"Kodi开始/切换播放 视频ID={video_id} 音量={'默认' if volume == -1 else volume}",
+                "data": {"video_id": video_id, "volume": volume}
             })
         return jsonify({
             "success": False,
@@ -195,6 +307,7 @@ def start_kodi_play_api():
 
 # 指定某台kodi_client_index播放图片,这边要上传图片并且传递完整url给kodi播放
 @app.route('/api/kodi/play_image', methods=['POST'])
+@login_required
 def play_image_api():
     """播放图片接口,支持文件上传或直接传递图片URL"""
     try:
@@ -321,6 +434,7 @@ def play_image_api():
 
 # 指定某台kodi_client_index播放rtsp视频
 @app.route('/api/kodi/play_rtsp', methods=['POST'])
+@login_required
 def play_rtsp_api():
     """播放RTSP视频流接口"""
     try:
@@ -393,6 +507,7 @@ def play_rtsp_api():
         }), 500
 
 @app.route('/api/kodi/revoke_individual_state', methods=['POST'])
+@login_required
 def revoke_individual_state_api():
     """撤销所有客户端的独立状态接口"""
     try:
@@ -415,6 +530,7 @@ def revoke_individual_state_api():
         }), 500
 
 @app.route('/api/kodi/start_all_apps', methods=['POST'])
+@login_required
 def start_all_kodi_apps_api():
     """启动所有kodi应用程序接口"""
     try:
@@ -436,6 +552,44 @@ def start_all_kodi_apps_api():
             "message": f"启动所有kodi应用程序失败: {str(e)}"
         }), 500
 
+@app.route('/api/kodi/set_volume', methods=['POST'])
+@login_required
+def set_kodi_volume_api():
+    """设置Kodi播放音量接口"""
+    try:
+        data = request.get_json()
+        if not data or 'volume' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少 volume 参数"
+            }), 400
+        
+        volume = data['volume']
+        if not isinstance(volume, int) or not (0 <= volume <= 100):
+            return jsonify({
+                "success": False,
+                "message": "音量必须是 0-100 之间的整数"
+            }), 400
+            
+        success = set_volume(volume)
+        if success:
+            return jsonify({
+                "success": True,
+                "message": f"已设置音量为 {volume}",
+                "data": {"volume": volume}
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": "设置音量失败"
+            }), 500
+    except Exception as e:
+        logger.error(f"设置音量异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"设置音量失败: {str(e)}"
+        }), 500
+
 # 提供上传文件的访问接口
 @app.route('/uploads/<filename>')
 def uploaded_file(filename):
@@ -443,6 +597,231 @@ def uploaded_file(filename):
     return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
 
 
+# ===== 电视开关控制接口 =====
+
+@app.route('/api/mitv/turn_on', methods=['POST'])
+@login_required
+def turn_on_display_api():
+    """唤醒指定ID的电视"""
+    try:
+        data = request.get_json()
+        if not data or 'kodi_id' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少 kodi_id 参数"
+            }), 400
+        
+        kodi_id = data['kodi_id']
+        if not isinstance(kodi_id, int) or kodi_id < 0:
+            return jsonify({
+                "success": False,
+                "message": "kodi_id 必须是大于等于0的整数"
+            }), 400
+            
+        def task():
+            try:
+                turn_on_display(kodi_id)
+            except Exception as e:
+                logger.error(f"异步唤醒电视异常 (ID: {kodi_id}): {str(e)}")
+        
+        threading.Thread(target=task).start()
+
+        return jsonify({
+            "success": True,
+            "message": f"已发送唤醒指令给电视 (ID: {kodi_id}) - 异步执行"
+        })
+
+    except Exception as e:
+        logger.error(f"唤醒电视异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"唤醒电视失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/mitv/turn_off', methods=['POST'])
+@login_required
+def turn_off_display_api():
+    """息屏指定ID的电视"""
+    try:
+        data = request.get_json()
+        if not data or 'kodi_id' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少 kodi_id 参数"
+            }), 400
+        
+        kodi_id = data['kodi_id']
+        if not isinstance(kodi_id, int) or kodi_id < 0:
+            return jsonify({
+                "success": False,
+                "message": "kodi_id 必须是大于等于0的整数"
+            }), 400
+            
+        def task():
+            try:
+                turn_off_display(kodi_id)
+            except Exception as e:
+                logger.error(f"异步息屏电视异常 (ID: {kodi_id}): {str(e)}")
+        
+        threading.Thread(target=task).start()
+
+        return jsonify({
+            "success": True,
+            "message": f"已发送息屏指令给电视 (ID: {kodi_id}) - 异步执行"
+        })
+
+    except Exception as e:
+        logger.error(f"息屏电视异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"息屏电视失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/mitv/turn_on_all', methods=['POST'])
+@login_required
+def turn_on_all_displays_api():
+    """唤醒所有电视"""
+    try:
+        def task():
+            try:
+                turn_on_all_displays()
+            except Exception as e:
+                logger.error(f"异步唤醒所有电视异常: {str(e)}")
+        
+        threading.Thread(target=task).start()
+
+        return jsonify({
+            "success": True,
+            "message": "已发送唤醒指令给所有电视 - 异步执行"
+        })
+
+    except Exception as e:
+        logger.error(f"唤醒所有电视异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"唤醒所有电视失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/mitv/turn_off_all', methods=['POST'])
+@login_required
+def turn_off_all_displays_api():
+    """息屏所有电视"""
+    try:
+        def task():
+            try:
+                turn_off_all_displays()
+            except Exception as e:
+                logger.error(f"异步息屏所有电视异常: {str(e)}")
+        
+        threading.Thread(target=task).start()
+
+        return jsonify({
+            "success": True,
+            "message": "已发送息屏指令给所有电视 - 异步执行"
+        })
+
+    except Exception as e:
+        logger.error(f"息屏所有电视异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"息屏所有电视失败: {str(e)}"
+        }), 500
+
+
+
+# ===== 门禁控制接口 =====
+@app.route('/api/door/control', methods=['POST'])
+@login_required
+def door_control_api():
+    """门禁控制接口"""
+    try:
+        data = request.get_json()
+        if not data or 'control_way' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少 control_way 参数"
+            }), 400
+        
+        control_way = data['control_way']
+        password = data.get('password') # 可选参数
+        
+        if not isinstance(control_way, int) or control_way not in [0, 1, 2]:
+             return jsonify({
+                "success": False,
+                "message": "control_way 必须是 0(在线), 1(常开), 2(常闭)"
+            }), 400
+
+        def task():
+            try:
+                set_emergency_control(control_way, password)
+            except Exception as e:
+                logger.error(f"异步门禁控制异常 (Mode: {control_way}): {str(e)}")
+        
+        threading.Thread(target=task).start()
+        
+        return jsonify({
+            "success": True,
+            "message": "已发送门禁控制指令 - 异步执行",
+            "data": {
+                "control_way": control_way
+            }
+        })
+
+    except Exception as e:
+        logger.error(f"门禁控制异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"门禁控制失败: {str(e)}"
+        }), 500
+
+
+
+@app.route('/api/door/open', methods=['POST'])
+@login_required
+def door_open_api():
+    """远程开门接口"""
+    try:
+        data = request.get_json()
+        if not data or 'door_id' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少 door_id 参数"
+            }), 400
+        
+        door_id = data['door_id']
+        password = data.get('password') # 可选参数
+        
+        if not isinstance(door_id, int):
+             return jsonify({
+                "success": False,
+                "message": "door_id 必须是整数"
+            }), 400
+
+        def task():
+            try:
+                open_door_control(door_id, password)
+            except Exception as e:
+                logger.error(f"异步远程开门异常 (ID: {door_id}): {str(e)}")
+        
+        threading.Thread(target=task).start()
+        
+        return jsonify({
+            "success": True,
+            "message": "已发送远程开门指令 - 异步执行",
+            "data": {
+                "door_id": door_id
+            }
+        })
+
+    except Exception as e:
+        logger.error(f"远程开门异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"远程开门失败: {str(e)}"
+        }), 500
 
 if __name__ == '__main__':
     # 创建templates目录
@@ -460,10 +839,18 @@ if __name__ == '__main__':
     logger.info("  POST /api/led/start - 启动展品灯效")
     logger.info("  POST /api/led/stop - 停止灯效")
     logger.info("  GET  /api/kodi/status - 获取Kodi状态")
+    logger.info("  GET  /api/kodi/clients - 获取Kodi客户端列表")
+    logger.info("  GET  /api/kodi/videos - 获取视频列表")
     logger.info("  POST /api/kodi/start - 启动/切换Kodi播放")
     logger.info("  POST /api/kodi/play_image - 播放图片(支持上传或URL)")
     logger.info("  POST /api/kodi/play_rtsp - 播放RTSP视频流")
     logger.info("  POST /api/kodi/revoke_individual_state - 撤销所有客户端的独立状态")
     logger.info("  POST /api/kodi/start_all_apps - 启动所有kodi应用程序")
+    logger.info("  POST /api/mitv/turn_on - 唤醒电视 (参数: kodi_id)")
+    logger.info("  POST /api/mitv/turn_off - 息屏电视 (参数: kodi_id)")
+    logger.info("  POST /api/mitv/turn_on_all - 唤醒所有电视")
+    logger.info("  POST /api/mitv/turn_off_all - 息屏所有电视")
+    logger.info("  POST /api/door/control - 门禁控制 (参数: control_way [0:在线, 1:常开, 2:常闭])")
+    logger.info("  POST /api/door/open - 远程开门 (参数: door_id)")
     
     app.run(debug=True, host='0.0.0.0', port=5050)

+ 134 - 0
hardware/door_module.py

@@ -0,0 +1,134 @@
+import requests
+import yaml
+import os
+from utils.logger_config import logger
+
+CONFIG_FILE = 'door_config.yaml'
+
+def load_config():
+    """加载门禁配置"""
+    if not os.path.exists(CONFIG_FILE):
+        logger.error(f"配置文件 {CONFIG_FILE} 不存在")
+        return None
+    
+    try:
+        with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f)
+            return config.get('door')
+    except Exception as e:
+        logger.error(f"读取配置文件失败: {e}")
+        return None
+
+def set_emergency_control(control_way, password=None):
+    """
+    设置紧急开关门方式
+    :param control_way: 0:在线, 1:常开, 2:常闭
+    :param password: 密码,如果未提供则从配置读取
+    :return: (success, message)
+    """
+    config = load_config()
+    if not config:
+        return False, "无法加载配置"
+
+    ip = config.get('ip')
+    port = config.get('port', 14460)
+    stored_password = config.get('password')
+    
+    # 优先使用传入的密码,否则使用配置文件的密码
+    # 如果两者都为 None,则默认为空字符串
+    final_password = password if password is not None else (stored_password if stored_password is not None else "")
+    
+    if not ip:
+        return False, "配置中缺少IP地址"
+    
+    # 允许空密码,不再检查 if not final_password
+
+    url = f"http://{ip}:{port}/setEmergencyControl"
+    
+    params = {
+        "pass": final_password,
+        "controlWay": control_way
+    }
+    
+    try:
+        # 根据用户提供的示例,请求体为 JSON 格式
+        response = requests.post(url, json=params, timeout=5)
+        
+        if response.status_code == 200:
+            try:
+                resp_data = response.json()
+                logger.info(f"门禁控制请求成功: {control_way}, 响应: {resp_data}")
+                
+                if resp_data.get('result') is True:
+                     return True, "设置成功"
+                else:
+                     error_msg = resp_data.get('message', '未知错误')
+                     return False, f"设置失败: {error_msg}"
+            except Exception as e:
+                logger.error(f"解析响应JSON失败: {e}, 原始内容: {response.text}")
+                return False, f"响应格式错误: {response.text}"
+
+        else:
+            logger.error(f"门禁控制请求失败: {response.status_code}, {response.text}")
+            return False, f"请求失败, 状态码: {response.status_code}"
+            
+    except Exception as e:
+        logger.error(f"门禁控制异常: {e}")
+        return False, f"请求异常: {str(e)}"
+
+def open_door_control(door_id, password=None):
+    """
+    远程开门控制
+    :param door_id: 门编号
+    :param password: 密码,如果未提供则从配置读取
+    :return: (success, message)
+    """
+    config = load_config()
+    if not config:
+        return False, "无法加载配置"
+
+    ip = config.get('ip')
+    port = config.get('port', 14460)
+    stored_password = config.get('password')
+    
+    # 优先使用传入的密码,否则使用配置文件的密码
+    # 如果两者都为 None,则默认为空字符串
+    final_password = password if password is not None else (stored_password if stored_password is not None else "")
+    
+    if not ip:
+        return False, "配置中缺少IP地址"
+    
+    # 允许空密码,不再检查 if not final_password
+
+    url = f"http://{ip}:{port}/openDoorControl"
+    
+    params = {
+        "pass": final_password,
+        "doorId": door_id
+    }
+    
+    try:
+        # 请求体为 JSON 格式
+        response = requests.post(url, json=params, timeout=5)
+        
+        if response.status_code == 200:
+            try:
+                resp_data = response.json()
+                logger.info(f"远程开门请求成功: {door_id}, 响应: {resp_data}")
+                
+                if resp_data.get('result') is True:
+                     return True, "远程开门成功"
+                else:
+                     error_msg = resp_data.get('message', '未知错误')
+                     return False, f"远程开门失败: {error_msg}"
+            except Exception as e:
+                logger.error(f"解析响应JSON失败: {e}, 原始内容: {response.text}")
+                return False, f"响应格式错误: {response.text}"
+
+        else:
+            logger.error(f"远程开门请求失败: {response.status_code}, {response.text}")
+            return False, f"请求失败, 状态码: {response.status_code}"
+            
+    except Exception as e:
+        logger.error(f"远程开门异常: {e}")
+        return False, f"请求异常: {str(e)}"

+ 1 - 0
hardware/ha_devices_module.py

@@ -0,0 +1 @@
+# 控制homeassistant的设备

+ 15 - 0
hardware/kodi_module.py

@@ -289,6 +289,21 @@ class KodiClientManager():
     def set_volume(self, volume):
         # 设置播放视频的音量
         self.volume = volume
+        
+        # 立即应用到符合条件的客户端
+        # 逻辑与 sync_play_video 中一致:第一台非独立设备设置音量,其他静音
+        # 注意:这里假设 kodi_clients 的顺序即为物理排列顺序
+        found_first_non_individual = False
+        
+        for client in self.kodi_clients:
+            if client.get_individual():
+                continue
+            
+            if not found_first_non_individual:
+                client.set_volume(self.volume)
+                found_first_non_individual = True
+            else:
+                client.set_volume(0)
     
     def _init_video_infos_from_config(self):
         config = self._load_config(self.video_config_path)

+ 117 - 0
hardware/mitvs_module.py

@@ -0,0 +1,117 @@
+import yaml
+import requests
+import os
+import json
+from utils.logger_config import logger
+
+class MiTVController:
+    def __init__(self, config_path='kodi_config_prod.yaml'):
+        self.config_path = config_path
+        self.config = self._load_config()
+        self.ha_config = self.config.get('home_assistant', {})
+        self.base_url = self.ha_config.get('url', '').rstrip('/')
+        self.token = self.ha_config.get('token', '')
+        self.headers = {
+            "Authorization": f"Bearer {self.token}",
+            "Content-Type": "application/json",
+        }
+        self.kodi_servers = self.config.get('kodi_servers', [])
+
+    def _load_config(self):
+        if not os.path.exists(self.config_path):
+            logger.error(f"配置文件不存在: {self.config_path}")
+            return {}
+        try:
+            with open(self.config_path, 'r', encoding='utf-8') as f:
+                return yaml.safe_load(f) or {}
+        except Exception as e:
+            logger.error(f"加载配置文件失败: {e}")
+            return {}
+
+    def _call_ha_service(self, entity_id, service="press", domain="button"):
+        if not self.base_url or not self.token:
+            logger.error("Home Assistant 配置缺失 (url or token)")
+            return False
+        
+        if not entity_id:
+            logger.warning("未配置 entity_id")
+            return False
+
+        url = f"{self.base_url}/api/services/{domain}/{service}"
+        payload = {"entity_id": entity_id}
+        
+        logger.info(f"准备调用HA接口: URL={url}, Entity={entity_id}")
+        
+        try:
+            response = requests.post(url, headers=self.headers, json=payload, timeout=5)
+            if response.status_code in [200, 201]:
+                logger.info(f"HA调用成功: {entity_id} -> {domain}.{service}, 响应: {response.text}")
+                return True
+            else:
+                logger.error(f"HA调用失败: {response.status_code} - {response.text}")
+                return False
+        except Exception as e:
+            logger.error(f"HA请求异常: {e}")
+            return False
+
+    def turn_on_display(self, kodi_id):
+        """唤醒指定ID的电视"""
+        server = next((s for s in self.kodi_servers if s.get('id') == kodi_id), None)
+        if not server:
+            logger.error(f"未找到ID为 {kodi_id} 的Kodi服务器配置")
+            return False
+        
+        entity_id = server.get('ha_turn_on_entity_id')
+        if not entity_id:
+            logger.error(f"Kodi ID {kodi_id} 未配置唤醒实例ID (ha_turn_on_entity_id)")
+            return False
+            
+        # 智能调用,不强制指定service,让 _call_ha_service 判断
+        return self._call_ha_service(entity_id)
+
+    def turn_off_display(self, kodi_id):
+        """息屏指定ID的电视"""
+        server = next((s for s in self.kodi_servers if s.get('id') == kodi_id), None)
+        if not server:
+            logger.error(f"未找到ID为 {kodi_id} 的Kodi服务器配置")
+            return False
+        
+        entity_id = server.get('ha_turn_off_entity_id')
+        if not entity_id:
+            logger.error(f"Kodi ID {kodi_id} 未配置息屏实例ID (ha_turn_off_entity_id)")
+            return False
+            
+        # 智能调用,不强制指定service
+        return self._call_ha_service(entity_id)
+
+    def turn_on_all_displays(self):
+        """唤醒所有电视"""
+        success_count = 0
+        for server in self.kodi_servers:
+            if self.turn_on_display(server.get('id')):
+                success_count += 1
+        return success_count > 0
+
+    def turn_off_all_displays(self):
+        """息屏所有电视"""
+        success_count = 0
+        for server in self.kodi_servers:
+            if self.turn_off_display(server.get('id')):
+                success_count += 1
+        return success_count > 0
+
+# 单例实例
+mitv_controller = MiTVController()
+
+def turn_on_display(kodi_id):
+    return mitv_controller.turn_on_display(kodi_id)
+
+def turn_off_display(kodi_id):
+    return mitv_controller.turn_off_display(kodi_id)
+
+def turn_on_all_displays():
+    return mitv_controller.turn_on_all_displays()
+
+def turn_off_all_displays():
+    return mitv_controller.turn_off_all_displays()
+

+ 17 - 1
kodi_config_prod.yaml

@@ -1,31 +1,47 @@
+home_assistant:
+  url: "https://api.ygtxfj.com:8125/"
+  token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4OTVlODgxMzJjYzU0MmY0YTJjYTkwMmFhMTJmZjU2MiIsImlhdCI6MTc2NDM5ODc1MSwiZXhwIjoyMDc5NzU4NzUxfQ.Io04NPaMTk55aWSehhXZpnhrVUYnBH6pga74mHS7ahY"
+
 kodi_servers:
   - ip: 192.168.188.181
     port: 8080
     username: kodi
     password: 123
     id: 0
+    ha_turn_off_entity_id: "button.xiaomi_cn_803548257_eaprh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_803548257_eaprh1_turn_mode_off_a_8_2"
   - ip: 192.168.188.182
     port: 8080
     username: kodi
     password: 123
     id: 1
+    ha_turn_off_entity_id: "button.xiaomi_cn_803548258_eaprh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_803548258_eaprh1_turn_mode_off_a_8_2"
   - ip: 192.168.188.183
     port: 8080
     username: kodi
     password: 123
     id: 2
+    ha_turn_off_entity_id: "button.xiaomi_cn_884091889_rmh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_884091889_rmh1_turn_mode_off_a_8_2"
   - ip: 192.168.188.184
     port: 8080
     username: kodi
     password: 123
     id: 3
+    ha_turn_off_entity_id: "button.xiaomi_cn_884091925_rmh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_884091925_rmh1_turn_mode_off_a_8_2"
   - ip: 192.168.188.185
     port: 8080
     username: kodi
     password: 123
     id: 4
+    ha_turn_off_entity_id: "button.xiaomi_cn_803548275_eaprh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_803548275_eaprh1_turn_mode_off_a_8_2"
   - ip: 192.168.188.186
     port: 8080
     username: kodi
     password: 123
-    id: 5
+    id: 5
+    ha_turn_off_entity_id: "button.xiaomi_cn_803548161_eaprh1_turn_mode_on_a_8_1"
+    ha_turn_on_entity_id: "button.xiaomi_cn_803548161_eaprh1_turn_mode_off_a_8_2"

ファイルの差分が大きいため隠しています
+ 537 - 259
templates/index.html


+ 651 - 0
templates/index_bak.html

@@ -0,0 +1,651 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>LED控制面板</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: 'Arial', sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 800px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 15px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
+            overflow: hidden;
+        }
+
+        .header {
+            background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
+            color: white;
+            padding: 30px;
+            text-align: center;
+        }
+
+        .header h1 {
+            font-size: 2.5em;
+            margin-bottom: 10px;
+        }
+
+        .header p {
+            font-size: 1.1em;
+            opacity: 0.9;
+        }
+
+        .content {
+            padding: 30px;
+        }
+
+        .control-group {
+            margin-bottom: 25px;
+            padding: 20px;
+            background: #f8f9fa;
+            border-radius: 10px;
+            border-left: 4px solid #4ecdc4;
+        }
+
+        .control-group h3 {
+            color: #333;
+            margin-bottom: 15px;
+            font-size: 1.3em;
+        }
+
+        .button-group {
+            display: flex;
+            gap: 10px;
+            flex-wrap: wrap;
+        }
+
+        .btn {
+            padding: 12px 24px;
+            border: none;
+            border-radius: 25px;
+            cursor: pointer;
+            font-size: 14px;
+            font-weight: bold;
+            transition: all 0.3s ease;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+        }
+
+        .btn-primary {
+            background: linear-gradient(45deg, #4ecdc4, #44a08d);
+            color: white;
+        }
+
+        .btn-primary:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 5px 15px rgba(78, 205, 196, 0.4);
+        }
+
+        .btn-danger {
+            background: linear-gradient(45deg, #ff6b6b, #ee5a52);
+            color: white;
+        }
+
+        .btn-danger:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 5px 15px rgba(255, 107, 107, 0.4);
+        }
+
+        .btn-warning {
+            background: linear-gradient(45deg, #feca57, #ff9ff3);
+            color: white;
+        }
+
+        .btn-warning:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 5px 15px rgba(254, 202, 87, 0.4);
+        }
+
+        .slider-container {
+            display: flex;
+            align-items: center;
+            gap: 15px;
+            margin: 15px 0;
+        }
+
+        .slider {
+            flex: 1;
+            height: 8px;
+            border-radius: 5px;
+            background: #ddd;
+            outline: none;
+            -webkit-appearance: none;
+        }
+
+        .slider::-webkit-slider-thumb {
+            -webkit-appearance: none;
+            appearance: none;
+            width: 20px;
+            height: 20px;
+            border-radius: 50%;
+            background: #4ecdc4;
+            cursor: pointer;
+        }
+
+        .slider::-moz-range-thumb {
+            width: 20px;
+            height: 20px;
+            border-radius: 50%;
+            background: #4ecdc4;
+            cursor: pointer;
+            border: none;
+        }
+
+        .color-picker {
+            width: 50px;
+            height: 50px;
+            border: none;
+            border-radius: 50%;
+            cursor: pointer;
+            margin: 0 10px;
+        }
+
+        .status-display {
+            background: #e8f5e8;
+            border: 1px solid #4caf50;
+            border-radius: 10px;
+            padding: 15px;
+            margin-top: 20px;
+        }
+
+        .status-display h4 {
+            color: #2e7d32;
+            margin-bottom: 10px;
+        }
+
+        .segment-controls {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+            gap: 15px;
+            margin-top: 15px;
+        }
+
+        .segment-item {
+            background: white;
+            padding: 15px;
+            border-radius: 8px;
+            border: 1px solid #ddd;
+            text-align: center;
+        }
+
+        .segment-item h4 {
+            color: #333;
+            margin-bottom: 10px;
+        }
+
+        .loading {
+            display: none;
+            text-align: center;
+            color: #666;
+            font-style: italic;
+        }
+
+        .error {
+            background: #ffebee;
+            color: #c62828;
+            padding: 10px;
+            border-radius: 5px;
+            margin: 10px 0;
+            display: none;
+        }
+
+        .success {
+            background: #e8f5e8;
+            color: #2e7d32;
+            padding: 10px;
+            border-radius: 5px;
+            margin: 10px 0;
+            display: none;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>🎨 LED控制面板</h1>
+            <p>通过Web界面控制您的LED灯带与Kodi播放</p>
+        </div>
+
+        <div class="content">
+            <!-- 展品灯效控制 -->
+            <div class="control-group">
+                <h3>🎭 展品灯效控制</h3>
+                <div style="margin-bottom: 15px;">
+                    <label for="exhibitId">展品ID (0开始):</label>
+                    <input type="number" id="exhibitId" min="0" value="0" style="margin-left: 10px; padding: 8px; border: 1px solid #ddd; border-radius: 5px; width: 80px;">
+                </div>
+                <div class="button-group">
+                    <button class="btn btn-primary" onclick="startEffect()">启动灯效</button>
+                    <button class="btn btn-danger" onclick="stopEffect()">停止灯效</button>
+                    <button class="btn btn-warning" onclick="getStatus()">刷新状态</button>
+                </div>
+            </div>
+
+            <!-- Kodi 播放控制 -->
+            <div class="control-group" style="border-left-color:#ff6b6b;">
+                <h3>🎬 Kodi 播放控制</h3>
+                <div style="margin-bottom: 15px;">
+                    <label for="videoId">视频ID (0开始):</label>
+                    <input type="number" id="videoId" min="0" value="0" style="margin-left: 10px; padding: 8px; border: 1px solid #ddd; border-radius: 5px; width: 80px;">
+                </div>
+                <div class="button-group">
+                    <button class="btn btn-primary" onclick="startKodi()">开始/切换播放</button>
+                    <button class="btn btn-warning" onclick="getKodiStatus()">刷新Kodi状态</button>
+                </div>
+                <div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
+                    <h4 style="color: #666; margin-bottom: 10px; font-size: 1.1em;">🔧 Kodi 系统控制</h4>
+                    <div class="button-group">
+                        <button class="btn btn-primary" onclick="revokeIndividualState()">撤销独立状态</button>
+                        <button class="btn btn-primary" onclick="startAllKodiApps()">启动所有Kodi应用</button>
+                    </div>
+                </div>
+                <div class="status-display" id="kodiStatusDisplay" style="margin-top:15px;">
+                    <h4>📊 Kodi 状态</h4>
+                    <div id="kodiStatusContent">点击"刷新Kodi状态"查看</div>
+                </div>
+            </div>
+
+            <!-- 图片播放控制 -->
+            <div class="control-group" style="border-left-color:#feca57;">
+                <h3>🖼️ 图片播放控制</h3>
+                <div style="margin-bottom: 15px;">
+                    <label for="kodiClientIndexImage">Kodi客户端索引 (0开始):</label>
+                    <input type="number" id="kodiClientIndexImage" min="0" value="0" style="margin-left: 10px; padding: 8px; border: 1px solid #ddd; border-radius: 5px; width: 80px;">
+                </div>
+                <div style="margin-bottom: 15px;">
+                    <label>方式一:上传图片文件</label>
+                    <input type="file" id="imageFileInput" accept="image/*" style="margin-top: 10px; padding: 8px; width: 100%; border: 1px solid #ddd; border-radius: 5px;">
+                </div>
+                <div style="margin-bottom: 15px;">
+                    <label>方式二:输入图片URL</label>
+                    <input type="text" id="imageUrlInput" placeholder="请输入图片URL,例如: http://example.com/image.jpg" style="margin-top: 10px; padding: 8px; width: 100%; border: 1px solid #ddd; border-radius: 5px;">
+                </div>
+                <div class="button-group">
+                    <button class="btn btn-primary" onclick="playImage()">播放图片</button>
+                </div>
+            </div>
+
+            <!-- RTSP视频播放控制 -->
+            <div class="control-group" style="border-left-color:#667eea;">
+                <h3>📹 RTSP视频播放控制</h3>
+                <div style="margin-bottom: 15px;">
+                    <label for="kodiClientIndexRtsp">Kodi客户端索引 (0开始):</label>
+                    <input type="number" id="kodiClientIndexRtsp" min="0" value="0" style="margin-left: 10px; padding: 8px; border: 1px solid #ddd; border-radius: 5px; width: 80px;">
+                </div>
+                <div style="margin-bottom: 15px;">
+                    <label for="rtspUrlInput">RTSP视频流URL:</label>
+                    <input type="text" id="rtspUrlInput" placeholder="请输入RTSP URL,例如: rtsp://example.com/stream" style="margin-top: 10px; padding: 8px; width: 100%; border: 1px solid #ddd; border-radius: 5px;">
+                </div>
+                <div style="margin-bottom: 15px;">
+                    <label for="rtspVolume">音量 (0-100):</label>
+                    <input type="number" id="rtspVolume" min="0" max="100" value="0" style="margin-left: 10px; padding: 8px; border: 1px solid #ddd; border-radius: 5px; width: 80px;">
+                </div>
+                <div class="button-group">
+                    <button class="btn btn-primary" onclick="playRtsp()">播放RTSP流</button>
+                </div>
+            </div>
+
+            <!-- 灯效说明 -->
+            <div class="control-group">
+                <h3>📖 灯效说明</h3>
+                <div style="background: #f0f8ff; padding: 15px; border-radius: 8px; border-left: 4px solid #4ecdc4;">
+                    <p><strong>灯效流程:</strong></p>
+                    <ol style="margin: 10px 0; padding-left: 20px;">
+                        <li>选择展品ID,点击"启动灯效"</li>
+                        <li>指定展品将显示白色呼吸灯效,其他展品保持静止</li>
+                        <li>10秒后,所有展品将随机选择以下灯效之一:</li>
+                        <ul style="margin: 5px 0; padding-left: 20px;">
+                            <li>随机波浪效果</li>
+                            <li>随机闪烁效果</li>
+                            <li>随机呼吸效果</li>
+                        </ul>
+                        <li>所有灯效都使用白色</li>
+                    </ol>
+                </div>
+            </div>
+
+            <!-- 状态显示 -->
+            <div class="status-display" id="statusDisplay">
+                <h4>📊 当前状态</h4>
+                <div id="statusContent">点击"刷新状态"查看当前LED状态</div>
+            </div>
+
+            <!-- 消息显示 -->
+            <div class="error" id="errorMessage"></div>
+            <div class="success" id="successMessage"></div>
+            <div class="loading" id="loadingMessage">正在处理请求...</div>
+        </div>
+    </div>
+
+    <script>
+        // 全局变量
+        let currentStatus = null;
+        let currentKodiStatus = null;
+
+        // 页面加载完成后初始化
+        document.addEventListener('DOMContentLoaded', function() {
+            getStatus();
+            getKodiStatus();
+        });
+
+        // 显示消息
+        function showMessage(message, type = 'success') {
+            const errorDiv = document.getElementById('errorMessage');
+            const successDiv = document.getElementById('successMessage');
+            
+            errorDiv.style.display = 'none';
+            successDiv.style.display = 'none';
+            
+            if (type === 'error') {
+                errorDiv.textContent = message;
+                errorDiv.style.display = 'block';
+            } else {
+                successDiv.textContent = message;
+                successDiv.style.display = 'block';
+            }
+            
+            setTimeout(() => {
+                errorDiv.style.display = 'none';
+                successDiv.style.display = 'none';
+            }, 3000);
+        }
+
+        // 显示加载状态
+        function showLoading(show = true) {
+            document.getElementById('loadingMessage').style.display = show ? 'block' : 'none';
+        }
+
+        // 获取LED状态
+        async function getStatus() {
+            try {
+                showLoading(true);
+                const response = await fetch('/api/led/status');
+                const result = await response.json();
+                
+                if (result.success) {
+                    currentStatus = result.data;
+                    updateUI();
+                    showMessage('LED状态已刷新');
+                } else {
+                    showMessage('获取LED状态失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // 更新UI显示
+        function updateUI() {
+            if (!currentStatus) return;
+            const statusContent = document.getElementById('statusContent');
+            statusContent.innerHTML = `
+                <p><strong>运行状态:</strong> ${currentStatus.is_running ? '运行中' : '已停止'}</p>
+                <p><strong>状态信息:</strong> ${currentStatus.message}</p>
+            `;
+        }
+
+        // 启动灯效
+        async function startEffect() {
+            const exhibitId = parseInt(document.getElementById('exhibitId').value);
+            if (isNaN(exhibitId) || exhibitId < 0) {
+                showMessage('请输入有效的展品ID(大于等于0的整数)', 'error');
+                return;
+            }
+            try {
+                showLoading(true);
+                const response = await fetch('/api/led/start', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ exhibit_id: exhibitId })
+                });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // 停止灯效
+        async function stopEffect() {
+            try {
+                showLoading(true);
+                const response = await fetch('/api/led/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // ===== Kodi 控制 =====
+        async function getKodiStatus() {
+            try {
+                showLoading(true);
+                const response = await fetch('/api/kodi/status');
+                const result = await response.json();
+                if (result.success) {
+                    currentKodiStatus = result.data;
+                    const el = document.getElementById('kodiStatusContent');
+                    el.innerHTML = `
+                        <p><strong>线程状态:</strong> ${currentKodiStatus.is_running ? '运行中' : '已停止'}</p>
+                        <p><strong>状态信息:</strong> ${currentKodiStatus.message}</p>
+                    `;
+                    showMessage('Kodi状态已刷新');
+                } else {
+                    showMessage('获取Kodi状态失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        async function startKodi() {
+            const videoId = parseInt(document.getElementById('videoId').value);
+            if (isNaN(videoId) || videoId < 0) {
+                showMessage('请输入有效的视频ID(大于等于0的整数)', 'error');
+                return;
+            }
+            try {
+                showLoading(true);
+                const response = await fetch('/api/kodi/start', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ video_id: videoId })
+                });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getKodiStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // ===== 图片播放控制 =====
+        async function playImage() {
+            const clientIndex = parseInt(document.getElementById('kodiClientIndexImage').value);
+            if (isNaN(clientIndex) || clientIndex < 0) {
+                showMessage('请输入有效的客户端索引(大于等于0的整数)', 'error');
+                return;
+            }
+
+            const fileInput = document.getElementById('imageFileInput');
+            const urlInput = document.getElementById('imageUrlInput').value.trim();
+
+            // 检查是否选择了文件或输入了URL
+            if (!fileInput.files || fileInput.files.length === 0) {
+                if (!urlInput) {
+                    showMessage('请选择图片文件或输入图片URL', 'error');
+                    return;
+                }
+            }
+
+            try {
+                showLoading(true);
+                let response;
+                
+                // 如果上传了文件,使用FormData
+                if (fileInput.files && fileInput.files.length > 0) {
+                    const formData = new FormData();
+                    formData.append('file', fileInput.files[0]);
+                    formData.append('kodi_client_index', clientIndex);
+                    
+                    response = await fetch('/api/kodi/play_image', {
+                        method: 'POST',
+                        body: formData
+                    });
+                } else {
+                    // 使用图片URL
+                    response = await fetch('/api/kodi/play_image', {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ 
+                            image_url: urlInput,
+                            kodi_client_index: clientIndex
+                        })
+                    });
+                }
+
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getKodiStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // ===== RTSP播放控制 =====
+        async function playRtsp() {
+            const clientIndex = parseInt(document.getElementById('kodiClientIndexRtsp').value);
+            const rtspUrl = document.getElementById('rtspUrlInput').value.trim();
+            const volume = parseInt(document.getElementById('rtspVolume').value);
+
+            if (isNaN(clientIndex) || clientIndex < 0) {
+                showMessage('请输入有效的客户端索引(大于等于0的整数)', 'error');
+                return;
+            }
+
+            if (!rtspUrl) {
+                showMessage('请输入RTSP视频流URL', 'error');
+                return;
+            }
+
+            if (isNaN(volume) || volume < 0 || volume > 100) {
+                showMessage('音量必须是0-100之间的整数', 'error');
+                return;
+            }
+
+            try {
+                showLoading(true);
+                const response = await fetch('/api/kodi/play_rtsp', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ 
+                        rtsp_url: rtspUrl,
+                        kodi_client_index: clientIndex,
+                        volume: volume
+                    })
+                });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getKodiStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        // ===== Kodi 系统控制 =====
+        async function revokeIndividualState() {
+            try {
+                showLoading(true);
+                const response = await fetch('/api/kodi/revoke_individual_state', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' }
+                });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getKodiStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+        async function startAllKodiApps() {
+            try {
+                showLoading(true);
+                const response = await fetch('/api/kodi/start_all_apps', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' }
+                });
+                const result = await response.json();
+                if (result.success) {
+                    showMessage(result.message);
+                    getKodiStatus();
+                } else {
+                    showMessage('操作失败: ' + result.message, 'error');
+                }
+            } catch (error) {
+                showMessage('网络错误: ' + error.message, 'error');
+            } finally {
+                showLoading(false);
+            }
+        }
+
+    </script>
+</body>
+</html>

+ 160 - 0
templates/login.html

@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>登录 - 展厅控制</title>
+    <style>
+        :root {
+            --primary-color: #4ecdc4;
+            --secondary-color: #ff6b6b;
+            --accent-color: #feca57;
+            --dark-text: #333;
+            --light-text: #fff;
+            --bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+            --card-bg: #ffffff;
+        }
+
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            background: var(--bg-gradient);
+            min-height: 100vh;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            color: var(--dark-text);
+        }
+
+        .login-container {
+            background: var(--card-bg);
+            border-radius: 15px;
+            padding: 40px;
+            width: 100%;
+            max-width: 400px;
+            box-shadow: 0 10px 25px rgba(0,0,0,0.1);
+            animation: fadeIn 0.5s ease-out;
+        }
+
+        @keyframes fadeIn {
+            from { opacity: 0; transform: translateY(-20px); }
+            to { opacity: 1; transform: translateY(0); }
+        }
+
+        .header {
+            text-align: center;
+            margin-bottom: 30px;
+        }
+
+        .header h1 {
+            color: #2c3e50;
+            font-size: 24px;
+            margin-bottom: 10px;
+        }
+
+        .header p {
+            color: #7f8c8d;
+            font-size: 14px;
+        }
+
+        .form-group {
+            margin-bottom: 20px;
+        }
+
+        label {
+            display: block;
+            margin-bottom: 8px;
+            color: #555;
+            font-weight: 600;
+            font-size: 14px;
+        }
+
+        input {
+            width: 100%;
+            padding: 12px;
+            border: 1px solid #ddd;
+            border-radius: 8px;
+            font-size: 15px;
+            transition: border-color 0.3s;
+        }
+
+        input:focus {
+            border-color: var(--primary-color);
+            outline: none;
+        }
+
+        .btn-submit {
+            width: 100%;
+            padding: 12px;
+            background-color: var(--primary-color);
+            color: white;
+            border: none;
+            border-radius: 8px;
+            font-size: 16px;
+            font-weight: 600;
+            cursor: pointer;
+            transition: background-color 0.3s, transform 0.1s;
+        }
+
+        .btn-submit:hover {
+            background-color: #3dbdb4;
+        }
+
+        .btn-submit:active {
+            transform: translateY(1px);
+        }
+
+        .alert {
+            padding: 12px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            font-size: 14px;
+            text-align: center;
+        }
+
+        .alert-error {
+            background-color: #ffebee;
+            color: #c62828;
+            border: 1px solid #ffcdd2;
+        }
+    </style>
+</head>
+<body>
+    <div class="login-container">
+        <div class="header">
+            <h1>展厅控制系统</h1>
+            <p>请登录以继续</p>
+        </div>
+        
+        {% with messages = get_flashed_messages(with_categories=true) %}
+            {% if messages %}
+                {% for category, message in messages %}
+                    <div class="alert alert-{{ category }}">
+                        {{ message }}
+                    </div>
+                {% endfor %}
+            {% endif %}
+        {% endwith %}
+
+        <form method="POST" action="/login">
+            <div class="form-group">
+                <label for="username">账号</label>
+                <input type="text" id="username" name="username" placeholder="请输入账号" required autofocus>
+            </div>
+            
+            <div class="form-group">
+                <label for="password">密码</label>
+                <input type="password" id="password" name="password" placeholder="请输入密码" required>
+            </div>
+            
+            <button type="submit" class="btn-submit">登录</button>
+        </form>
+    </div>
+</body>
+</html>
+

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません