liuq преди 5 месеца
родител
ревизия
217dc0664c

+ 73 - 43
.gitignore

@@ -1,60 +1,90 @@
-# ---> Python
-# Byte-compiled / optimized / DLL files
+# Python
 __pycache__/
 *.py[cod]
 *$py.class
-
-# C extensions
 *.so
-
-# Distribution / packaging
 .Python
+
+# Virtual Environment
+.venv/
+venv/
+ENV/
 env/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
 *.egg-info/
-.installed.cfg
-*.egg
+dist/
+build/
+
+# Flask
+instance/
+.webassets-cache
+*.db
+*.sqlite
+*.sqlite3
 
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
+# Environment Variables
+.env
+.env.local
+.env.*.local
+*.env
 
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
+# Logs
+logs/
+*.log
+*.log.*
+
+# Uploads (保持目录结构但忽略内容)
+uploads/*
+!uploads/.gitkeep
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.project
+.pydevproject
+.settings/
+
+# OS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+Desktop.ini
 
-# Unit test / coverage reports
+# Testing
+.pytest_cache/
+.coverage
 htmlcov/
 .tox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*,cover
+.hypothesis/
 
-# Translations
-*.mo
-*.pot
+# Jupyter Notebook
+.ipynb_checkpoints
 
-# Django stuff:
-*.log
+# pyenv
+.python-version
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
 
-# Sphinx documentation
-docs/_build/
+# Temporary files
+*.tmp
+*.temp
+*.bak
+*.cache
+*.pid
+*.seed
+*.pid.lock
 
-# PyBuilder
-target/
+# Configuration files with sensitive data (uncomment if needed)
+# *.yaml
+# *.yml
+# !example*.yaml
 

+ 178 - 0
README_API.md

@@ -0,0 +1,178 @@
+# LED 与 Kodi 控制接口文档(Flask API)
+
+本项目提供一个基于 Flask 的 HTTP API 与网页界面,用于控制展品 LED 灯效与 Kodi 播放。
+
+## 环境与运行
+
+1. 安装依赖
+```bash
+pip install -r requirements.txt
+```
+
+2. 启动服务
+```bash
+python flask_api.py
+```
+或双击 `start_server.bat`
+
+3. 访问
+- 网页界面:`http://localhost:5000`
+- API 根路径同上(默认端口 5000,已启用 CORS)
+
+## 返回格式约定
+
+- 所有接口均返回 JSON
+- 统一字段:
+  - `success`:布尔,是否成功
+  - `message`:字符串,结果说明
+  - `data`:对象,可选,承载具体数据
+
+---
+
+## 页面
+
+- **GET** `/`
+  - 返回 `templates/index.html` 页面
+
+---
+
+## LED 灯效控制
+
+- **GET** `/api/led/status`
+  - 说明:获取当前 LED 灯效运行状态
+  - 成功响应示例:
+```json
+{
+  "success": true,
+  "data": {
+    "is_running": true,
+    "message": "灯效正在运行"
+  }
+}
+```
+
+- **POST** `/api/led/start`
+  - 说明:按展品 ID 启动灯效
+  - 请求体(JSON):
+```json
+{ "exhibit_id": 0 }
+```
+  - 校验:`exhibit_id` 必须为大于等于 0 的整数
+  - 成功响应示例:
+```json
+{
+  "success": true,
+  "message": "展品 0 的灯效已启动",
+  "data": { "exhibit_id": 0, "is_running": true }
+}
+```
+
+- **POST** `/api/led/stop`
+  - 说明:停止当前灯效
+  - 成功响应示例:
+```json
+{ "success": true, "message": "灯效已停止", "data": { "is_running": false } }
+```
+
+---
+
+## Kodi 播放控制
+
+- **GET** `/api/kodi/status`
+  - 说明:获取 Kodi 播放线程状态
+  - 成功响应示例:
+```json
+{
+  "success": true,
+  "data": {
+    "is_running": false,
+    "message": "Kodi播放线程已停止"
+  }
+}
+```
+
+- **POST** `/api/kodi/start`
+  - 说明:启动或切换 Kodi 播放到指定 `video_id`
+  - 请求体(JSON):
+```json
+{ "video_id": 1 }
+```
+  - 校验:`video_id` 必须为大于等于 0 的整数
+  - 成功响应示例:
+```json
+{ "success": true, "message": "Kodi开始/切换播放 视频ID=1", "data": { "video_id": 1 } }
+```
+
+- **POST** `/api/kodi/stop`
+  - 说明:停止 Kodi 播放
+  - 成功响应示例:
+```json
+{ "success": true, "message": "Kodi播放已停止" }
+```
+
+---
+
+## 失败与错误码
+
+- 参数缺失/非法:HTTP 400
+  - 示例:
+```json
+{ "success": false, "message": "缺少展品ID参数" }
+```
+
+- 服务器内部错误:HTTP 500
+  - 示例:
+```json
+{ "success": false, "message": "启动灯效失败: error detail" }
+```
+
+---
+
+## curl 示例
+
+```bash
+# LED 状态
+curl -s http://localhost:5000/api/led/status | jq
+
+# 启动 LED(展品ID=0)
+curl -s -X POST http://localhost:5000/api/led/start \
+  -H "Content-Type: application/json" \
+  -d '{"exhibit_id": 0}' | jq
+
+# 停止 LED
+curl -s -X POST http://localhost:5000/api/led/stop | jq
+
+# Kodi 状态
+curl -s http://localhost:5000/api/kodi/status | jq
+
+# 启动/切换 Kodi(视频ID=1)
+curl -s -X POST http://localhost:5000/api/kodi/start \
+  -H "Content-Type: application/json" \
+  -d '{"video_id": 1}' | jq
+
+# 停止 Kodi
+curl -s -X POST http://localhost:5000/api/kodi/stop | jq
+```
+
+---
+
+## 目录结构(节选)
+
+```
+LEDEffectFramework_V1/
+├── flask_api.py          # Flask API 入口
+├── application/
+│   ├── wled_thread.py    # LED 业务逻辑入口
+│   └── kodi_thread.py    # Kodi 业务逻辑入口
+├── hardware/             # 硬件/控制模块
+├── templates/
+│   └── index.html        # 网页界面
+├── requirements.txt
+├── start_server.bat
+└── README_API.md
+```
+
+## 备注
+
+- 端口、跨域等在 `flask_api.py` 中配置,默认 `0.0.0.0:5000`
+- 具体硬件行为取决于 `application/*` 与 `hardware/*` 实现

+ 75 - 0
app_test.py

@@ -0,0 +1,75 @@
+import time
+from application.wled_thread import start_exhibit_led_effect, stop_led_effect
+from hardware.kodi_module import KodiClientManager
+from hardware.wled_controller_module import WledController
+from hardware.wled_enum import LEDBrightness, LEDEffectType, RGBColor
+from utils.logger_config import logger
+
+
+def set_wled_state():
+    wled_controller = WledController()
+    wled_state = wled_controller.get_wled_state()
+    wled_state.power = True
+    wled_state.set_global_brightness(LEDBrightness.MAXIMUM.value)
+    segment_0 = wled_controller.get_segment_by_id(0)
+    segment_0.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_0.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    segment_1 = wled_controller.get_segment_by_id(1)
+    segment_1.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_1.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    segment_2 = wled_controller.get_segment_by_id(2)
+    segment_2.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    segment_2.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_3 = wled_controller.get_segment_by_id(3)
+    segment_2.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    segment_3.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_3.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    segment_3.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_4 = wled_controller.get_segment_by_id(4)
+    segment_4.set_color(RGBColor.WHITE.rgb_tuple)
+    segment_4.set_effect(LEDEffectType.COLOR_RANDOM_BREATH.value)
+    wled_controller.send_state(wled_state)
+
+# 使用示例
+def test_wled_effect():
+     # 测试展品3的灯效
+    logger.info("启动展品3的灯效...")
+    success = start_exhibit_led_effect(3)
+    logger.info(f"启动结果: {success}")
+    
+    # 等待一段时间
+    time.sleep(15)
+    
+    # 停止灯效
+    logger.info("停止灯效...")
+    stop_led_effect()
+    logger.info("测试完成")
+
+def play_video():
+    # 从 config.yaml 读取 Kodi 服务器配置
+    kodi_client_manager = KodiClientManager()
+    # 选择要播放的视频索引 (例如,使用第一个视频,索引为 0)
+    kodi_client_manager.sync_play_video(kodi_client_manager.kodi_clients, kodi_client_manager.video_infos[1].video_path)
+    logger.info("开始一次同步播放...")
+
+def play_url_image():
+    kodi_client_manager = KodiClientManager()
+    kodi_client_manager.play_url_image_on_client(kodi_client_manager.kodi_clients[0], "https://image.baidu.com/search/detail?adpicid=0&b_applid=12120618120285162411&bdtype=0&commodity=&copyright=&cs=766916511%2C2442144840&di=7562963243866521601&fr=click-pic&fromurl=http%253A%252F%252Fwww.douyin.com%252Fnote%252F7404637882255805759&gsm=1e&hd=&height=0&hot=&ic=&ie=utf-8&imgformat=&imgratio=&imgspn=0&is=3908015339%2C2306624355&isImgSet=&latest=&lid=f3a5b35900f977dd&lm=&objurl=https%253A%252F%252Fp3-pc-sign.douyinpic.com%252Ftos-cn-i-0813c001%252Fo4IuiAZjBXAcOFCxsvgiPAiIA98PRBDEAKDBg~tplv-dy-aweme-images%253Aq75.webp&os=3908015339%2C2306624355&pd=image_content&pi=0&pn=0&rn=1&simid=766916511%2C2442144840&tn=baiduimagedetail&width=0&word=%E5%9B%BE%E7%89%87&z=")
+
+def play_rtsp_video():
+    kodi_client_manager = KodiClientManager()
+    kodi_client_manager.play_rtsp_video_on_client(kodi_client_manager.kodi_clients[0], "rtsp://admin:Aa147258@192.168.188.61:554/Streaming/Channels/1")
+    kodi_client_manager.play_rtsp_video_on_client(kodi_client_manager.kodi_clients[1], "rtsp://admin:Aa147258@192.168.188.62:554/Streaming/Channels/1")
+
+def revoke_individual_state():
+    kodi_client_manager = KodiClientManager()
+    kodi_client_manager.revoke_individual_state()
+    logger.info("撤销所有客户端的独立状态")
+
+def check_all_kodi_clients_online():
+    kodi_client_manager = KodiClientManager()
+    client_index = kodi_client_manager.check_all_kodi_clients_online()
+    logger.info(f"所有kodi客户端是否在线: {client_index}")
+
+if __name__ == "__main__":
+    check_all_kodi_clients_online()

+ 145 - 0
application/kodi_alive_thread.py

@@ -0,0 +1,145 @@
+import threading
+import time
+from utils.logger_config import logger
+from hardware.kodi_module import KodiClientManager
+
+
+class KodiAliveThreadSingleton:
+    """Kodi心跳检测线程单例类,每秒检查一次心跳,如果检测不到Kodi在线则启动Kodi应用"""
+    
+    _instance = None
+    _lock = threading.Lock()
+    
+    def __new__(cls):
+        if cls._instance is None:
+            with cls._lock:
+                if cls._instance is None:
+                    cls._instance = super(KodiAliveThreadSingleton, cls).__new__(cls)
+        return cls._instance
+    
+    def __init__(self):
+        # 避免重复初始化
+        if hasattr(self, '_initialized'):
+            return
+        
+        self._initialized = True
+        self.thread = None
+        self.is_running = False
+        self._should_stop = False
+        self.manager: KodiClientManager = None
+        
+        # 配置参数
+        self.check_interval_seconds = 1  # 每秒检查一次
+        
+        # 启动工作线程
+        self._start_worker_thread()
+    
+    def _start_worker_thread(self):
+        """启动工作线程"""
+        if self.thread is None or not self.thread.is_alive():
+            self.is_running = True
+            self._should_stop = False
+            self.thread = threading.Thread(target=self._worker_loop, daemon=True)
+            self.thread.start()
+            logger.info("Kodi心跳检测线程已启动")
+    
+    def _initialize_manager(self):
+        """初始化KodiClientManager"""
+        if self.manager is None:
+            self.manager = KodiClientManager()
+            logger.info("KodiClientManager 初始化成功")
+    
+    def _worker_loop(self):
+        """工作线程主循环"""
+        logger.info("Kodi心跳检测线程开始运行,每秒检查一次心跳")
+        
+        try:
+            # 确保Manager已初始化
+            self._initialize_manager()
+            
+            while self.is_running and not self._should_stop:
+                try:
+                    # 检查所有Kodi客户端是否在线
+                    offline_client_index = self.manager.check_all_kodi_clients_online()
+                    
+                    if offline_client_index >= 0:
+                        # 检测到不在线的客户端,启动对应的Kodi应用
+                        logger.warning(f"检测到Kodi客户端 {offline_client_index} 不在线,尝试启动Kodi应用")
+                        
+                        try:
+                            # 根据client_index获取对应的客户端并启动
+                            if offline_client_index < len(self.manager.kodi_clients):
+                                client = self.manager.kodi_clients[offline_client_index]
+                                result = client.start_kodi()
+                                logger.info(f"已尝试启动客户端 {offline_client_index} 的Kodi应用,响应: {result}")
+                            else:
+                                logger.error(f"客户端索引 {offline_client_index} 超出范围,客户端总数: {len(self.manager.kodi_clients)}")
+                        except Exception as e:
+                            logger.error(f"启动客户端 {offline_client_index} 的Kodi应用时发生异常: {e}")
+                    else:
+                        # 所有客户端都在线
+                        logger.debug("所有Kodi客户端在线")
+                    
+                    # 等待指定时间后再次检查
+                    time.sleep(self.check_interval_seconds)
+                    
+                    if self._should_stop:
+                        logger.info("收到停止信号,退出循环")
+                        break
+                        
+                except Exception as e:
+                    logger.error(f"工作线程循环异常: {e}")
+                    time.sleep(self.check_interval_seconds)
+                    
+        except Exception as e:
+            logger.error(f"工作线程异常: {e}")
+        finally:
+            self.is_running = False
+            logger.info("Kodi心跳检测线程结束")
+    
+    def stop(self):
+        """停止线程"""
+        self._should_stop = True
+        self.is_running = False
+        logger.info("停止Kodi心跳检测线程")
+
+
+# 全局单例实例
+_kodi_alive_thread = KodiAliveThreadSingleton()
+
+
+def start_kodi_alive_check() -> bool:
+    """
+    启动Kodi心跳检测
+    
+    启动后,线程会每秒自动检查所有Kodi客户端的心跳
+    如果检测到不在线的客户端,会自动尝试启动对应的Kodi应用
+    
+    Returns:
+        bool: 启动是否成功
+    """
+    if not _kodi_alive_thread.is_running:
+        _kodi_alive_thread._start_worker_thread()
+    return _kodi_alive_thread.is_running
+
+
+def stop_kodi_alive_check() -> bool:
+    """
+    停止Kodi心跳检测
+    
+    Returns:
+        bool: 停止是否成功
+    """
+    _kodi_alive_thread.stop()
+    return True
+
+
+def is_alive_check_running() -> bool:
+    """
+    检查心跳检测线程是否正在运行
+    
+    Returns:
+        bool: 线程是否正在运行
+    """
+    return _kodi_alive_thread.is_running
+

+ 133 - 0
application/kodi_free_time_thread.py

@@ -0,0 +1,133 @@
+import threading
+import time
+from utils.logger_config import logger
+from application.kodi_thread import _kodi_thread
+
+
+class KodiFreeTimeThreadSingleton:
+    """Kodi空闲时间播放线程单例类,每5分钟循环播放视频ID 0"""
+    
+    _instance = None
+    _lock = threading.Lock()
+    
+    def __new__(cls):
+        if cls._instance is None:
+            with cls._lock:
+                if cls._instance is None:
+                    cls._instance = super(KodiFreeTimeThreadSingleton, cls).__new__(cls)
+        return cls._instance
+    
+    def __init__(self):
+        # 避免重复初始化
+        if hasattr(self, '_initialized'):
+            return
+        
+        self._initialized = True
+        self.thread = None
+        self.is_running = False
+        self._should_stop = False
+        
+        # 配置参数
+        self.video_id = 0
+        self.interval_seconds = 5 * 60  # 5分钟 = 300秒
+        
+        # 启动工作线程
+        self._start_worker_thread()
+    
+    def _start_worker_thread(self):
+        """启动工作线程"""
+        if self.thread is None or not self.thread.is_alive():
+            self.is_running = True
+            self._should_stop = False
+            self.thread = threading.Thread(target=self._worker_loop, daemon=True)
+            self.thread.start()
+            logger.info("Kodi空闲时间播放线程已启动")
+    
+    def _worker_loop(self):
+        """工作线程主循环"""
+        logger.info("Kodi空闲时间播放线程开始运行,每5分钟播放视频ID 0(循环模式)")
+        
+        try:
+            # 确保Kodi线程的manager已初始化
+            _kodi_thread._initialize_manager()
+            
+            while self.is_running and not self._should_stop:
+                try:
+                    # 调用播放方法,循环播放视频ID 0
+                    logger.info(f"触发空闲时间播放:视频ID={self.video_id},循环模式=True")
+                    success = _kodi_thread._play_sync_by_video_id(self.video_id, loop=True)
+                    
+                    if success:
+                        logger.info(f"空闲时间播放启动成功:视频ID={self.video_id}")
+                    else:
+                        logger.warning(f"空闲时间播放启动失败:视频ID={self.video_id}")
+                    
+                    # 等待5分钟后再次触发
+                    logger.info(f"等待 {self.interval_seconds} 秒后再次触发播放")
+                    
+                    # 分段等待,以便能够响应停止信号
+                    waited = 0
+                    while waited < self.interval_seconds and not self._should_stop:
+                        sleep_interval = min(1.0, self.interval_seconds - waited)  # 每秒检查一次
+                        time.sleep(sleep_interval)
+                        waited += sleep_interval
+                    
+                    if self._should_stop:
+                        logger.info("收到停止信号,退出循环")
+                        break
+                        
+                except Exception as e:
+                    logger.error(f"工作线程循环异常: {e}")
+                    time.sleep(1)
+                    
+        except Exception as e:
+            logger.error(f"工作线程异常: {e}")
+        finally:
+            self.is_running = False
+            logger.info("Kodi空闲时间播放线程结束")
+    
+    def stop(self):
+        """停止线程"""
+        self._should_stop = True
+        self.is_running = False
+        logger.info("停止Kodi空闲时间播放线程")
+
+
+# 全局单例实例
+_kodi_free_time_thread = KodiFreeTimeThreadSingleton()
+
+
+def start_kodi_free_time_play() -> bool:
+    """
+    启动Kodi空闲时间播放
+    
+    启动后,线程会每5分钟自动循环播放视频ID 0
+    
+    Returns:
+        bool: 启动是否成功
+    """
+    if not _kodi_free_time_thread.is_running:
+        _kodi_free_time_thread._start_worker_thread()
+    return _kodi_free_time_thread.is_running
+
+
+def stop_kodi_free_time_play() -> bool:
+    """
+    停止Kodi空闲时间播放
+    
+    Returns:
+        bool: 停止是否成功
+    """
+    _kodi_free_time_thread.stop()
+    return True
+
+
+def is_free_time_play_running() -> bool:
+    """
+    检查空闲时间播放线程是否正在运行
+    
+    Returns:
+        bool: 线程是否正在运行
+    """
+    return _kodi_free_time_thread.is_running
+

+ 234 - 0
application/kodi_thread.py

@@ -0,0 +1,234 @@
+import threading
+import time
+from typing import Optional
+
+from hardware.kodi_module import KodiClientManager, VideoInfo
+from utils.logger_config import logger
+
+
+class KodiPlayThreadSingleton:
+    """
+    极简播放线程(仅任务):
+    - 仅在收到新任务时立即播放对应视频一次;
+    - 按配置的时长等待,期间如收到新的任务则打断并切换;
+    - 播放结束后不做任何默认播放,进入空闲,继续等待下一任务。
+    """
+
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls):
+        if cls._instance is None:
+            with cls._lock:
+                if cls._instance is None:
+                    cls._instance = super(KodiPlayThreadSingleton, cls).__new__(cls)
+        return cls._instance
+
+    def __init__(self):
+        if hasattr(self, "_initialized"):
+            return
+        self._initialized = True
+
+        self.manager: Optional[KodiClientManager] = None
+        self.thread: Optional[threading.Thread] = None
+        self.is_running: bool = False
+        self._lock_data = threading.Lock()
+        self._should_stop: bool = False
+        self._incoming_task_id: Optional[int] = None
+
+        self._start_worker_thread()
+
+    def _start_worker_thread(self):
+        if self.thread is None or not self.thread.is_alive():
+            self.is_running = True
+            self.thread = threading.Thread(target=self._worker_loop, daemon=True)
+            self.thread.start()
+            logger.info("KODI任务播放线程已启动")
+
+    def _initialize_manager(self):
+        if self.manager is None:
+            self.manager = KodiClientManager()
+            logger.info("KodiClientManager 初始化成功")
+
+    def _lookup_video_info(self, video_id: int) -> Optional[VideoInfo]:
+        try:
+            if self.manager is None:
+                return None
+            for v in self.manager.video_infos:
+                if v.id == video_id:
+                    return v
+            return None
+        except Exception as e:
+            logger.error(f"查找视频信息失败: {e}")
+            return None
+
+    def _play_sync_by_video_id(self, video_id: int,loop: bool = False) -> bool:
+        video_info = self._lookup_video_info(video_id)
+        if video_info is None:
+            logger.error(f"无效的视频ID: {video_id}")
+            return False
+        try:
+            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}")
+            return True
+        except Exception as e:
+            logger.error(f"同步播放异常: {e}")
+            return False
+    
+    def _play_image_by_url(self, image_url: str,client_index: int) -> bool:
+        try:
+            self.manager.play_url_image_on_client(self.manager.kodi_clients[client_index], image_url)
+            logger.info(f"开始同步播放 图片URL={image_url} 客户端索引={client_index}")
+            return True
+        except Exception as e:
+            logger.error(f"同步播放异常: {e}")
+            return False
+            
+    def _play_rtsp_video_by_url(self, rtsp_url: str, client_index: int, volume: int = 0) -> bool:
+        try:
+            self.manager.play_rtsp_video_on_client(self.manager.kodi_clients[client_index], rtsp_url, volume=volume)
+            logger.info(f"开始同步播放 视频URL={rtsp_url} 客户端索引={client_index} 音量={volume}")
+            return True
+        except Exception as e:
+            logger.error(f"同步播放异常: {e}")
+            return False
+
+    def _worker_loop(self):
+        logger.info("KODI任务播放线程运行中(仅任务,不播放默认)")
+        self._initialize_manager()
+        while self.is_running and not self._should_stop:
+            try:
+                # 等待直到有新任务
+                task_id: Optional[int] = None
+                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
+                            self._incoming_task_id = 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):
+                    logger.warning(f"任务 视频ID={task_id} 播放启动失败,跳过")
+                    continue
+
+                # 等待任务视频时长,期间若有新任务,则立刻打断并切换到处理新任务
+                video_info = self._lookup_video_info(task_id)
+                expected = int(video_info.video_duration) if (video_info and isinstance(video_info.video_duration, int)) else 5
+                interrupted = self._sleep_with_interrupt(expected)
+                if interrupted:
+                    logger.info(f"[主循环] 任务ID={task_id} 被新任务/stop打断,立即处理下一任务")
+                    continue
+
+                logger.info(f"[主循环] 任务ID={task_id} 播放完毕,线程进入空闲等待下一任务")
+                time.sleep(0.05)
+            except Exception as e:
+                logger.error(f"线程异常: {e}")
+                time.sleep(0.5)
+        logger.info("KODI任务播放线程结束")
+
+    # 撤销所有客户端的独立状态
+    def _revoke_individual_state(self):
+        try:
+            self.manager.revoke_individual_state()
+            logger.info("撤销所有客户端的独立状态")
+            return True
+        except Exception as e:
+            logger.error(f"撤销所有客户端的独立状态异常: {e}")
+            return False
+
+    # 启动所有kodi应用程序
+    def _start_all_kodi_apps(self):
+        try:
+            self.manager.start_all_kodi_apps()
+            logger.info("启动所有kodi应用程序")
+            return True
+        except Exception as e:
+            logger.error(f"启动所有kodi应用程序异常: {e}")
+            return False
+
+# 全局单例
+_kodi_thread = KodiPlayThreadSingleton()
+
+def start_kodi_play(video_id: int) -> bool:
+    """开始(或切换到)播放指定视频ID(可被新任务打断)。"""
+    return _kodi_thread._play_sync_by_video_id(video_id)
+
+def stop_kodi_play() -> bool:
+    """停止播放线程与当前播放。"""
+    return _kodi_thread.stop()
+
+def is_kodi_thread_running() -> bool:
+    """线程是否在运行。"""
+    return _kodi_thread.is_running
+
+def play_image(image_url: str, client_index: int) -> bool:
+    """在指定的 Kodi 客户端上播放图片(通过URL)。
+    
+    Args:
+        image_url: 图片的URL地址
+        client_index: Kodi客户端索引(从0开始)
+    
+    Returns:
+        bool: 是否成功启动播放
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        logger.error("KodiClientManager 初始化失败")
+        return False
+    if client_index < 0 or client_index >= len(_kodi_thread.manager.kodi_clients):
+        logger.error(f"无效的客户端索引: {client_index},有效范围: 0-{len(_kodi_thread.manager.kodi_clients)-1}")
+        return False
+    return _kodi_thread._play_image_by_url(image_url, client_index)
+
+def play_rtsp(rtsp_url: str, client_index: int, volume: int = 0) -> bool:
+    """在指定的 Kodi 客户端上播放 RTSP 视频流。
+    
+    Args:
+        rtsp_url: RTSP视频流的URL地址
+        client_index: Kodi客户端索引(从0开始)
+        volume: 播放音量(0-100),默认为0
+    
+    Returns:
+        bool: 是否成功启动播放
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        logger.error("KodiClientManager 初始化失败")
+        return False
+    if client_index < 0 or client_index >= len(_kodi_thread.manager.kodi_clients):
+        logger.error(f"无效的客户端索引: {client_index},有效范围: 0-{len(_kodi_thread.manager.kodi_clients)-1}")
+        return False
+    # 确保音量在有效范围内
+    if volume < 0:
+        volume = 0
+    elif volume > 100:
+        volume = 100
+    return _kodi_thread._play_rtsp_video_by_url(rtsp_url, client_index, volume)
+
+def revoke_individual_state() -> bool:
+    """撤销所有客户端的独立状态。
+    
+    Returns:
+        bool: 是否成功撤销
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        logger.error("KodiClientManager 初始化失败")
+        return False
+    return _kodi_thread._revoke_individual_state()
+
+def start_all_kodi_apps() -> bool:
+    """启动所有kodi应用程序。
+    
+    Returns:
+        bool: 是否成功启动
+    """
+    _kodi_thread._initialize_manager()
+    if _kodi_thread.manager is None:
+        logger.error("KodiClientManager 初始化失败")
+        return False
+    return _kodi_thread._start_all_kodi_apps()

+ 272 - 0
application/wled_thread.py

@@ -0,0 +1,272 @@
+# 这个模块是负责控制wled灯带的线程
+# 只有一个对外开放的函数,这个函数方法描述如下
+# 1.接收一个展品ID,0开始
+# 2.根据展品ID,只有哪个展品ID的灯效是呼吸灯效,其他灯效都设置为静止
+# 3.灯的颜色都是白色
+# 4.当触发10s后,所有展品都随机选择COLOR_RANDOM_WAVE = 5、COLOR_RANDOM_WINK = 7、COLOR_RANDOM_BREATH = 8三个灯效之一
+
+import threading
+import time
+import random
+from typing import Optional
+from hardware.wled_controller_module import WledController
+from hardware.wled_enum import LEDEffectType, RGBColor
+from utils.logger_config import logger
+
+
+class WledThreadSingleton:
+    """WLED线程单例类,线程一直运行,通过修改内部变量控制播放"""
+    
+    _instance = None
+    _lock = threading.Lock()
+    
+    def __new__(cls):
+        if cls._instance is None:
+            with cls._lock:
+                if cls._instance is None:
+                    cls._instance = super(WledThreadSingleton, cls).__new__(cls)
+        return cls._instance
+    
+    def __init__(self):
+        # 避免重复初始化
+        if hasattr(self, '_initialized'):
+            return
+        
+        self._initialized = True
+        self.controller = None
+        self.thread = None
+        self.is_running = False
+        
+        # 线程控制变量(需要加锁)
+        self._data_lock = threading.Lock()
+        self._current_exhibit_id = 0
+        self._is_new_task = False
+        self._should_stop = False
+        
+        # 启动工作线程
+        self._start_worker_thread()
+    
+    def _start_worker_thread(self):
+        """启动工作线程"""
+        if self.thread is None or not self.thread.is_alive():
+            self.is_running = True
+            self.thread = threading.Thread(target=self._worker_loop, daemon=True)
+            self.thread.start()
+            logger.info("WLED工作线程已启动")
+    
+    def _initialize_controller(self):
+        """初始化WLED控制器"""
+        try:
+            if self.controller is None:
+                self.controller = WledController()
+                logger.info("WLED控制器初始化成功")
+        except Exception as e:
+            logger.error(f"WLED控制器初始化失败: {e}")
+            raise
+    
+    def _worker_loop(self):
+        """工作线程主循环"""
+        logger.info("WLED工作线程开始运行")
+        
+        try:
+            # 初始化控制器
+            self._initialize_controller()
+            
+            # 状态变量
+            current_phase = "idle"  # idle, breathing, random
+            phase_start_time = 0
+            breathing_duration = 10  # 呼吸灯效持续时间(秒)
+            
+            while self.is_running and not self._should_stop:
+                try:
+                    # 检查是否有新任务
+                    with self._data_lock:
+                        is_new_task = self._is_new_task
+                        exhibit_id = self._current_exhibit_id
+                        if is_new_task:
+                            self._is_new_task = False
+                    
+                    # 处理新任务
+                    if is_new_task:
+                        logger.info(f"收到新任务,展品ID: {exhibit_id}")
+                        current_phase = "breathing"
+                        phase_start_time = time.time()
+                        self._set_breathing_effect(exhibit_id)
+                    
+                    # 检查阶段切换
+                    if current_phase == "breathing":
+                        elapsed_time = time.time() - phase_start_time
+                        if elapsed_time >= breathing_duration:
+                            logger.info("切换到随机灯效阶段")
+                            current_phase = "random"
+                            self._set_random_effects()
+                    
+                    # 短暂休眠,避免CPU占用过高
+                    time.sleep(0.1)
+                    
+                except Exception as e:
+                    logger.error(f"工作线程循环异常: {e}")
+                    time.sleep(1)
+                    
+        except Exception as e:
+            logger.error(f"工作线程异常: {e}")
+        finally:
+            logger.info("WLED工作线程结束")
+    
+    def set_exhibit_task(self, exhibit_id: int) -> bool:
+        """
+        设置新的展品任务
+        
+        Args:
+            exhibit_id: 展品ID,从0开始
+            
+        Returns:
+            bool: 设置是否成功
+        """
+        try:
+            # 验证展品ID
+            if not isinstance(exhibit_id, int) or exhibit_id < 0:
+                logger.error(f"无效的展品ID: {exhibit_id}")
+                return False
+            
+            # 检查展品ID是否在有效范围内
+            if self.controller is None:
+                self._initialize_controller()
+            
+            segments_count = self.controller.wled_state.get_segments_count()
+            if exhibit_id >= segments_count:
+                logger.error(f"展品ID {exhibit_id} 超出范围,最大ID为 {segments_count - 1}")
+                return False
+            
+            # 设置新任务
+            with self._data_lock:
+                self._current_exhibit_id = exhibit_id
+                self._is_new_task = True
+            
+            logger.info(f"设置新任务,展品ID: {exhibit_id}")
+            return True
+            
+        except Exception as e:
+            logger.error(f"设置展品任务失败: {e}")
+            return False
+    
+    def stop_effect(self):
+        """停止当前灯效"""
+        with self._data_lock:
+            self._should_stop = True
+        logger.info("停止灯效控制")
+    
+    def _set_breathing_effect(self, exhibit_id: int):
+        """
+        设置呼吸灯效
+        
+        Args:
+            exhibit_id: 展品ID
+        """
+        try:
+            logger.info(f"设置展品 {exhibit_id} 为呼吸灯效")
+            
+            # 获取所有分段
+            segments = self.controller.wled_state.seg
+            
+            for i, segment in enumerate(segments):
+                if i == exhibit_id:
+                    # 目标展品:设置为呼吸灯效,白色
+                    segment.set_color(RGBColor.WHITE.rgb_tuple)
+                    segment.set_effect(LEDEffectType.BREATH.value, speed=200, intensity=255)
+                    segment.on = True
+                    logger.info(f"分段 {i} ({segment}) 设置为呼吸灯效")
+                else:
+                    # 其他展品:设置为静止,白色
+                    segment.set_color(RGBColor.WHITE.rgb_tuple)
+                    segment.set_effect(LEDEffectType.SOLID.value, speed=0, intensity=0)
+                    segment.on = True
+                    logger.info(f"分段 {i} ({segment}) 设置为静止")
+            
+            # 发送状态到设备
+            success = self.controller.send_state()
+            if success:
+                logger.info("呼吸灯效设置成功")
+            else:
+                logger.error("呼吸灯效设置失败")
+                
+        except Exception as e:
+            logger.error(f"设置呼吸灯效失败: {e}")
+    
+    def _set_random_effects(self):
+        """
+        设置随机灯效
+        """
+        try:
+            logger.info(f"设置所有展品为随机灯效")
+            
+            # 随机选择灯效
+            random_effects = [
+                LEDEffectType.COLOR_RANDOM_BREATH.value
+            ]
+            
+            # 获取所有分段
+            segments = self.controller.wled_state.seg
+            
+            for i, segment in enumerate(segments):
+                # 为每个展品随机选择一种灯效
+                selected_effect = random.choice(random_effects)
+                segment.set_color(RGBColor.WHITE.rgb_tuple)
+                segment.set_effect(selected_effect, speed=200, intensity=255)
+                segment.on = True
+                logger.info(f"分段 {i} ({segment}) 设置为随机灯效 {selected_effect}")
+            
+            # 发送状态到设备
+            self.controller.get_wled_state().set_transition_time(10000)
+            success = self.controller.send_state()
+            if success:
+                logger.info("所有展品的随机灯效设置成功")
+            else:
+                logger.error("随机灯效设置失败")
+                
+        except Exception as e:
+            logger.error(f"设置随机灯效失败: {e}")
+
+
+# 全局单例实例
+_wled_thread = WledThreadSingleton()
+
+
+def start_exhibit_led_effect(exhibit_id: int) -> bool:
+    """
+    启动展品LED灯效控制
+    
+    这是模块的主要对外接口函数,功能如下:
+    1. 接收一个展品ID,从0开始
+    2. 根据展品ID,只有该展品ID的灯效是呼吸灯效,其他灯效都设置为静止
+    3. 灯的颜色都是白色
+    4. 当触发10秒后,所有展品都随机选择COLOR_RANDOM_WAVE = 5、COLOR_RANDOM_WINK = 7、COLOR_RANDOM_BREATH = 8三个灯效之一
+    
+    Args:
+        exhibit_id: 展品ID,从0开始
+        
+    Returns:
+        bool: 启动是否成功
+    """
+    return _wled_thread.set_exhibit_task(exhibit_id)
+
+
+def stop_led_effect():
+    """
+    停止当前LED灯效
+    
+    Returns:
+        bool: 停止是否成功
+    """
+    _wled_thread.stop_effect()
+    return True
+
+
+def is_effect_running() -> bool:
+    """
+    检查是否有灯效正在运行
+    
+    Returns:
+        bool: 是否有灯效正在运行
+    """
+    return _wled_thread.is_running

+ 469 - 0
flask_api.py

@@ -0,0 +1,469 @@
+from flask import Flask, jsonify, request, render_template, send_from_directory
+from flask_cors import CORS
+import json
+import os
+import time
+import uuid
+import socket
+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 utils.logger_config import logger
+
+
+app = Flask(__name__)
+CORS(app)  # 允许跨域请求
+
+# 配置上传文件夹
+UPLOAD_FOLDER = 'uploads'
+ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
+app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
+app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 限制上传文件大小为16MB
+
+# 确保上传文件夹存在
+if not os.path.exists(UPLOAD_FOLDER):
+    os.makedirs(UPLOAD_FOLDER)
+
+def allowed_file(filename):
+    """检查文件扩展名是否允许"""
+    return '.' in filename and \
+           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
+
+def get_server_ip():
+    """获取服务器的本地IP地址"""
+    try:
+        # 连接到一个远程地址(不会实际发送数据)
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        ip = s.getsockname()[0]
+        s.close()
+        return ip
+    except Exception:
+        # 如果获取失败,尝试其他方法
+        try:
+            hostname = socket.gethostname()
+            ip = socket.gethostbyname(hostname)
+            return ip
+        except Exception:
+            return "127.0.0.1"  # 回退到localhost
+
+
+@app.route('/')
+def index():
+    """返回HTML页面"""
+    return render_template('index.html')
+
+
+@app.route('/api/led/status', methods=['GET'])
+def get_led_status():
+    """获取LED状态"""
+    try:
+        is_running = is_effect_running()
+        return jsonify({
+            "success": True,
+            "data": {
+                "is_running": is_running,
+                "message": "灯效正在运行" if is_running else "灯效已停止"
+            }
+        })
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"获取状态失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/led/start', methods=['POST'])
+def start_led_effect():
+    """启动展品LED灯效控制"""
+    try:
+        data = request.get_json()
+        if not data or 'exhibit_id' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少展品ID参数"
+            }), 400
+        
+        exhibit_id = data['exhibit_id']
+        if not isinstance(exhibit_id, int) or exhibit_id < 0:
+            return jsonify({
+                "success": False,
+                "message": "展品ID必须是大于等于0的整数"
+            }), 400
+        
+        success = start_exhibit_led_effect(exhibit_id)
+        if success:
+            return jsonify({
+                "success": True,
+                "message": f"展品 {exhibit_id} 的灯效已启动",
+                "data": {
+                    "exhibit_id": exhibit_id,
+                    "is_running": True
+                }
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": f"启动展品 {exhibit_id} 的灯效失败"
+            }), 500
+            
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"启动灯效失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/led/stop', methods=['POST'])
+def stop_led_effect_api():
+    """停止当前LED灯效"""
+    try:
+        success = stop_led_effect()
+        if success:
+            return jsonify({
+                "success": True,
+                "message": "灯效已停止",
+                "data": {
+                    "is_running": False
+                }
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": "停止灯效失败"
+            }), 500
+            
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"停止灯效失败: {str(e)}"
+        }), 500
+
+
+# ===== Kodi 播放控制接口 =====
+@app.route('/api/kodi/status', methods=['GET'])
+def get_kodi_status():
+    try:
+        running = is_kodi_thread_running()
+        return jsonify({
+            "success": True,
+            "data": {
+                "is_running": running,
+                "message": "Kodi播放线程运行中" if running else "Kodi播放线程已停止"
+            }
+        })
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"获取Kodi状态失败: {str(e)}"
+        }), 500
+
+
+@app.route('/api/kodi/start', methods=['POST'])
+def start_kodi_play_api():
+    try:
+        data = request.get_json()
+        if not data or 'video_id' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少视频ID参数"
+            }), 400
+        video_id = data['video_id']
+        if not isinstance(video_id, int) or video_id < 0:
+            return jsonify({
+                "success": False,
+                "message": "视频ID必须是大于等于0的整数"
+            }), 400
+        ok = start_kodi_play(video_id)
+        if ok:
+            return jsonify({
+                "success": True,
+                "message": f"Kodi开始/切换播放 视频ID={video_id}",
+                "data": {"video_id": video_id}
+            })
+        return jsonify({
+            "success": False,
+            "message": f"Kodi播放启动失败(视频ID={video_id})"
+        }), 500
+    except Exception as e:
+        return jsonify({
+            "success": False,
+            "message": f"Kodi播放启动异常: {str(e)}"
+        }), 500
+
+# 指定某台kodi_client_index播放图片,这边要上传图片并且传递完整url给kodi播放
+@app.route('/api/kodi/play_image', methods=['POST'])
+def play_image_api():
+    """播放图片接口,支持文件上传或直接传递图片URL"""
+    try:
+        # 检查是否有文件上传
+        if 'file' in request.files:
+            file = request.files['file']
+            if file.filename == '':
+                return jsonify({
+                    "success": False,
+                    "message": "未选择文件"
+                }), 400
+            
+            if file and allowed_file(file.filename):
+                # 获取原始文件扩展名
+                original_filename = file.filename
+                # 提取文件扩展名(不包含点号)
+                if '.' in original_filename:
+                    file_ext = original_filename.rsplit('.', 1)[1].lower()
+                else:
+                    # 如果没有扩展名,根据Content-Type推断或默认为jpg
+                    content_type = file.content_type or ''
+                    if 'png' in content_type:
+                        file_ext = 'png'
+                    elif 'jpeg' in content_type or 'jpg' in content_type:
+                        file_ext = 'jpg'
+                    elif 'gif' in content_type:
+                        file_ext = 'gif'
+                    else:
+                        file_ext = 'jpg'  # 默认扩展名
+                
+                # 确保扩展名在允许列表中
+                if file_ext not in ALLOWED_EXTENSIONS:
+                    file_ext = 'jpg'
+                
+                # 生成唯一文件名:使用时间戳和UUID,确保文件名唯一且安全
+                timestamp = int(time.time() * 1000)
+                unique_id = str(uuid.uuid4())[:8]  # 使用UUID的前8位作为唯一标识
+                filename = f"{timestamp}_{unique_id}.{file_ext}"
+                
+                filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+                file.save(filepath)
+                
+                # 生成可访问的URL(使用服务器的实际IP地址,而不是localhost)
+                # 如果request.host包含localhost或127.0.0.1,使用实际IP
+                host = request.host
+                if 'localhost' in host or '127.0.0.1' in host:
+                    server_ip = get_server_ip()
+                    port = request.environ.get('SERVER_PORT', '5000')
+                    host = f"{server_ip}:{port}"
+                
+                image_url = f"http://{host}/uploads/{filename}"
+                logger.info(f"图片已上传: {filepath}, URL: {image_url}, 原始文件名: {original_filename}")
+            else:
+                return jsonify({
+                    "success": False,
+                    "message": f"不支持的文件类型,允许的类型: {', '.join(ALLOWED_EXTENSIONS)}"
+                }), 400
+        elif request.is_json:
+            # 检查是否有直接的图片URL
+            data = request.get_json()
+            if 'image_url' not in data:
+                return jsonify({
+                    "success": False,
+                    "message": "缺少参数:需要 'file'(文件上传)或 'image_url'(图片URL)"
+                }), 400
+            image_url = data['image_url']
+            if not image_url or not isinstance(image_url, str):
+                return jsonify({
+                    "success": False,
+                    "message": "无效的图片URL"
+                }), 400
+        else:
+            return jsonify({
+                "success": False,
+                "message": "缺少参数:需要 'file'(文件上传)或 'image_url'(图片URL)"
+            }), 400
+        
+        # 获取客户端索引
+        if 'kodi_client_index' in request.form:
+            try:
+                client_index = int(request.form['kodi_client_index'])
+            except (ValueError, TypeError):
+                return jsonify({
+                    "success": False,
+                    "message": "kodi_client_index 必须是整数"
+                }), 400
+        elif request.is_json and 'kodi_client_index' in request.get_json():
+            client_index = request.get_json()['kodi_client_index']
+        else:
+            return jsonify({
+                "success": False,
+                "message": "缺少参数: kodi_client_index"
+            }), 400
+        
+        if not isinstance(client_index, int) or client_index < 0:
+            return jsonify({
+                "success": False,
+                "message": "kodi_client_index 必须是大于等于0的整数"
+            }), 400
+        
+        # 调用播放函数
+        success = play_image(image_url, client_index)
+        if success:
+            return jsonify({
+                "success": True,
+                "message": f"已在客户端 {client_index} 上启动图片播放",
+                "data": {
+                    "image_url": image_url,
+                    "client_index": client_index
+                }
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": f"在客户端 {client_index} 上启动图片播放失败"
+            }), 500
+            
+    except Exception as e:
+        logger.error(f"播放图片异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"播放图片失败: {str(e)}"
+        }), 500
+
+# 指定某台kodi_client_index播放rtsp视频
+@app.route('/api/kodi/play_rtsp', methods=['POST'])
+def play_rtsp_api():
+    """播放RTSP视频流接口"""
+    try:
+        data = request.get_json()
+        if not data:
+            return jsonify({
+                "success": False,
+                "message": "请求体不能为空"
+            }), 400
+        
+        # 检查必需的参数
+        if 'rtsp_url' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少参数: rtsp_url"
+            }), 400
+        
+        if 'kodi_client_index' not in data:
+            return jsonify({
+                "success": False,
+                "message": "缺少参数: kodi_client_index"
+            }), 400
+        
+        rtsp_url = data['rtsp_url']
+        client_index = data['kodi_client_index']
+        volume = data.get('volume', 0)  # 可选参数,默认为0
+        
+        # 参数验证
+        if not isinstance(rtsp_url, str) or not rtsp_url.strip():
+            return jsonify({
+                "success": False,
+                "message": "rtsp_url 必须是有效的字符串"
+            }), 400
+        
+        if not isinstance(client_index, int) or client_index < 0:
+            return jsonify({
+                "success": False,
+                "message": "kodi_client_index 必须是大于等于0的整数"
+            }), 400
+        
+        if not isinstance(volume, int) or volume < 0 or volume > 100:
+            return jsonify({
+                "success": False,
+                "message": "volume 必须是 0-100 之间的整数"
+            }), 400
+        
+        # 调用播放函数
+        success = play_rtsp(rtsp_url, client_index, volume)
+        if success:
+            return jsonify({
+                "success": True,
+                "message": f"已在客户端 {client_index} 上启动RTSP播放",
+                "data": {
+                    "rtsp_url": rtsp_url,
+                    "client_index": client_index,
+                    "volume": volume
+                }
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": f"在客户端 {client_index} 上启动RTSP播放失败"
+            }), 500
+            
+    except Exception as e:
+        logger.error(f"播放RTSP异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"播放RTSP失败: {str(e)}"
+        }), 500
+
+@app.route('/api/kodi/revoke_individual_state', methods=['POST'])
+def revoke_individual_state_api():
+    """撤销所有客户端的独立状态接口"""
+    try:
+        success = revoke_individual_state()
+        if success:
+            return jsonify({
+                "success": True,
+                "message": "已撤销所有客户端的独立状态"
+            })
+        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('/api/kodi/start_all_apps', methods=['POST'])
+def start_all_kodi_apps_api():
+    """启动所有kodi应用程序接口"""
+    try:
+        success = start_all_kodi_apps()
+        if success:
+            return jsonify({
+                "success": True,
+                "message": "已启动所有kodi应用程序"
+            })
+        else:
+            return jsonify({
+                "success": False,
+                "message": "启动所有kodi应用程序失败"
+            }), 500
+    except Exception as e:
+        logger.error(f"启动所有kodi应用程序异常: {str(e)}")
+        return jsonify({
+            "success": False,
+            "message": f"启动所有kodi应用程序失败: {str(e)}"
+        }), 500
+
+# 提供上传文件的访问接口
+@app.route('/uploads/<filename>')
+def uploaded_file(filename):
+    """提供上传文件的访问接口"""
+    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
+
+
+
+if __name__ == '__main__':
+    # 创建templates目录
+    import os
+    if not os.path.exists('templates'):
+        os.makedirs('templates')
+    logger.info("启动Kodi心跳检测")
+    start_kodi_alive_check()
+    logger.info("启动Kodi空闲时间播放")
+    start_kodi_free_time_play()
+    logger.info("Flask API服务器启动中...")
+    logger.info("访问 http://localhost:5050 查看HTML页面")
+    logger.info("API端点:")
+    logger.info("  GET  /api/led/status - 获取LED状态")
+    logger.info("  POST /api/led/start - 启动展品灯效")
+    logger.info("  POST /api/led/stop - 停止灯效")
+    logger.info("  GET  /api/kodi/status - 获取Kodi状态")
+    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应用程序")
+    
+    app.run(debug=True, host='0.0.0.0', port=5050)

+ 412 - 0
hardware/kodi_module.py

@@ -0,0 +1,412 @@
+import json
+from typing import Any, Dict
+import requests
+import time
+import threading
+import yaml  # Add yaml import
+import os
+import sys
+from utils.logger_config import logger
+
+class VideoInfo:
+    def __init__(self, name, formula, description, video_duration, video_path, id=0):
+        self.name = name
+        self.formula = formula
+        self.description = description
+        self.video_duration = video_duration
+        self.video_path = video_path
+        self.id = id
+
+class KodiClient:
+    def __init__(self, host="localhost", port=8080, username=None, password=None, id=0):
+        self.host = host
+        self.port = port
+        self.id = id
+        self.url = f"http://{host}:{port}/jsonrpc"
+        self.headers = {'Content-Type': 'application/json'}
+        # 非独立的客户端默认播放展品视频,独立情况下可能会播放图片或监控
+        self.isIndividual = False
+        # 启动kodi的url
+        self.my_app_name = "org.xbmc.kodi" # kodi的包名
+        self.mitv_startapp_url = f"http://{host}:6095/controller?action=startapp&&type=packagename&packagename={self.my_app_name}"
+        
+        if username and password:
+            self.auth = (username, password)
+        else:
+            self.auth = None
+        self.ready_event = threading.Event()
+
+    def _send_request(self, method, params=None):
+        data = {
+            "jsonrpc": "2.0",
+            "method": method,
+            "id": 1,
+        }
+        if params:
+            data["params"] = params
+        response = requests.post(
+            self.url,
+            data=json.dumps(data),
+            headers=self.headers,
+            auth=self.auth
+        )
+        return response.json()
+
+    def _send_request_async(self, method, params=None):
+        """在后台线程中发送JSON-RPC请求,立即返回,不阻塞当前调用。"""
+        def _worker():
+            try:
+                resp = self._send_request(method, params)
+                logger.debug(f"[async] {method} 响应: {resp}")
+            except Exception as e:
+                logger.warning(f"[async] {method} 调用异常: {e}")
+        t = threading.Thread(target=_worker, daemon=True)
+        t.start()
+        return True
+
+    # 播放url图片
+    def play_url_image(self, image_url):
+        """播放指定url的图片文件(异步派发)。"""
+        params = {"item": {"file": image_url}}
+        self._send_request_async("Player.Open", params)
+        logger.info(f"[async] Player.Open(url image) 已派发: {image_url}")
+        return {"queued": True, "file": image_url}
+
+    # 播放rtsp视频流
+    def play_rtsp_video(self, rtsp_url):
+        """播放指定rtsp视频流(异步派发)。"""
+        params = {"item": {"file": rtsp_url}}
+        self._send_request_async("Player.Open", params)
+        logger.info(f"[async] Player.Open(rtsp) 已派发: {rtsp_url}")
+        return {"queued": True, "file": rtsp_url}
+
+    def play_video(self, video_path, loop=False):
+        """播放指定路径的视频文件(异步派发)。"""
+        params = {"item": {"file": video_path}}
+        self._send_request_async("Player.Open", params)
+        logger.info(f"[async] Player.Open(video) 已派发: {video_path}")
+        return {"queued": True, "file": video_path}
+
+    def play_playlist_looped(self, video_paths):
+        """清空播放列表,添加多个视频,并循环播放(整体异步派发)。"""
+        if not isinstance(video_paths, list) or not video_paths:
+            logger.error("错误:video_paths 必须是一个非空列表。")
+            return None
+        playlist_id = 1
+        def _playlist_worker():
+            try:
+                logger.info(f"[async] Playlist.Clear -> {playlist_id}")
+                self._send_request("Playlist.Clear", {"playlistid": playlist_id})
+                for vp in video_paths:
+                    logger.info(f"[async] Playlist.Add -> {vp}")
+                    self._send_request("Playlist.Add", {"playlistid": playlist_id, "item": {"file": vp}})
+                logger.info(f"[async] Player.Open playlist -> position 0")
+                self._send_request("Player.Open", {"item": {"playlistid": playlist_id, "position": 0}})
+                logger.info(f"[async] Player.SetRepeat(all) -> playerid 1")
+                self._send_request("Player.SetRepeat", {"playerid": 1, "repeat": "all"})
+            except Exception as e:
+                logger.warning(f"[async] 播放列表派发异常: {e}")
+        threading.Thread(target=_playlist_worker, daemon=True).start()
+        return {"queued": True, "playlist": len(video_paths)}
+
+    def _get_active_player_id(self):
+        """获取当前活动的播放器ID"""
+        try:
+            response = self._send_request("Player.GetActivePlayers")
+            logger.debug(f"Player.GetActivePlayers 响应: {response}")
+            if response and response.get('result'):
+                players = response['result']
+                if players:
+                    return players[0].get('playerid')
+            logger.warning("未能从响应中找到有效的播放器ID。")
+            return None
+        except Exception as e:
+            logger.error(f"获取活动播放器ID时出错: {str(e)}")
+            return None
+
+    def stop_playback(self):
+        """停止当前播放(异步派发)。"""
+        # 直接尝试默认播放器1,避免阻塞
+        self._send_request_async("Player.Stop", {"playerid": 1})
+        logger.info("[async] Player.Stop 已派发 (playerid=1)")
+        return {"queued": True}
+
+
+    def pause_playback(self):
+        """暂停/继续播放(异步派发)。"""
+        self._send_request_async("Player.PlayPause", {"playerid": 1})
+        logger.info("[async] Player.PlayPause 已派发 (playerid=1)")
+        return {"queued": True}
+
+    def set_volume(self, volume):
+        """设置Kodi音量 (0-100)"""
+        if not isinstance(volume, int) or not 0 <= volume <= 100:
+            logger.error("错误:音量必须是 0 到 100 之间的整数。")
+            return None
+        params = {"volume": volume}
+        # 异步发送,不阻塞调用方
+        self._send_request_async("Application.SetVolume", params)
+        logger.info(f"Application.SetVolume ({volume}%) 已异步派发")
+        return {"queued": True, "volume": volume}
+
+    def get_player_state(self):
+        """获取当前播放器状态(为避免阻塞,改为异步派发查询,并返回占位数据)。"""
+        self._send_request_async('Player.GetActivePlayers')
+        logger.info("[async] Player.GetActivePlayers 已派发(不阻塞)")
+        # 占位返回,避免调用方阻塞;如需真实状态,应改造为回调/轮询机制
+        return {'queued': True}
+
+    def set_ready(self):
+        """设置客户端准备就绪"""
+        self.ready_event.set()
+
+    def wait_for_ready(self, timeout=10):
+        """等待客户端准备就绪"""
+        return self.ready_event.wait(timeout)
+
+    def set_individual(self, isIndividual=False):
+        """设置客户端是否为独立客户端"""
+        self.isIndividual = isIndividual
+
+    def get_individual(self):
+        """获取客户端是否为独立客户端"""
+        return self.isIndividual
+
+    # 启动kodi
+    def start_kodi(self):
+        """启动kodi"""
+        res = requests.get(self.mitv_startapp_url, timeout=3).json()
+        logger.info(f"启动kodi响应: {res}")
+        return res
+
+    # kodi心跳检测,检查kodi客户端是否在线
+    def kodi_heartbeat_check(self):
+        """检查kodi客户端是否在线"""
+        try:
+            # 使用JSON-RPC请求进行心跳检测,支持认证
+            data = {
+                "jsonrpc": "2.0",
+                "method": "JSONRPC.Version",
+                "id": 1
+            }
+            response = requests.post(
+                self.url,
+                data=json.dumps(data),
+                headers=self.headers,
+                auth=self.auth,
+                timeout=3
+            )
+            # 检查HTTP状态码
+            if response.status_code != 200:
+                logger.warning(f"kodi心跳检测失败: HTTP状态码 {response.status_code}")
+                return False
+            # 检查响应内容是否为空
+            if not response.text or response.text.strip() == '':
+                logger.warning(f"kodi心跳检测失败: 响应内容为空")
+                return False
+            # 尝试解析JSON
+            try:
+                json_data = response.json()
+                # JSONRPC.Version返回的结果包含version字段表示成功
+                if json_data.get('result') is not None:
+                    return True
+                else:
+                    return False
+            except json.JSONDecodeError as json_err:
+                logger.warning(f"kodi心跳检测失败: JSON解析错误 - {json_err}, 响应内容: {response.text[:100]}")
+                return False
+        except requests.exceptions.Timeout:
+            logger.warning(f"kodi心跳检测超时: 连接 {self.url} 超时")
+            return False
+        except requests.exceptions.ConnectionError:
+            logger.warning(f"kodi心跳检测失败: 无法连接到 {self.url}")
+            return False
+        except Exception as e:
+            logger.error(f"kodi心跳检测异常: {e}")
+            return False    
+
+class KodiClientManager():
+
+    def __init__(self):
+        self.kodi_clients = []
+        self.video_infos = []
+        # 生产环境配置文件路径
+        self.kodi_config_path = 'kodi_config_prod.yaml'
+        self.video_config_path = 'video_config_prod.yaml'
+        # 开发环境配置文件路径
+        # self.kodi_config_path = 'kodi_config_test.yaml'
+        # self.video_config_path = 'video_config_test.yaml'
+        # 只有一台可以有声音,其他没有声音,这是音量
+        self.volume = 65
+        self._init_kodi_clients_from_config()
+        self._init_video_infos_from_config()
+
+    def set_volume(self, volume):
+        # 设置播放视频的音量
+        self.volume = volume
+    
+    def _init_video_infos_from_config(self):
+        config = self._load_config(self.video_config_path)
+        video_infos_config = config.get('video_infos', [])
+        for video_info_config in video_infos_config:
+            video_info = VideoInfo(video_info_config.get('name'), video_info_config.get('formula'), video_info_config.get('description'), video_info_config.get('video_duration'), video_info_config.get('video_path'), video_info_config.get('id'))
+            logger.info(f"成功加载视频信息: {video_info.name}")
+            self.video_infos.append(video_info)
+
+    def _load_config(self, config_path) -> Dict[str, Any]:
+        """
+        从YAML配置文件加载配置
+        
+        Returns:
+            dict: 配置字典
+            
+        Raises:
+            FileNotFoundError: 配置文件不存在
+            yaml.YAMLError: YAML解析错误
+            Exception: 其他加载错误
+        """
+
+        if not os.path.exists(config_path):
+            error_msg = f"配置文件不存在: {config_path}"
+            logger.error(error_msg)
+            raise FileNotFoundError(error_msg)
+        
+        try:
+            with open(config_path, 'r', encoding='utf-8') as file:
+                config = yaml.safe_load(file)
+                if config is None:
+                    error_msg = f"配置文件为空或格式错误: {config_path}"
+                    logger.error(error_msg)
+                    raise ValueError(error_msg)
+                logger.info(f"成功加载配置文件: {config_path}")
+                return config
+        except yaml.YAMLError as e:
+            error_msg = f"YAML解析错误: {e}"
+            logger.error(error_msg)
+            raise
+        except Exception as e:
+            error_msg = f"加载配置文件失败: {e}"
+            logger.error(error_msg)
+            raise
+
+    def _init_kodi_clients_from_config(self):
+        config = self._load_config(self.kodi_config_path)
+        kodi_servers_config = config.get('kodi_servers', [])
+
+        if not kodi_servers_config:
+            logger.error("未在 config.yaml 中找到有效的 Kodi 服务器配置,脚本将退出。")
+            exit()
+
+        # 创建 Kodi 客户端实例列表
+        
+        for server_config in kodi_servers_config:
+            client = KodiClient(
+                host=server_config.get('ip', 'localhost'),
+                port=server_config.get('port', 8080),
+                username=server_config.get('username'),
+                password=server_config.get('password'),
+                id=server_config.get('id', 0)
+            )
+            try:
+                if hasattr(client, 'set_volume') and callable(getattr(client, 'set_volume')):
+                    client.set_volume(65)
+                else:
+                    # 兼容旧构建:直接通过 JSON-RPC 设置音量
+                    client._send_request("Application.SetVolume", {"volume": 65})
+            except Exception as e:
+                logger.warning(f"设置音量时出现问题(已忽略以继续):{e}")
+            self.kodi_clients.append(client)
+
+
+
+    def _resolve_config_path(self, filename = "kodi_config.yaml"):
+        """Return absolute path to config file, located next to the script/exe.
+
+        When bundled with PyInstaller, sys.frozen is True and sys.executable points to the exe.
+        In normal execution, use the directory of this file.
+        """
+        try:
+            if getattr(sys, "frozen", False):
+                base_dir = os.path.dirname(sys.executable)
+            else:
+                base_dir = os.path.dirname(os.path.abspath(__file__))
+            return os.path.join(base_dir, filename)
+        except Exception:
+            # Fallback to current working directory
+            return filename
+
+    def sync_play_video(self, clients, video_path, loop=False):
+        """同步播放视频"""
+        # 创建一个共享的Event来控制所有客户端同时开始播放
+        start_event = threading.Event()
+        logger.info(f"开始同步播放视频: {video_path}")
+        
+        # 创建播放线程
+        def play_thread(client):
+            # 等待开始信号
+            start_event.wait()
+            # 执行播放
+            result = client.play_video(video_path, loop=loop)
+            logger.info(f"播放结果: {result}")
+
+        # 启动所有播放线程
+        threads = []
+        client_index = 0
+        for client in clients:
+            
+            if client.get_individual():
+                client_index += 1
+                continue
+            # 只对第一台设置音量
+            if client == clients[client_index]:
+                client.set_volume(self.volume)
+            else:
+                client.set_volume(0)
+            thread = threading.Thread(target=play_thread, args=(client,))
+            threads.append(thread)
+            thread.start()
+
+        # 等待所有线程准备就绪
+        time.sleep(0.1)  # 给线程一点时间启动
+        
+        # 同时触发所有客户端开始播放
+        start_event.set()
+
+        # 等待所有线程完成
+        for thread in threads:
+            thread.join()
+
+    # 指定某台播放url图片
+    def play_url_image_on_client(self, client, image_url):
+        """指定某台播放url图片"""
+        client.isIndividual = True
+        return client.play_url_image(image_url)
+    # 指定某台播放rtsp视频流
+    def play_rtsp_video_on_client(self, client, rtsp_url,volume=0):
+        """指定某台播放rtsp视频流"""
+        client.isIndividual = True
+        client.set_volume(volume)
+        return client.play_rtsp_video(rtsp_url)
+    # 撤销所有客户端的独立状态
+    def revoke_individual_state(self):
+        """撤销所有客户端的独立状态"""
+        for client in self.kodi_clients:
+            client.isIndividual = False
+            client.stop_playback()
+
+    # 启动所有kodi应用程序
+    def start_all_kodi_apps(self):
+        """启动所有kodi应用程序"""
+        for client in self.kodi_clients:
+            client.start_kodi()
+
+    # 检查所有kodi客户端是否在线,如果有不在线返回不在线的client_index
+    def check_all_kodi_clients_online(self):
+        """检查所有kodi客户端是否在线"""
+        client_index = 0
+        for client in self.kodi_clients:
+            
+            if not client.kodi_heartbeat_check():
+                return client_index
+            client_index += 1
+        return -1

+ 317 - 0
hardware/wled_controller_module.py

@@ -0,0 +1,317 @@
+
+import yaml
+import os
+import requests
+import time
+from typing import Optional, Dict, Any
+from .wled_module import WledState, WledSegment
+from utils.logger_config import logger
+
+class WledController:
+    """
+    WLED控制器类
+    负责从配置文件读取设置,生成WledState对象,并提供设备控制功能
+    """
+    
+    def __init__(self, config_path: str = "led_config.yaml"):
+        """
+        初始化WLED控制器
+        
+        Args:
+            config_path: 配置文件路径,默认为led_config.yaml
+            
+        Raises:
+            FileNotFoundError: 配置文件不存在
+            KeyError: 必需的配置项缺失
+            ValueError: 配置值无效
+        """
+        self.config_path = config_path
+        self.config = self._load_config()
+        
+        # 验证必需的配置项
+        if 'wled_ip' not in self.config:
+            error_msg = "配置文件中缺少必需的 'wled_ip' 配置项"
+            logger.error(error_msg)
+            raise KeyError(error_msg)
+        
+        if 'led_group_count' not in self.config:
+            error_msg = "配置文件中缺少必需的 'led_group_count' 配置项"
+            logger.error(error_msg)
+            raise KeyError(error_msg)
+        
+        if 'segments' not in self.config:
+            error_msg = "配置文件中缺少必需的 'segments' 配置项"
+            logger.error(error_msg)
+            raise KeyError(error_msg)
+        
+        # 验证配置值
+        self.wled_ip = self.config['wled_ip']
+        if not isinstance(self.wled_ip, str) or not self.wled_ip.strip():
+            error_msg = f"无效的 wled_ip 配置值: {self.wled_ip}"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        self.led_group_count = self.config['led_group_count']
+        if not isinstance(self.led_group_count, int) or self.led_group_count <= 0:
+            error_msg = f"无效的 led_group_count 配置值: {self.led_group_count},必须为正整数"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        self.segments_config = self.config['segments']
+        if not isinstance(self.segments_config, list):
+            error_msg = f"无效的 segments 配置值: {self.segments_config},必须为列表"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        if not self.segments_config:
+            error_msg = "segments 配置不能为空"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        # 按配置文件分段
+        self.wled_state = self._create_wled_state()
+        # 创建合并所有分段的状态对象,用于控制整条灯带
+        self.wled_state_all = self.wled_state.merge_segments()
+        logger.info(f"WLED控制器初始化完成,IP: {self.wled_ip}")
+    
+    def _load_config(self) -> Dict[str, Any]:
+        """
+        从YAML配置文件加载配置
+        
+        Returns:
+            dict: 配置字典
+            
+        Raises:
+            FileNotFoundError: 配置文件不存在
+            yaml.YAMLError: YAML解析错误
+            Exception: 其他加载错误
+        """
+
+        if not os.path.exists(self.config_path):
+            error_msg = f"配置文件不存在: {self.config_path}"
+            logger.error(error_msg)
+            raise FileNotFoundError(error_msg)
+        
+        try:
+            with open(self.config_path, 'r', encoding='utf-8') as file:
+                config = yaml.safe_load(file)
+                if config is None:
+                    error_msg = f"配置文件为空或格式错误: {self.config_path}"
+                    logger.error(error_msg)
+                    raise ValueError(error_msg)
+                logger.info(f"成功加载配置文件: {self.config_path}")
+                return config
+        except yaml.YAMLError as e:
+            error_msg = f"YAML解析错误: {e}"
+            logger.error(error_msg)
+            raise
+        except Exception as e:
+            error_msg = f"加载配置文件失败: {e}"
+            logger.error(error_msg)
+            raise
+    
+    def _create_wled_state(self) -> WledState:
+        """
+        根据配置文件创建WledState对象
+        
+        Returns:
+            WledState: 配置好的WLED状态对象
+            
+        Raises:
+            KeyError: 分段配置缺少必需字段
+            ValueError: 分段配置值无效
+        """
+        # 创建基础状态
+        wled_state = WledState(
+            on=True,
+            brightness=255,
+            transition=10
+        )
+        
+        # 根据配置创建分段
+        for i, segment_config in enumerate(self.segments_config):
+            # 验证分段配置的必需字段
+            required_fields = ['id', 'start', 'stop', 'name']
+            for field in required_fields:
+                if field not in segment_config:
+                    error_msg = f"分段配置 {i} 缺少必需的字段 '{field}'"
+                    logger.error(error_msg)
+                    raise KeyError(error_msg)
+            
+            # 验证分段配置值
+            segment_id = segment_config['id']
+            if not isinstance(segment_id, int):
+                error_msg = f"分段 {i} 的 id 必须是整数,当前值: {segment_id}"
+                logger.error(error_msg)
+                raise ValueError(error_msg)
+            
+            start = segment_config['start']
+            if not isinstance(start, int) or start < 0:
+                error_msg = f"分段 {i} 的 start 必须是非负整数,当前值: {start}"
+                logger.error(error_msg)
+                raise ValueError(error_msg)
+            
+            stop = segment_config['stop']
+            if not isinstance(stop, int) or stop <= start:
+                error_msg = f"分段 {i} 的 stop 必须是大于 start 的整数,当前值: stop={stop}, start={start}"
+                logger.error(error_msg)
+                raise ValueError(error_msg)
+            
+            name = segment_config['name']
+            if not isinstance(name, str) or not name.strip():
+                error_msg = f"分段 {i} 的 name 必须是非空字符串,当前值: {name}"
+                logger.error(error_msg)
+                raise ValueError(error_msg)
+            
+            segment = WledSegment(
+                start=start,
+                stop=stop,
+                segment_id=segment_id,
+                group=self.led_group_count,
+                on=True,
+                colors=[[0, 0, 255], [0, 0, 0], [0, 0, 0]],  # 默认蓝色
+                effect=6,  # 默认彩虹特效
+                speed=230,
+                intensity=255,
+                palette=0,
+                reverse=False,
+                selected=True
+            )
+            wled_state.add_segment(segment)
+            logger.info(f"创建分段: {name} (ID: {segment.id}, LED: {segment.start}-{segment.stop-1})")
+        
+        logger.info(f"WledState创建完成,共{len(self.segments_config)}个分段")
+        return wled_state
+    
+    def get_wled_state(self) -> WledState:
+        """
+        获取当前WLED状态对象
+        
+        Returns:
+            WledState: 当前状态对象
+        """
+        return self.wled_state
+    
+    def get_segment_by_id(self, id: int) -> WledSegment:
+        """
+        根据ID获取分段
+        
+        Args:
+            id: 分段ID
+            
+        Returns:
+            WledSegment: 分段对象
+            
+        Raises:
+            ValueError: 分段ID无效或分段不存在
+        """
+        if not isinstance(id, int) or id < 0:
+            error_msg = f"分段ID必须是非负整数,当前值: {id}"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        return self.wled_state.get_segment(id)
+    
+    def set_segment_effect(self, segment_id: int, effect_id: int, 
+                          speed: int = None, intensity: int = None):
+        """
+        设置指定分段的特效
+        
+        Args:
+            segment_id: 分段ID
+            effect_id: 特效ID
+            speed: 特效速度 (0-255)
+            intensity: 特效强度 (0-255)
+            
+        Raises:
+            ValueError: 分段ID无效或分段不存在
+        """
+        if not isinstance(segment_id, int) or segment_id < 0:
+            error_msg = f"分段ID必须是非负整数,当前值: {segment_id}"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        segment = self.wled_state.get_segment(segment_id)
+        if not segment:
+            error_msg = f"未找到分段ID: {segment_id}"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        segment.set_effect(effect_id, speed, intensity)
+        logger.info(f"分段{segment_id}特效已设置为: {effect_id}")
+    
+    def set_global_brightness(self, brightness: int):
+        """
+        设置全局亮度
+        
+        Args:
+            brightness: 亮度值 0-255
+            
+        Raises:
+            ValueError: 亮度值无效
+        """
+        if not isinstance(brightness, int) or brightness < 0 or brightness > 255:
+            error_msg = f"亮度值必须是0-255之间的整数,当前值: {brightness}"
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+        
+        self.wled_state.set_global_brightness(brightness)
+        logger.info(f"全局亮度已设置为: {brightness}")
+    
+    def toggle_power(self):
+        """切换设备总开关状态"""
+        self.wled_state.toggle_power()
+        status = "开启" if self.wled_state.on else "关闭"
+        logger.info(f"设备状态已切换为: {status}")
+    
+    def send_state(self, state: WledState = None, timeout: float = 5.0, 
+                   retry_count: int = 3) -> bool:
+        """
+        发送状态到WLED设备
+        
+        Args:
+            state: 要发送的状态对象,默认为当前状态
+            timeout: 请求超时时间(秒)
+            retry_count: 重试次数
+            
+        Returns:
+            bool: 发送是否成功
+        """
+        if state is None:
+            state = self.wled_state
+        
+        payload = state.to_dict()
+        url = f"http://{self.wled_ip}/json/state"
+        headers = {"Content-Type": "application/json"}
+        
+        for attempt in range(retry_count + 1):
+            try:
+                logger.info(f"发送状态到设备 {self.wled_ip} (尝试 {attempt + 1}/{retry_count + 1})")
+                logger.info(f"发送状态: {payload}")
+                response = requests.post(
+                    url, 
+                    headers=headers, 
+                    json=payload, 
+                    timeout=timeout,
+                    verify=False
+                )
+                
+                if response.status_code == 200:
+                    logger.info(f"状态发送成功,状态码: {response.status_code}")
+                    return True
+                else:
+                    logger.warning(f"状态发送失败,状态码: {response.status_code}")
+                    
+            except requests.exceptions.RequestException as e:
+                logger.error(f"发送请求失败 (尝试 {attempt + 1}): {e}")
+                if attempt < retry_count:
+                    wait_time = 2 ** attempt  # 指数退避
+                    logger.info(f"等待 {wait_time} 秒后重试...")
+                    time.sleep(wait_time)
+        
+        logger.error(f"所有重试均失败,无法发送状态到设备 {self.wled_ip}")
+        return False
+    
+    def __str__(self) -> str:
+        """返回控制器的字符串表示"""
+        return f"WledController(IP: {self.wled_ip}, 分段数: {len(self.segments_config)})"

+ 161 - 0
hardware/wled_enum.py

@@ -0,0 +1,161 @@
+from enum import Enum
+from typing import Tuple
+from utils.logger_config import logger
+
+
+class RGBColor(Enum):
+    """RGB颜色枚举类,包含常用颜色的RGB值"""
+    
+    # 基础颜色
+    RED = (255, 0, 0)
+    GREEN = (0, 255, 0)
+    BLUE = (0, 0, 255)
+    
+    # 混合色
+    YELLOW = (255, 255, 0)
+    CYAN = (0, 255, 255)
+    MAGENTA = (255, 0, 255)
+    
+    # 白色和黑色
+    WHITE = (255, 255, 255)
+    BLACK = (0, 0, 0)
+    
+    # 灰色系
+    GRAY = (128, 128, 128)
+    LIGHT_GRAY = (192, 192, 192)
+    DARK_GRAY = (64, 64, 64)
+    
+    # 暖色调
+    ORANGE = (255, 165, 0)
+    PINK = (255, 192, 203)
+    ROSE = (255, 0, 128)
+    CORAL = (255, 127, 80)
+    
+    # 冷色调
+    LIME = (0, 255, 0)
+    AQUA = (0, 255, 255)
+    NAVY = (0, 0, 128)
+    TEAL = (0, 128, 128)
+    
+    # 紫色系
+    PURPLE = (128, 0, 128)
+    VIOLET = (238, 130, 238)
+    INDIGO = (75, 0, 130)
+    
+    # 棕色系
+    BROWN = (165, 42, 42)
+    MAROON = (128, 0, 0)
+    OLIVE = (128, 128, 0)
+    
+    # 特殊颜色
+    GOLD = (255, 215, 0)
+    SILVER = (192, 192, 192)
+    COPPER = (184, 115, 51)
+    
+    def __init__(self, r: int, g: int, b: int):
+        """初始化RGB颜色值"""
+        self.r = r
+        self.g = g
+        self.b = b
+        self._value_ = (r, g, b)
+    
+    @property
+    def rgb_tuple(self) -> Tuple[int, int, int]:
+        """返回RGB元组"""
+        return (self.r, self.g, self.b)
+    
+    @property
+    def hex_value(self) -> str:
+        """返回十六进制颜色值"""
+        return f"#{self.r:02x}{self.g:02x}{self.b:02x}".upper()
+    
+    def __str__(self) -> str:
+        """字符串表示"""
+        return f"{self.name}: RGB{self.rgb_tuple} ({self.hex_value})"
+    
+    @classmethod
+    def from_hex(cls, hex_color: str) -> 'RGBColor':
+        """从十六进制颜色值创建RGBColor实例"""
+        hex_color = hex_color.lstrip('#')
+        if len(hex_color) != 6:
+            raise ValueError("十六进制颜色值必须是6位字符")
+        
+        r = int(hex_color[0:2], 16)
+        g = int(hex_color[2:4], 16)
+        b = int(hex_color[4:6], 16)
+        
+        # 查找最接近的预定义颜色
+        closest_color = None
+        min_distance = float('inf')
+        
+        for color in cls:
+            distance = ((r - color.r) ** 2 + (g - color.g) ** 2 + (b - color.b) ** 2) ** 0.5
+            if distance < min_distance:
+                min_distance = distance
+                closest_color = color
+        
+        return closest_color
+    
+    @classmethod
+    def from_rgb(cls, r: int, g: int, b: int) -> 'RGBColor':
+        """从RGB值创建RGBColor实例"""
+        # 确保RGB值在有效范围内
+        r = max(0, min(255, r))
+        g = max(0, min(255, g))
+        b = max(0, min(255, b))
+        
+        # 查找最接近的预定义颜色
+        closest_color = None
+        min_distance = float('inf')
+        
+        for color in cls:
+            distance = ((r - color.r) ** 2 + (g - color.g) ** 2 + (b - color.b) ** 2) ** 0.5
+            if distance < min_distance:
+                min_distance = distance
+                closest_color = color
+        
+        return closest_color
+
+
+class LEDEffectType(Enum):
+    """LED效果类型枚举"""
+    SOLID = 0
+    BLINK = 1
+    BREATH = 2
+    COLOR_RANDOM_WAVE = 5
+    COLOR_RANDOM_WINK = 7
+    COLOR_RANDOM_BREATH = 8
+    WINK = 12
+
+class LEDBrightness(Enum):
+    """LED亮度等级枚举"""
+    OFF = 0
+    LOW = 25
+    MEDIUM = 50
+    HIGH = 75
+    MAXIMUM = 255
+
+
+# 使用示例
+if __name__ == "__main__":
+    # 基本使用
+    logger.info("=== RGB颜色枚举使用示例 ===")
+    
+    # 获取颜色
+    red = RGBColor.RED
+    logger.info(f"红色: {red}")
+    logger.info(f"RGB值: {red.rgb_tuple}")
+    logger.info(f"十六进制: {red.hex_value}")
+    
+    # 从十六进制创建
+    custom_color = RGBColor.from_hex("#FF5733")
+    logger.info(f"从十六进制创建: {custom_color}")
+    
+    # 从RGB值创建
+    custom_rgb = RGBColor.from_rgb(100, 200, 50)
+    logger.info(f"从RGB创建: {custom_rgb}")
+    
+    # 遍历所有颜色
+    logger.info("\n=== 所有预定义颜色 ===")
+    for color in RGBColor:
+        logger.info(f"{color.name:12} - {color}")

+ 352 - 0
hardware/wled_module.py

@@ -0,0 +1,352 @@
+from typing import List, Optional, Dict, Any
+
+class WledSegment:
+    """
+    WLED分段控制类
+    用于控制LED灯条的分段效果,包括位置、颜色、特效等参数
+    """
+    
+    def __init__(self, start: int = 0, stop: int = 150, segment_id: int = 0, 
+                 group: int = 5, on: bool = True, colors: list = None,
+                 effect: int = 6, speed: int = 230, intensity: int = 255,
+                 palette: int = 0, reverse: bool = False, selected: bool = True):
+        """
+        初始化WLED分段
+        
+        Args:
+            start: 分段起始灯珠编号
+            stop: 分段结束灯珠编号(不包含此编号)
+            segment_id: 分段ID,用于唯一标识
+            group: 分组数量,每N个灯珠为1组运行特效
+            on: 分段开关状态
+            colors: 颜色配置,3个颜色通道[主色, 辅助色, 第三色]
+            effect: 特效ID
+            speed: 特效速度 (0-255)
+            intensity: 特效强度 (0-255)
+            palette: 调色板ID
+            reverse: 方向反转
+            selected: 分段选中状态
+        """
+        self.start = start
+        self.stop = stop
+        self.id = segment_id
+        self.grp = group
+        self.on = on
+        self.col = colors or [[0, 0, 255], [0, 0, 0], [0, 0, 0]]  # 默认蓝色主色
+        self.fx = effect
+        self.sx = speed
+        self.ix = intensity
+        self.pal = palette
+        self.rev = reverse
+        self.sel = selected
+    
+    def to_dict(self) -> dict:
+        """
+        将分段配置转换为字典格式,用于API调用
+        
+        Returns:
+            dict: 分段配置字典
+        """
+        return {
+            "start": self.start,
+            "stop": self.stop,
+            "id": self.id,
+            "grp": self.grp,
+            "on": self.on,
+            "col": self.col,
+            "fx": self.fx,
+            "sx": self.sx,
+            "ix": self.ix,
+            "pal": self.pal,
+            "rev": self.rev,
+            "sel": self.sel
+        }
+    
+    def set_color(self, primary_color: tuple, secondary_color: tuple = (0, 0, 0), 
+                  tertiary_color: tuple = (0, 0, 0)):
+        """
+        设置分段颜色
+        
+        Args:
+            primary_color: 主色 RGB元组
+            secondary_color: 辅助色 RGB元组
+            tertiary_color: 第三色 RGB元组
+        """
+        self.col = [list(primary_color), list(secondary_color), list(tertiary_color)]
+    
+    def set_effect(self, effect_id: int, speed: int = None, intensity: int = None):
+        """
+        设置分段特效
+        
+        Args:
+            effect_id: 特效ID
+            speed: 特效速度 (0-255)
+            intensity: 特效强度 (0-255)
+        """
+        self.fx = effect_id
+        if speed is not None:
+            self.sx = max(0, min(255, speed))
+        if intensity is not None:
+            self.ix = max(0, min(255, intensity))
+    
+    def set_position(self, start: int, stop: int):
+        """
+        设置分段位置
+        
+        Args:
+            start: 起始灯珠编号
+            stop: 结束灯珠编号(不包含)
+        """
+        self.start = start
+        self.stop = stop
+    
+    def set_group_size(self, group_size: int):
+        """
+        设置分组大小
+        
+        Args:
+            group_size: 每组的灯珠数量
+        """
+        self.grp = group_size
+    
+    def toggle(self):
+        """切换分段开关状态"""
+        self.on = not self.on
+    
+    def select(self):
+        """选中此分段"""
+        self.sel = True
+    
+    def deselect(self):
+        """取消选中此分段"""
+        self.sel = False
+    
+    def reverse(self):
+        """切换方向反转状态"""
+        self.rev = not self.rev
+    
+    def __str__(self) -> str:
+        """返回分段的字符串表示"""
+        return f"WLED Segment {self.id}: LEDs {self.start}-{self.stop-1}, " \
+               f"Effect {self.fx}, Color {self.col[0]}, {'ON' if self.on else 'OFF'}"
+
+class WledState:
+    """
+    WLED设备状态控制类
+    用于管理WLED设备的全局状态,包括开关、亮度、过渡时间等参数
+    """
+    
+    def __init__(self, on: bool = True, brightness: int = 255, transition: int = 10, 
+                 segments: list = None):
+        """
+        初始化WLED设备状态
+        
+        Args:
+            on: 设备总开关,True=开启,False=关闭(全局生效,分段开关需配合此值)
+            brightness: 全局亮度,取值 0-255,255=最大亮度(所有分段的亮度均基于此值)
+            transition: 过渡时间,单位 100ms,10=1秒(颜色/特效切换时的平滑过渡时长)
+            segments: 分段数组,存放多个独立控制的灯珠段
+        """
+        self.on = on
+        self.bri = max(0, min(255, brightness))  # 限制亮度范围 0-255
+        self.transition = max(0, transition)  # 过渡时间不能为负数
+        self.seg = segments or []  # 分段数组,默认为空列表
+    
+    def add_segment(self, segment: WledSegment):
+        """
+        添加一个分段到设备状态中
+        
+        Args:
+            segment: wled_segment对象
+        """
+        self.seg.append(segment)
+    
+    def remove_segment(self, segment_id: int):
+        """
+        根据ID移除分段
+        
+        Args:
+            segment_id: 要移除的分段ID
+        """
+        self.seg = [seg for seg in self.seg if seg.id != segment_id]
+    
+    def get_segment(self, segment_id: int) -> Optional[WledSegment]:
+        """
+        根据ID获取分段
+        
+        Args:
+            segment_id: 分段ID
+            
+        Returns:
+            wled_segment对象,如果未找到返回None
+        """
+        for seg in self.seg:
+            if seg.id == segment_id:
+                return seg
+        return None
+    
+    def set_global_brightness(self, brightness: int):
+        """
+        设置全局亮度
+        
+        Args:
+            brightness: 亮度值 0-255
+        """
+        self.bri = max(0, min(255, brightness))
+    
+    def set_transition_time(self, transition: int):
+        """
+        设置过渡时间
+        
+        Args:
+            transition: 过渡时间,单位 100ms
+        """
+        self.transition = max(0, transition)
+    
+    def toggle_power(self):
+        """切换设备总开关状态"""
+        self.on = not self.on
+    
+    def turn_on(self):
+        """开启设备"""
+        self.on = True
+    
+    def turn_off(self):
+        """关闭设备"""
+        self.on = False
+    
+    def to_dict(self) -> dict:
+        """
+        将设备状态转换为字典格式,用于API调用
+        
+        Returns:
+            dict: 设备状态字典,符合WLED API格式
+        """
+        return {
+            "on": self.on,
+            "bri": self.bri,
+            "transition": self.transition,
+            "seg": [segment.to_dict() for segment in self.seg]
+        }
+    
+    def from_dict(self, state_dict: dict):
+        """
+        从字典格式加载设备状态
+        
+        Args:
+            state_dict: 包含设备状态的字典
+        """
+        self.on = state_dict.get("on", True)
+        self.bri = state_dict.get("bri", 255)
+        self.transition = state_dict.get("transition", 10)
+        
+        # 清空现有分段
+        self.seg = []
+        
+        # 加载分段数据
+        segments_data = state_dict.get("seg", [])
+        for seg_data in segments_data:
+            segment = WledSegment(
+                start=seg_data.get("start", 0),
+                stop=seg_data.get("stop", 150),
+                segment_id=seg_data.get("id", 0),
+                group=seg_data.get("grp", 5),
+                on=seg_data.get("on", True),
+                colors=seg_data.get("col", [[0, 0, 255], [0, 0, 0], [0, 0, 0]]),
+                effect=seg_data.get("fx", 6),
+                speed=seg_data.get("sx", 230),
+                intensity=seg_data.get("ix", 255),
+                palette=seg_data.get("pal", 0),
+                reverse=seg_data.get("rev", False),
+                selected=seg_data.get("sel", True)
+            )
+            self.add_segment(segment)
+    
+    def get_segments_count(self) -> int:
+        """获取分段数量"""
+        return len(self.seg)
+    
+    def get_total_leds(self) -> int:
+        """获取总LED数量(基于最后一个分段的结束位置)"""
+        if not self.seg:
+            return 0
+        return max(seg.stop for seg in self.seg)
+    
+    def clone(self) -> 'WledState':
+        """
+        深度克隆WledState对象
+        
+        Returns:
+            WledState: 克隆的新对象
+        """
+        # 创建新的WledState对象
+        cloned_state = WledState(
+            on=self.on,
+            brightness=self.bri,
+            transition=self.transition
+        )
+        
+        # 深度克隆所有分段
+        for segment in self.seg:
+            cloned_segment = WledSegment(
+                start=segment.start,
+                stop=segment.stop,
+                segment_id=segment.id,
+                group=segment.grp,
+                on=segment.on,
+                colors=[color[:] for color in segment.col],  # 深拷贝颜色数组
+                effect=segment.fx,
+                speed=segment.sx,
+                intensity=segment.ix,
+                palette=segment.pal,
+                reverse=segment.rev,
+                selected=segment.sel
+            )
+            cloned_state.add_segment(cloned_segment)
+        
+        return cloned_state
+    
+    def merge_segments(self) -> 'WledState':
+        """
+        合并所有分段为一个完整的分段,用于控制整条灯带
+        
+        Returns:
+            WledState: 合并后的状态对象
+        """
+        if not self.seg:
+            return self.clone()
+        
+        # 计算总LED数量
+        total_leds = self.get_total_leds()
+        
+        # 创建合并后的状态
+        merged_state = WledState(
+            on=self.on,
+            brightness=self.bri,
+            transition=self.transition
+        )
+        
+        # 创建单个分段覆盖整个灯带
+        merged_segment = WledSegment(
+            start=0,
+            stop=total_leds,
+            segment_id=0,
+            group=self.seg[0].grp if self.seg else 5,
+            on=True,
+            colors=[[0, 0, 255], [0, 0, 0], [0, 0, 0]],  # 默认蓝色
+            effect=6,  # 默认彩虹特效
+            speed=230,
+            intensity=255,
+            palette=0,
+            reverse=False,
+            selected=True
+        )
+        
+        merged_state.add_segment(merged_segment)
+        return merged_state
+    
+    def __str__(self) -> str:
+        """返回设备状态的字符串表示"""
+        status = "ON" if self.on else "OFF"
+        return f"WLED State: {status}, Brightness: {self.bri}, " \
+               f"Transition: {self.transition}ms, Segments: {len(self.seg)}"

+ 31 - 0
kodi_config_prod.yaml

@@ -0,0 +1,31 @@
+kodi_servers:
+  - ip: 192.168.189.181
+    port: 8080
+    username: kodi
+    password: 123
+    id: 0
+  - ip: 192.168.189.182
+    port: 8080
+    username: kodi
+    password: 123
+    id: 1
+  - ip: 192.168.189.183
+    port: 8080
+    username: kodi
+    password: 123
+    id: 2
+  - ip: 192.168.189.184
+    port: 8080
+    username: kodi
+    password: 123
+    id: 3
+  - ip: 192.168.189.185
+    port: 8080
+    username: kodi
+    password: 123
+    id: 4
+  - ip: 192.168.189.187
+    port: 8080
+    username: kodi
+    password: 123
+    id: 5

+ 11 - 0
kodi_config_test.yaml

@@ -0,0 +1,11 @@
+kodi_servers:
+  - ip: 192.168.189.220
+    port: 8080
+    username: kodi
+    password: 123
+    id: 0
+  - ip: 192.168.189.221
+    port: 8080
+    username: kodi
+    password: 123
+    id: 1

+ 50 - 0
led_config.yaml

@@ -0,0 +1,50 @@
+# 设备信息
+wled_ip: 192.168.2.34
+# 每个灯座有多少组灯珠
+led_group_count: 7
+segments:
+  - 
+    # 灯组ID
+    id: 0
+    # 灯组起始灯珠编号
+    start: 0
+    # 灯组结束灯珠编号
+    stop: 7
+    # 灯组名称
+    name: 氯化铯
+  - 
+    # 灯组ID
+    id: 1
+    # 灯组起始灯珠编号
+    start: 7
+    # 灯组结束灯珠编号
+    stop: 14
+    # 灯组名称
+    name: 氯化铷
+  - 
+    # 灯组ID
+    id: 2
+    # 灯组起始灯珠编号
+    start: 14
+    # 灯组结束灯珠编号
+    stop: 21
+    # 灯组名称
+    name: 硫酸钙
+  - 
+    # 灯组ID
+    id: 3
+    # 灯组起始灯珠编号
+    start: 21
+    # 灯组结束灯珠编号
+    stop: 28
+    # 灯组名称
+    name: 硫酸钡
+  - 
+    # 灯组ID
+    id: 4
+    # 灯组起始灯珠编号
+    start: 28
+    # 灯组结束灯珠编号
+    stop: 35
+    # 灯组名称
+    name: 硫酸钡

+ 44 - 0
requirements-lock.txt

@@ -0,0 +1,44 @@
+# This file was autogenerated by uv via the following command:
+#    uv pip compile requirements.txt -o requirements-lock.txt
+blinker==1.9.0
+    # via flask
+certifi==2025.10.5
+    # via requests
+charset-normalizer==3.4.4
+    # via requests
+click==8.3.0
+    # via flask
+colorama==0.4.6
+    # via
+    #   click
+    #   loguru
+flask==2.3.3
+    # via
+    #   -r requirements.txt
+    #   flask-cors
+flask-cors==4.0.0
+    # via -r requirements.txt
+idna==3.11
+    # via requests
+itsdangerous==2.2.0
+    # via flask
+jinja2==3.1.6
+    # via flask
+loguru==0.7.3
+    # via -r requirements.txt
+markupsafe==3.0.3
+    # via
+    #   jinja2
+    #   werkzeug
+pyyaml==6.0.3
+    # via -r requirements.txt
+requests==2.32.5
+    # via -r requirements.txt
+urllib3==2.5.0
+    # via requests
+werkzeug==2.3.7
+    # via
+    #   -r requirements.txt
+    #   flask
+win32-setctime==1.2.0
+    # via loguru

+ 6 - 0
requirements.txt

@@ -0,0 +1,6 @@
+Flask==2.3.3
+Flask-CORS==4.0.0
+Werkzeug==2.3.7
+requests==2.32.5
+PyYAML==6.0.3
+loguru==0.7.3

+ 97 - 0
start_flask_api.ps1

@@ -0,0 +1,97 @@
+# PowerShell Script: Start Flask API Server
+# Use virtual environment to run flask_api.py
+
+# Set console encoding to UTF-8
+chcp 65001 | Out-Null
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$OutputEncoding = [System.Text.Encoding]::UTF8
+
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "Starting Flask API Server" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+
+# Change to script directory
+$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
+Set-Location $scriptPath
+Write-Host "Working directory: $scriptPath" -ForegroundColor Gray
+Write-Host ""
+
+# Check if virtual environment exists
+$venvPath = Join-Path $scriptPath ".venv"
+if (-not (Test-Path $venvPath)) {
+    Write-Host "[ERROR] Virtual environment not found (.venv directory missing)" -ForegroundColor Red
+    Write-Host "Please run setup_venv.bat first to create virtual environment" -ForegroundColor Yellow
+    Write-Host ""
+    Read-Host "Press Enter to exit"
+    exit 1
+}
+
+# Check if activation script exists
+$activateScript = Join-Path $venvPath "Scripts\Activate.ps1"
+if (-not (Test-Path $activateScript)) {
+    Write-Host "[ERROR] Virtual environment activation script not found" -ForegroundColor Red
+    Write-Host "Path: $activateScript" -ForegroundColor Yellow
+    Write-Host "Please recreate the virtual environment" -ForegroundColor Yellow
+    Write-Host ""
+    Read-Host "Press Enter to exit"
+    exit 1
+}
+
+# Check if flask_api.py exists
+$flaskApiFile = Join-Path $scriptPath "flask_api.py"
+if (-not (Test-Path $flaskApiFile)) {
+    Write-Host "[ERROR] flask_api.py file not found" -ForegroundColor Red
+    Write-Host "Path: $flaskApiFile" -ForegroundColor Yellow
+    Write-Host ""
+    Read-Host "Press Enter to exit"
+    exit 1
+}
+
+Write-Host "Activating virtual environment..." -ForegroundColor Green
+try {
+    # Activate virtual environment
+    & $activateScript
+    Write-Host "Virtual environment activated" -ForegroundColor Green
+    Write-Host ""
+} catch {
+    Write-Host "[ERROR] Error activating virtual environment: $_" -ForegroundColor Red
+    Read-Host "Press Enter to exit"
+    exit 1
+}
+
+# Check if Python is available
+Write-Host "Checking Python environment..." -ForegroundColor Green
+$pythonPath = Join-Path $venvPath "Scripts\python.exe"
+if (-not (Test-Path $pythonPath)) {
+    Write-Host "[ERROR] Python interpreter not found: $pythonPath" -ForegroundColor Red
+    Read-Host "Press Enter to exit"
+    exit 1
+}
+
+# Display Python version
+$pythonVersion = & $pythonPath --version 2>&1
+Write-Host "Python version: $pythonVersion" -ForegroundColor Gray
+Write-Host ""
+
+# Start Flask API server
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "Starting Flask API Server..." -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+Write-Host "Server will start at http://localhost:5050" -ForegroundColor Yellow
+Write-Host "Press Ctrl+C to stop the server" -ForegroundColor Yellow
+Write-Host ""
+
+try {
+    # Run flask_api.py using Python from virtual environment
+    & $pythonPath flask_api.py
+} catch {
+    Write-Host ""
+    Write-Host "[ERROR] Error running Flask API: $_" -ForegroundColor Red
+    Read-Host "Press Enter to exit"
+    exit 1
+} finally {
+    Write-Host ""
+    Write-Host "Server stopped" -ForegroundColor Gray
+}

+ 651 - 0
templates/index.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>

+ 38 - 0
utils/logger_config.py

@@ -0,0 +1,38 @@
+# logger_config.py
+from loguru import logger
+import sys
+import os
+
+# 移除默认配置(可选)
+logger.remove()
+
+# 配置控制台输出
+logger.add(
+    sys.stdout,
+    colorize=True,
+    format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
+    level="DEBUG"
+)
+
+# 配置文件输出(带轮换)
+log_path = os.path.join(os.getcwd(), "logs", "app_{time}.log")
+logger.add(
+    log_path,
+    rotation="100 MB",  # 按大小轮换
+    retention="30 days",  # 保留30天
+    compression="zip",  # 压缩旧日志
+    enqueue=True,  # 线程安全
+    backtrace=True,  # 记录异常堆栈
+    diagnose=True,  # 显示变量值(生产环境建议关闭)
+    level="INFO",
+    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
+)
+
+# 可选:添加错误日志单独文件
+logger.add(
+    os.path.join(os.getcwd(), "logs", "error.log"),
+    rotation="00:00",  # 每天轮换
+    retention="90 days",
+    level="ERROR",
+    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
+)

+ 43 - 0
video_config_prod.yaml

@@ -0,0 +1,43 @@
+video_infos:
+  - id: 0
+    name: 公司宣传片
+    formula: 公司宣传片
+    description: 公司宣传片
+    video_duration: 48
+    video_path: /sdcard/Movies/0.mp4
+  - id: 1
+    name: 氯化铷
+    formula: 氯化铷
+    description: 氯化铷是一种铷的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/氯化铷.mp4
+  - id: 2
+    name: 硫酸钙新材料
+    formula: 硫酸钙新材料
+    description: 硫酸钙新材料是一种硫酸钙的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/硫酸钙新材料.mp4
+  - id: 3
+    name: 白炭黑
+    formula: 白炭黑
+    description: 白炭黑是一种白炭黑的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/白炭黑.mp4
+  - id: 4
+    name: 碳酸锂
+    formula: 碳酸锂
+    description: 碳酸锂是一种碳酸锂的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/碳酸锂.mp4
+  - id: 5
+    name: 硫酸钾
+    formula: 硫酸钾
+    description: 硫酸钾是一种硫酸钾的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/硫酸钾.mp4
+  - id: 6
+    name: 氯化铯
+    formula: 氯化铯
+    description: 氯化铯是一种氯化铯的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/氯化铯.mp4

+ 13 - 0
video_config_test.yaml

@@ -0,0 +1,13 @@
+video_infos:
+  - id: 0
+    name: 氯化铷
+    formula: RbCl
+    description: 氯化铷是一种铷的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 48
+    video_path: /storage/emulated/0/Movies/氯化铷.mp4
+  - id: 1
+    name: 氯化铯
+    formula: CsCl
+    description: 氯化铯是一种铯的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /storage/emulated/0/Movies/氯化铯.mp4