Skip to content

Latest commit

 

History

History
687 lines (555 loc) · 25.4 KB

File metadata and controls

687 lines (555 loc) · 25.4 KB

SerialDebugger 插件开发专业指南 (修订版)

1. 架构概述

SerialDebugger 采用模块化插件架构,旨在提供一个可扩展的核心平台,允许开发者通过创建和集成独立的功能模块(面板插件)来增强应用程序的能力。本指南详细阐述了插件开发的标准规范、完整的生命周期管理机制以及与主程序高效交互的最佳实践,助力开发者构建高质量、易于维护的插件。

2. 开发规范

2.1 目录结构

所有外部插件都应作为独立的Python包(包含init.py的目录)存放于主应用程序根目录下的 panel_plugins/ 文件夹内。推荐的结构如下:

project_root/
├── main.py                  # 主程序入口
├── panel_interface.py       # 面板接口定义 (所有插件面板必须继承)
├── plugin_manager.py        # 插件管理器实现
└── panel_plugins/           # 外部插件标准存放目录
    └── my_data_monitor/     # 示例插件包名 (遵循命名约定)
        ├── __init__.py      # 插件注册入口,必须存在
        ├── monitor_panel.py # 插件面板核心实现 (文件名可自定义)
        ├── resources/       # (可选) 存放图标、qss等静态资源
        │   └── icons/
        │       └── monitor_icon.png
        └── README.md        # (推荐) 插件说明文档

2.2 命名约定

  • 插件包名 (目录名): 全小写,单词间使用下划线分隔 (例如:data_monitor, advanced_plotter)
  • 插件模块名 (导入时使用): 例如 panel_plugins.data_monitor
  • 面板类名: 遵循Python的PascalCase命名规范 (例如 DataMonitorPanel)
  • PANEL_TYPE_NAME (静态类属性):
    • 必须在插件面板类中定义
    • 全局唯一的字符串标识符,用于系统内部识别面板类型
    • 推荐使用小写字母和下划线 (例如:"data_monitor_panel")
  • PANEL_DISPLAY_NAME (静态类属性):
    • 必须在插件面板类中定义
    • 用户友好的名称,将显示在菜单、插件管理器等UI界面中 (例如:"数据监视器")

3. 核心接口实现 (PanelInterface)

所有插件面板必须继承自项目根目录 panel_interface.py 中定义的 PanelInterface 类。

3.1 基础模板

# panel_plugins/example_custom_panel/__init__.py
"""
自定义面板插件包

这个模块提供了一个示例自定义面板的完整实现,包括:
- 面板组件的定义和实现
- 插件注册逻辑
- 元数据管理
"""

from typing import Dict, Any, List, Optional, Union
import logging

# 导入面板组件
try:
    from .custom_panel_widget import MyCustomPanelWidget
except ImportError as e:
    logging.error(f"无法导入 MyCustomPanelWidget: {e}")
    MyCustomPanelWidget = None

# 插件版本和基本信息
PLUGIN_VERSION = "1.0.0"
PLUGIN_NAME = "example_custom_panel"
PLUGIN_DISPLAY_NAME = "我的自定义插件包示例"
PLUGIN_AUTHOR = "您的名字"

# 设置日志记录器
logger = logging.getLogger(__name__)

# 调试模式开关 - 设置为 False 可关闭所有调试输出
DEBUG_MODE = False


def _debug_print(message: str) -> None:
    """
    条件调试打印函数

    Args:
        message: 要打印的调试信息
    """
    if DEBUG_MODE:
        print(f"DEBUG: {message}")


def register_plugin_panels(plugin_manager) -> bool:
    """
    注册插件提供的所有面板类型

    Args:
        plugin_manager: 插件管理器实例

    Returns:
        bool: 注册是否成功
    """
    try:
        _debug_print(f"==> REGISTER_PLUGIN_PANELS CALLED for module: {__name__} <==")
        logger.info(f"开始注册插件面板: {__name__}")

        # 检查组件是否成功导入
        if MyCustomPanelWidget is None:
            error_msg = f"面板组件 MyCustomPanelWidget 导入失败,跳过注册"
            logger.error(error_msg)
            return False

        # 验证面板组件的必要属性
        if not _validate_panel_widget(MyCustomPanelWidget):
            return False

        module_full_name = __name__

        # 注册面板类型
        try:
            _debug_print(f"==> Attempting to register panel type: {MyCustomPanelWidget.PANEL_TYPE_NAME} <==")
            _debug_print(f"==> Panel display name: {MyCustomPanelWidget.PANEL_DISPLAY_NAME} <==")
            _debug_print(f"==> Module name: {module_full_name} <==")

            result = plugin_manager.register_panel_type(
                MyCustomPanelWidget,
                module_name=module_full_name
            )

            _debug_print(f"==> register_panel_type returned: {result} (type: {type(result)}) <==")

            # 处理不同的返回值类型
            if result is None:
                # 如果返回 None,假设注册成功(某些实现可能不返回值)
                _debug_print(f"==> register_panel_type returned None, assuming success <==")
                success = True
            elif isinstance(result, bool):
                success = result
            else:
                # 如果返回其他类型,转换为布尔值
                success = bool(result)
                _debug_print(f"==> register_panel_type returned non-boolean: {result}, converted to: {success} <==")

            if success:
                _debug_print(
                    f"==> Successfully registered {MyCustomPanelWidget.PANEL_DISPLAY_NAME} from {module_full_name} <==")
                logger.info(f"面板类型注册成功: {MyCustomPanelWidget.PANEL_DISPLAY_NAME}")
                return True
            else:
                logger.error(f"面板类型注册失败: {MyCustomPanelWidget.PANEL_DISPLAY_NAME}")
                return False

        except Exception as reg_error:
            logger.exception(f"面板注册过程中发生异常: {str(reg_error)}")
            return False

    except Exception as e:
        error_msg = f"注册插件面板时发生异常: {str(e)}"
        logger.exception(error_msg)
        return False


def _validate_panel_widget(panel_class) -> bool:
    """
    验证面板组件类是否符合接口要求

    Args:
        panel_class: 要验证的面板类

    Returns:
        bool: 验证是否通过
    """
    try:
        _debug_print(f"==> Validating panel class: {panel_class.__name__} <==")

        # 检查必要的类属性
        required_attrs = ['PANEL_TYPE_NAME', 'PANEL_DISPLAY_NAME']
        for attr in required_attrs:
            if not hasattr(panel_class, attr):
                logger.error(f"面板类验证失败: 缺少属性 {attr}")
                return False

            # 检查属性值是否为空
            value = getattr(panel_class, attr)
            _debug_print(f"==> {attr} = '{value}' (type: {type(value)}) <==")
            if not value or not isinstance(value, str):
                logger.error(f"面板类验证失败: 属性 {attr} 值无效")
                return False

        # 检查必要的方法
        required_methods = ['get_config', 'apply_config', 'get_initial_dock_title']
        for method in required_methods:
            if not hasattr(panel_class, method):
                logger.error(f"面板类验证失败: 缺少方法 {method}")
                return False
            elif not callable(getattr(panel_class, method)):
                logger.error(f"面板类验证失败: {method} 不可调用")
                return False
            else:
                _debug_print(f"==> Method {method} found and callable <==")

        # 检查是否继承自正确的基类
        try:
            # 尝试检查是否有 PanelInterface 基类
            mro = panel_class.__mro__
            base_class_names = [cls.__name__ for cls in mro]
            _debug_print(f"==> Panel class MRO: {base_class_names} <==")

            if 'PanelInterface' not in base_class_names:
                logger.warning(f"面板类可能没有继承正确的基类: {panel_class.__name__}")
        except Exception as e:
            _debug_print(f"==> Could not check base class inheritance: {e} <==")

        _debug_print(f"==> Panel class {panel_class.__name__} validation passed <==")
        logger.debug(f"面板类 {panel_class.__name__} 验证通过")
        return True

    except Exception as e:
        logger.exception("面板类验证异常")
        return False


def get_plugin_metadata() -> Dict[str, Any]:
    """
    获取插件元数据信息

    Returns:
        Dict[str, Any]: 包含插件详细信息的字典
    """
    # 定义面板类型信息的类型
    panel_types_info: List[Dict[str, Any]] = []

    metadata: Dict[str, Any] = {
        "module_name": __name__,
        "plugin_name": PLUGIN_NAME,
        "display_name": PLUGIN_DISPLAY_NAME,
        "version": PLUGIN_VERSION,
        "description": "这是一个自定义面板的完整示例插件,演示了如何创建、注册和管理自定义面板组件。",
        "author": PLUGIN_AUTHOR,
        "panel_types_info": panel_types_info,
        "dependencies": [],
        "min_app_version": "1.0.0",
        "tags": ["示例", "自定义面板", "教程"]
    }

    # 动态添加面板类型信息
    if MyCustomPanelWidget is not None:
        try:
            panel_info: Dict[str, Any] = {
                "type_name": MyCustomPanelWidget.PANEL_TYPE_NAME,
                "display_name": MyCustomPanelWidget.PANEL_DISPLAY_NAME,
                "description": "一个功能完整的自定义面板示例,包含配置管理、主题支持和与主窗口的交互功能。",
                "features": [
                    "配置保存和加载",
                    "主题适配",
                    "串口状态监控",
                    "动态标题更新",
                    "资源自动清理"
                ],
                "version": "1.0.0"
            }
            panel_types_info.append(panel_info)
        except AttributeError as e:
            logger.warning(f"获取面板类型信息时出错: {e}")
            # 如果面板类属性不完整,提供基本信息
            error_panel_info: Dict[str, Any] = {
                "type_name": "example_custom_panel",
                "display_name": "自定义面板示例",
                "description": "面板类型信息不完整",
                "error": str(e)
            }
            panel_types_info.append(error_panel_info)

    return metadata


def get_supported_panel_types() -> List[str]:
    """
    获取此插件支持的面板类型列表

    Returns:
        List[str]: 面板类型名称列表
    """
    if MyCustomPanelWidget is not None:
        try:
            return [MyCustomPanelWidget.PANEL_TYPE_NAME]
        except AttributeError:
            logger.warning("无法获取面板类型名称")
            return []
    return []


def check_plugin_compatibility(app_version: str) -> Dict[str, Any]:
    """
    检查插件与应用程序的兼容性

    Args:
        app_version: 应用程序版本

    Returns:
        Dict[str, Any]: 兼容性检查结果
    """
    try:
        # 简单的版本比较(实际项目中可能需要更复杂的版本解析)
        metadata = get_plugin_metadata()
        min_version = metadata.get("min_app_version", "1.0.0")

        # 这里简化处理,实际应用中应该有更严格的版本比较逻辑
        compatible = True  # 假设兼容

        result = {
            "compatible": compatible,
            "plugin_version": PLUGIN_VERSION,
            "min_app_version": min_version,
            "current_app_version": app_version,
            "warnings": [],
            "errors": []
        }

        # 检查面板组件是否可用
        if MyCustomPanelWidget is None:
            result["compatible"] = False
            result["errors"].append("面板组件导入失败")

        return result

    except Exception as e:
        logger.exception("兼容性检查失败")
        return {
            "compatible": False,
            "error": str(e),
            "plugin_version": PLUGIN_VERSION
        }


# 插件卸载清理函数(可选)
def cleanup_plugin() -> None:
    """
    插件卸载时的清理工作
    """
    try:
        logger.info(f"正在清理插件: {PLUGIN_DISPLAY_NAME}")
        # 执行必要的清理工作
        # 例如:关闭文件、断开连接、清理缓存等
        logger.info(f"插件清理完成: {PLUGIN_DISPLAY_NAME}")
    except Exception as e:
        logger.exception(f"插件清理时发生错误: {e}")


# 模块级别的信息(用于调试和文档)
__version__ = PLUGIN_VERSION
__author__ = PLUGIN_AUTHOR
__description__ = "自定义面板插件示例"

# 导出的公共接口
__all__ = [
    'register_plugin_panels',
    'get_plugin_metadata',
    'get_supported_panel_types',
    'check_plugin_compatibility',
    'cleanup_plugin',
    'MyCustomPanelWidget'
]


# panel_plugins/example_custom_panel/custom_panel_widget.py
from PySide6.QtWidgets import QVBoxLayout, QLabel, QPushButton, QWidget
from PySide6.QtCore import Slot
from typing import Dict, Any, Optional

# 导入 PanelInterface,支持不同的导入路径
try:
    from core.panel_interface import PanelInterface
except ImportError:
    import sys
    from pathlib import Path

    project_root = Path(__file__).resolve().parent.parent.parent
    if str(project_root) not in sys.path:
        sys.path.insert(0, str(project_root))
    from core.panel_interface import PanelInterface




class MyCustomPanelWidget(PanelInterface):
    """
    自定义面板示例组件

    这个组件演示了如何实现一个完整的自定义面板,包括:
    - UI初始化
    - 配置管理
    - 与主窗口交互
    - 资源清理
    """

    # PanelInterface 必须定义的静态属性
    PANEL_TYPE_NAME: str = "example_custom_panel"
    PANEL_DISPLAY_NAME: str = "我的自定义面板"

    def __init__(self,
                 panel_id: int,
                 #  SerialDebugger的实际引用在PanelInterface中已经实现,该警告可以忽略
                 main_window_ref: 'SerialDebugger',
                 initial_config: Optional[Dict[str, Any]] = None,
                 parent: Optional[QWidget] = None):
        """
        初始化自定义面板

        Args:
            panel_id: 面板唯一标识符
            main_window_ref: 主窗口引用
            initial_config: 初始配置数据
            parent: 父级组件
        """
        super().__init__(panel_id, main_window_ref, initial_config, parent)

        # 记录初始化日志
        if self.error_logger:
            self.error_logger.log_debug(
                f"MyCustomPanelWidget (ID: {panel_id}) 开始初始化",
                self.PANEL_TYPE_NAME
            )

        # 初始化组件特定属性
        self.custom_setting: str = "初始默认值"
        self._click_count: int = 0

        # 初始化UI组件引用
        self.info_label: Optional[QLabel] = None
        self.my_button: Optional[QPushButton] = None
        self.config_display_label: Optional[QLabel] = None

        # 构建用户界面
        self._init_ui()

        # 应用初始配置或设置默认状态
        if initial_config:
            self.apply_config(initial_config)
        else:
            self._update_ui_state()

        # 更新标题
        self._update_title()

    def _init_ui(self) -> None:
        """构建和初始化面板的用户界面"""
        try:
            if self.error_logger:
                self.error_logger.log_debug(
                    f"MyCustomPanelWidget (ID: {self.panel_id}) 开始构建UI",
                    self.PANEL_TYPE_NAME
                )

            layout = QVBoxLayout(self)

            # 创建信息标签
            self.info_label = QLabel(f"这是 {self.PANEL_DISPLAY_NAME} (ID: {self.panel_id})")
            self.info_label.setWordWrap(True)
            layout.addWidget(self.info_label)

            # 创建操作按钮
            self.my_button = QPushButton("点击我!")
            self.my_button.clicked.connect(self._on_my_button_clicked)
            layout.addWidget(self.my_button)

            # 创建配置显示标签
            self.config_display_label = QLabel(f"当前设置: {self.custom_setting}")
            self.config_display_label.setWordWrap(True)
            layout.addWidget(self.config_display_label)

            self.setLayout(layout)

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"MyCustomPanelWidget UI初始化失败: {str(e)}",
                    self.PANEL_TYPE_NAME
                )
            raise

    def _update_ui_state(self) -> None:
        """更新UI状态以反映当前配置"""
        if self.config_display_label:
            self.config_display_label.setText(f"当前设置: {self.custom_setting}")

        if self.info_label:
            self.info_label.setText(
                f"这是 {self.PANEL_DISPLAY_NAME} (ID: {self.panel_id})\n"
                f"点击次数: {self._click_count}"
            )

    def _update_title(self) -> None:
        """根据内部状态更新停靠窗口标题"""
        try:
            # 构建新标题,限制长度避免UI过于拥挤
            setting_preview = self.custom_setting[:15]
            if len(self.custom_setting) > 15:
                setting_preview += "..."

            new_title = f"{self.PANEL_DISPLAY_NAME} [{self.panel_id}] - {setting_preview}"
            self.dock_title_changed.emit(new_title)

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"更新面板标题失败: {str(e)}",
                    self.PANEL_TYPE_NAME
                )

    @Slot()
    def _on_my_button_clicked(self) -> None:
        """处理按钮点击事件"""
        try:
            if self.error_logger:
                self.error_logger.log_info(
                    f"自定义面板 {self.panel_id} 的按钮被点击",
                    self.PANEL_TYPE_NAME
                )

            # 获取当前串口连接状态
            port_info = self._get_port_info()

            # 更新点击计数和设置
            self._click_count += 1
            self.custom_setting = f"点击次数: {self._click_count}"

            # 更新UI显示
            if self.info_label:
                self.info_label.setText(f"按钮已点击! 串口: {port_info}")

            self._update_ui_state()
            self._update_title()

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"按钮点击处理失败: {str(e)}",
                    self.PANEL_TYPE_NAME
                )

    def _get_port_info(self) -> str:
        """获取当前串口连接信息"""
        try:
            if (hasattr(self.main_window_ref, 'serial_manager') and
                    self.main_window_ref.serial_manager.is_connected and
                    hasattr(self.main_window_ref, 'current_serial_config') and
                    self.main_window_ref.current_serial_config):
                return self.main_window_ref.current_serial_config.port_name or "未知端口"
            return "未连接"
        except Exception:
            return "状态未知"

    # === PanelInterface 必须实现的方法 ===

    def get_config(self) -> Dict[str, Any]:
        """返回面板当前状态的配置数据"""
        return {
            "version": "1.0",
            "custom_setting": self.custom_setting,
            "click_count": self._click_count,
            "panel_type": self.PANEL_TYPE_NAME
        }

    def apply_config(self, config: Dict[str, Any]) -> None:
        """应用配置数据恢复面板状态"""
        try:
            # 验证配置版本(可选)
            config_version = config.get("version", "1.0")
            if config_version != "1.0":
                if self.error_logger:
                    self.error_logger.log_warning(
                        f"配置版本不匹配: {config_version}, 预期: 1.0",
                        self.PANEL_TYPE_NAME
                    )

            # 应用配置值
            self.custom_setting = config.get("custom_setting", "从配置加载的默认值")
            self._click_count = config.get("click_count", 0)

            # 更新UI状态
            self._update_ui_state()
            self._update_title()

            if self.error_logger:
                self.error_logger.log_debug(
                    f"面板 {self.panel_id} 配置应用成功",
                    self.PANEL_TYPE_NAME
                )

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"应用配置失败: {str(e)}",
                    self.PANEL_TYPE_NAME
                )

    def get_initial_dock_title(self) -> str:
        """返回面板停靠窗口的初始标题"""
        return f"{self.PANEL_DISPLAY_NAME} ({self.panel_id})"

    # === PanelInterface 可选实现的方法 ===

    def on_panel_added(self) -> None:
        """面板被添加到主窗口后的回调"""
        super().on_panel_added()

        if self.error_logger:
            self.error_logger.log_info(
                f"面板 '{self.PANEL_DISPLAY_NAME}' (ID: {self.panel_id}) 已成功添加",
                self.PANEL_TYPE_NAME
            )

        # 可以在这里执行需要主窗口完全初始化后才能进行的操作
        # 例如:连接主窗口的特定信号、获取初始数据等

    def on_panel_removed(self) -> None:
        """面板即将被移除前的清理回调"""
        super().on_panel_removed()

        if self.error_logger:
            self.error_logger.log_info(
                f"面板 '{self.PANEL_DISPLAY_NAME}' (ID: {self.panel_id}) 正在清理资源",
                self.PANEL_TYPE_NAME
            )

        try:
            # 断开信号连接(通常Qt会自动处理,但手动断开更安全)
            if self.my_button:
                self.my_button.clicked.disconnect()

            # 清理其他资源
            # 例如:停止定时器、关闭文件句柄、断开网络连接等

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"资源清理时发生错误: {str(e)}",
                    self.PANEL_TYPE_NAME
                )

    def update_theme(self) -> None:
        """主题变化时的更新回调"""
        super().update_theme()

        if self.error_logger:
            self.error_logger.log_debug(
                f"面板 '{self.PANEL_DISPLAY_NAME}' (ID: {self.panel_id}) 主题更新",
                self.PANEL_TYPE_NAME
            )

        # 根据主题更新样式
        try:
            if hasattr(self.main_window_ref, 'theme_manager'):
                theme_manager = self.main_window_ref.theme_manager

                # 示例:根据主题调整样式
                if hasattr(theme_manager, 'is_dark_theme') and theme_manager.is_dark_theme():
                    # 深色主题样式
                    if self.info_label:
                        self.info_label.setStyleSheet("QLabel { color: #ffffff; }")
                else:
                    # 浅色主题样式
                    if self.info_label:
                        self.info_label.setStyleSheet("QLabel { color: #000000; }")

        except Exception as e:
            if self.error_logger:
                self.error_logger.log_error(
                    f"主题更新失败: {str(e)}",
                    self.PANEL_TYPE_NAME
                )

3.2 生命周期与回调方法

方法/属性 类型/签名 必须实现 说明
PANEL_TYPE_NAME str (静态类属性) 全局唯一的内部类型名。
PANEL_DISPLAY_NAME str (静态类属性) 显示给用户的名称。
init init(self, panel_id, main_window_ref, initial_config=None, parent=None) 构造函数,必须调用 super().__init__(...)
_init_ui _init_ui(self) -> None 构建和初始化面板的用户界面。
get_config get_config(self) -> Dict[str, Any] 返回面板当前状态的 JSON 可序列化字典。
apply_config apply_config(self, config: Dict[str, Any]) -> None 从字典加载并应用配置,恢复面板状态。