跳到主内容

自定义插件开发

教程

自定义插件开发

本教程演示如何为 EnerOS 开发一个自定义插件,使其支持私有协议解析或第三方算法接入。EnerOS 插件基于动态库(cdylib)与 trait 接口实现热加载,无需重启主进程即可扩展系统能力。本教程将开发一个完整的 IEC 60870-5-103(继电保护设备协议)插件,从零开始直至发布到插件市场。

插件架构概览

EnerOS 插件系统由以下组件构成:

┌─────────────────────────────────────────────────────────────┐
│                     EnerOS 主进程                            │
│                                                              │
│  ┌────────────────┐   ┌────────────────┐   ┌─────────────┐ │
│  │ PluginRegistry │──→│ PluginLoader   │──→│  Sandbox    │ │
│  │  (注册表)      │   │ (动态库加载)   │   │ (seccomp)   │ │
│  └────────────────┘   └────────────────┘   └─────────────┘ │
│         │                      │                  │         │
│         │                      │                  │         │
│  ┌──────▼───────┐    ┌────────▼─────────┐  ┌──────▼──────┐ │
│  │ Protocol     │    │ Agent Strategy   │  │ Analysis    │ │
│  │ Plugin Slot  │    │ Plugin Slot      │  │ Plugin Slot │ │
│  └──────┬───────┘    └────────┬─────────┘  └──────┬──────┘ │
└─────────┼──────────────────────┼───────────────────┼────────┘
          │                      │                   │
          ▼                      ▼                   ▼
   ┌──────────────┐      ┌──────────────┐    ┌──────────────┐
   │ libiec103.so │      │ libmystrat.so│    │ liboptflow.so│
   │ (本教程产物) │      │              │    │              │
   └──────────────┘      └──────────────┘    └──────────────┘

EnerOS 支持四类插件:

插件类型实现接口用途
ProtocolProtocolPlugin trait协议适配器(IEC 103、Modbus、私有协议)
AgentAgentPlugin traitAgent 决策策略(电压控制、故障恢复)
AnalysisAnalysisPlugin trait分析模块(状态估计、谐波分析)
StrategyStrategyPackRef策略包(可热替换的 Agent 决策包)

准备工作

  • 安装 Rust 1.78+ 与 cargo
  • 阅读 项目结构,了解 crate 划分
  • 熟悉动态库(cdylib)的基本概念

本教程涉及以下 crate:

Crate作用
eneros-plugin插件框架核心(trait、loader、registry、sandbox)
eneros-plugin-macros过程宏(简化插件声明)
eneros-plugin-api稳定的插件 API(ABI 兼容层)

步骤 1:创建插件 crate

使用 cargo new 创建一个动态库 crate:

cargo new --lib iec103-driver
cd iec103-driver

Cargo.toml 中声明插件接口依赖:

[package]
name = "iec103-driver"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
description = "IEC 60870-5-103 protocol driver for EnerOS"
license = "Apache-2.0"

[lib]
name = "iec103"
crate-type = ["cdylib", "rlib"]   # 同时生成动态库与静态库

[dependencies]
eneros-plugin = { path = "../eneros/crates/eneros-plugin", version = "0.47" }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "net", "io-util"] }
tracing = "0.1"

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }

:::tip crate-type 选择 cdylib 生成 .so(Linux)/ .dll(Windows)/ .dylib(macOS),供主进程动态加载。rlib 用于单元测试时静态链接。 :::

步骤 2:编写插件清单

每个插件必须提供一个 TOML 格式的清单(manifest),描述元数据、依赖与安全信息:

# iec103-driver/plugin.toml

[plugin]
name = "iec103-driver"
version = "0.1.0"
api_version = "0.47.0"           # 必须与主程序 API 版本兼容
plugin_type = "Protocol"          # Protocol / Agent / Analysis / Strategy
description = "IEC 60870-5-103 继电保护设备通信协议驱动"
author = "EnerOS Community"

[dependencies]
plugins = []                      # 不依赖其他插件

[security]
signer = "eneros-trusted"         # 签名者标识
public_key = "ed25519:Base64..."  # 公钥(用于验证签名)

清单字段说明:

字段类型必填说明
plugin.nameString插件唯一标识(与 crate 名一致)
plugin.versionString语义化版本
plugin.api_versionString编译时使用的 EnerOS API 版本
plugin.plugin_typeEnum插件类型(Protocol/Agent/Analysis/Strategy)
plugin.descriptionString人类可读描述
plugin.authorString作者信息
dependencies.pluginsVec依赖的其他插件名
security.signerString签名者标识
security.public_keyStringEd25519 公钥

步骤 3:实现协议插件 trait

实现 ProtocolPlugin trait,处理上行报文并产出时序点位。下面是完整的 IEC 103 驱动实现:

// src/lib.rs

use eneros_plugin::protocol::{ProtocolPlugin, ProtocolAdapterInstance, ProtocolPluginConfig};
use eneros_plugin::error::{PluginError, PluginResult};
use eneros_plugin::manifest::{PluginManifest, PluginMetadata, PluginType};
use async_trait::async_trait;
use std::sync::Arc;
use parking_lot::Mutex;
use tracing::{info, debug, warn};

/// IEC 103 协议插件
#[derive(Debug)]
pub struct Iec103Plugin {
    metadata: PluginMetadata,
    /// 已创建的适配器实例数(用于诊断)
    instance_count: Arc<Mutex<u32>>,
}

impl Iec103Plugin {
    pub fn new() -> Self {
        Self {
            metadata: PluginMetadata {
                name: "iec103-driver".into(),
                version: "0.1.0".into(),
                api_version: "0.47.0".into(),
                plugin_type: PluginType::Protocol,
                description: "IEC 60870-5-103 继电保护设备通信协议驱动".into(),
                author: "EnerOS Community".into(),
            },
            instance_count: Arc::new(Mutex::new(0)),
        }
    }

    pub fn instance_count(&self) -> u32 {
        *self.instance_count.lock()
    }
}

#[async_trait]
impl ProtocolPlugin for Iec103Plugin {
    fn protocol_name(&self) -> &str {
        "iec103"
    }

    fn protocol_type_str(&self) -> String {
        "custom:iec103".into()
    }

    fn description(&self) -> &str {
        "IEC 60870-5-103 继电保护设备通信协议,用于接入保护测控装置"
    }

    /// 创建协议适配器实例(每次设备连接调用一次)
    async fn create_adapter(
        &self,
        config: &ProtocolPluginConfig,
    ) -> Result<Box<dyn ProtocolAdapterInstance>, PluginError> {
        info!(
            addr = %config.address,
            timeout_ms = config.timeout_ms,
            "创建 IEC 103 适配器实例"
        );

        let mut count = self.instance_count.lock();
        *count += 1;

        let adapter = Iec103Adapter::new(
            config.address.clone(),
            config.timeout_ms,
            config.protocol_config.clone(),
        )?;

        Ok(Box::new(adapter))
    }
}

/// IEC 103 协议适配器实例(对应一次设备连接)
pub struct Iec103Adapter {
    address: String,
    timeout_ms: u64,
    /// 设备地址(IEC 103 中的 ASDU 地址)
    device_address: u8,
    /// TCP 连接(连接后才有值)
    stream: Option<tokio::net::TcpStream>,
    /// 接收回调
    point_callback: Option<Arc<dyn Fn(&str, f64) + Send + Sync>>,
}

impl Iec103Adapter {
    fn new(
        address: String,
        timeout_ms: u64,
        protocol_config: serde_json::Value,
    ) -> Result<Self, PluginError> {
        let device_address = protocol_config
            .get("device_address")
            .and_then(|v| v.as_u64())
            .unwrap_or(1) as u8;

        Ok(Self {
            address,
            timeout_ms,
            device_address,
            stream: None,
            point_callback: None,
        })
    }
}

#[async_trait]
impl ProtocolAdapterInstance for Iec103Adapter {
    /// 连接设备
    async fn connect(&mut self) -> Result<(), PluginError> {
        debug!(addr = %self.address, "连接 IEC 103 设备");
        let stream = tokio::time::timeout(
            std::time::Duration::from_millis(self.timeout_ms),
            tokio::net::TcpStream::connect(&self.address),
        )
        .await
        .map_err(|_| PluginError::Timeout(format!("连接超时: {}", self.address)))?
        .map_err(|e| PluginError::Connection(format!("连接失败: {}", e)))?;

        // 设置读写超时
        let _ = stream.set_nodelay(true);
        self.stream = Some(stream);

        info!(addr = %self.address, device = self.device_address, "IEC 103 设备已连接");
        Ok(())
    }

    /// 断开连接
    async fn disconnect(&mut self) -> Result<(), PluginError> {
        if let Some(stream) = self.stream.take() {
            debug!(addr = %self.address, "断开 IEC 103 设备");
            // 显式 drop 关闭连接
            drop(stream);
        }
        Ok(())
    }

    /// 发送报文(原始字节)
    async fn send(&mut self, data: &[u8]) -> Result<(), PluginError> {
        use tokio::io::AsyncWriteExt;
        let stream = self.stream.as_mut()
            .ok_or_else(|| PluginError::NotConnected("IEC 103 未连接".into()))?;
        stream.write_all(data).await
            .map_err(|e| PluginError::Io(format!("发送失败: {}", e)))?;
        debug!(len = data.len(), "已发送报文");
        Ok(())
    }

    /// 接收报文(返回原始字节与解析后的点位)
    async fn receive(&mut self) -> Result<Vec<(String, f64)>, PluginError> {
        use tokio::io::AsyncReadExt;
        let stream = self.stream.as_mut()
            .ok_or_else(|| PluginError::NotConnected("IEC 103 未连接".into()))?;

        // 读取 IEC 103 帧头:固定 4 字节(10H + LEN + LEN + 68H)
        let mut header = [0u8; 4];
        stream.read_exact(&mut header).await
            .map_err(|e| PluginError::Io(format!("读取帧头失败: {}", e)))?;

        if header[0] != 0x10 || header[3] != 0x68 {
            return Err(PluginError::Protocol(format!(
                "无效帧头: {:02X?} (期望 10 .. 68)", header
            )));
        }

        let data_len = header[1] as usize;
        if data_len == 0 || data_len > 253 {
            return Err(PluginError::Protocol(format!(
                "数据长度异常: {}", data_len
            )));
        }

        // 读取数据段 + 校验和 + 结束符
        let mut payload = vec![0u8; data_len + 2];
        stream.read_exact(&mut payload).await
            .map_err(|e| PluginError::Io(format!("读取数据段失败: {}", e)))?;

        // 验证结束符
        if payload[data_len + 1] != 0x16 {
            return Err(PluginError::Protocol(
                format!("结束符错误: {:02X}", payload[data_len + 1])
            ));
        }

        // 验证校验和
        let checksum: u8 = payload[..data_len].iter().fold(0u8, |a, &b| a.wrapping_add(b));
        if checksum != payload[data_len] {
            warn!(expected = checksum, got = payload[data_len], "校验和错误");
            return Err(PluginError::Protocol("校验和不匹配".into()));
        }

        // 解析 ASDU
        let points = self.parse_asdu(&payload[..data_len])?;
        debug!(points = points.len(), "解析得到测点");
        Ok(points)
    }

    /// 查询设备状态(同步扫描)
    async fn scan(&mut self) -> Result<Vec<(String, f64)>, PluginError> {
        // IEC 103 的扫描通过发送 C_RD 8-0 命令实现
        let cmd = self.build_read_command(self.device_address, 0)?;
        self.send(&cmd).await?;

        // 等待响应(可能分多帧返回)
        let mut all_points = Vec::new();
        for _ in 0..10 {
            match tokio::time::timeout(
                std::time::Duration::from_millis(self.timeout_ms),
                self.receive(),
            ).await {
                Ok(Ok(points)) => {
                    if points.is_empty() { break; }
                    all_points.extend(points);
                }
                Ok(Err(PluginError::Timeout(_))) => break,
                Ok(Err(e)) => return Err(e),
                Err(_) => break,  // 超时,结束扫描
            }
        }
        Ok(all_points)
    }
}

impl Iec103Adapter {
    /// 解析 ASDU(应用服务数据单元)
    fn parse_asdu(&self, data: &[u8]) -> Result<Vec<(String, f64)>, PluginError> {
        if data.len() < 4 {
            return Err(PluginError::Protocol("ASDU 过短".into()));
        }

        let type_id = data[0];
        let asdu_addr = u16::from_le_bytes([data[2], data[3]]);

        let mut points = Vec::new();

        match type_id {
            // 类型 1:时间标记的单点信息(断路器状态)
            1 => {
                let point_name = format!("iec103.dev{}.breaker_state", asdu_addr);
                let value = if data.len() > 4 { data[4] as f64 } else { 0.0 };
                points.push((point_name, value));
            }
            // 类型 4:时间标记的测量值
            4 => {
                if data.len() >= 9 {
                    let value = i16::from_le_bytes([data[4], data[5]]) as f64;
                    let point_name = format!("iec103.dev{}.meas_{}", asdu_addr, data[6]);
                    points.push((point_name, value));
                }
            }
            // 类型 7:故障记录
            7 => {
                warn!(dev = asdu_addr, "收到故障记录 ASDU");
                let point_name = format!("iec103.dev{}.fault_flag", asdu_addr);
                points.push((point_name, 1.0));
            }
            // 类型 23:接地故障
            23 => {
                warn!(dev = asdu_addr, "接地故障告警");
                let point_name = format!("iec103.dev{}.ground_fault", asdu_addr);
                points.push((point_name, 1.0));
            }
            _ => {
                debug!(type_id, "未知 ASDU 类型,跳过");
            }
        }

        Ok(points)
    }

    /// 构造读取命令(C_RD 8-0)
    fn build_read_command(&self, asdu_addr: u8, record: u8) -> Result<Vec<u8>, PluginError> {
        // 固定长度帧格式:10H + LEN + LEN + 68H + C_RD + 0 + ASDU_ADDR + 16H
        let cmd = vec![
            0x10, 0x03, 0x03, 0x68,
            0x08,                      // C_RD: 读命令
            record,
            asdu_addr,
            0x08 ^ record ^ asdu_addr, // 校验和
            0x16,                      // 结束符
        ];
        Ok(cmd)
    }
}

// ============================================================================
// C ABI 入口函数 — 主进程通过 dlopen 加载此函数
// ============================================================================

/// 插件创建入口(必须为 extern "C")
/// 返回 Box<dyn ProtocolPlugin> 的裸指针
#[no_mangle]
pub extern "C" fn eneros_plugin_create() -> *mut dyn ProtocolPlugin {
    let plugin = Iec103Plugin::new();
    Box::into_raw(Box::new(plugin))
}

/// 插件销毁入口(用于安全释放)
#[no_mangle]
pub extern "C" fn eneros_plugin_destroy(ptr: *mut dyn ProtocolPlugin) {
    if !ptr.is_null() {
        unsafe { drop(Box::from_raw(ptr)) };
    }
}

/// 插件元数据入口(供加载器在不创建实例时查询信息)
#[no_mangle]
pub extern "C" fn eneros_plugin_metadata() -> PluginMetadata {
    PluginMetadata {
        name: "iec103-driver".into(),
        version: "0.1.0".into(),
        api_version: "0.47.0".into(),
        plugin_type: PluginType::Protocol,
        description: "IEC 60870-5-103 继电保护设备通信协议驱动".into(),
        author: "EnerOS Community".into(),
    }
}

// ============================================================================
// 单元测试
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_plugin_metadata() {
        let plugin = Iec103Plugin::new();
        assert_eq!(plugin.protocol_name(), "iec103");
        assert_eq!(plugin.protocol_type_str(), "custom:iec103");
        assert!(!plugin.description().is_empty());
    }

    #[test]
    fn test_create_adapter() {
        let plugin = Iec103Plugin::new();
        let config = ProtocolPluginConfig {
            address: "127.0.0.1:2405".into(),
            protocol_config: serde_json::json!({"device_address": 1}),
            timeout_ms: 1000,
        };
        // 同步运行异步函数
        let rt = tokio::runtime::Runtime::new().unwrap();
        let adapter = rt.block_on(plugin.create_adapter(&config)).unwrap();
        assert_eq!(plugin.instance_count(), 1);
        drop(adapter);
    }

    #[test]
    fn test_parse_breaker_state() {
        let adapter = Iec103Adapter::new(
            "127.0.0.1:0".into(), 100, serde_json::json!({}),
        ).unwrap();
        // 模拟 ASDU: type=1, asdu_addr=1, value=1
        let data = [1, 0, 1, 0, 1];
        let points = adapter.parse_asdu(&data).unwrap();
        assert_eq!(points.len(), 1);
        assert_eq!(points[0].0, "iec103.dev1.breaker_state");
        assert_eq!(points[0].1, 1.0);
    }

    #[test]
    fn test_parse_measurement() {
        let adapter = Iec103Adapter::new(
            "127.0.0.1:0".into(), 100, serde_json::json!({}),
        ).unwrap();
        // 模拟 ASDU: type=4, asdu_addr=2, value=12345 (i16 LE), meas_id=5
        let value: i16 = 12345;
        let bytes = value.to_le_bytes();
        let data = [4, 0, 2, 0, bytes[0], bytes[1], 5];
        let points = adapter.parse_asdu(&data).unwrap();
        assert_eq!(points.len(), 1);
        assert_eq!(points[0].0, "iec103.dev2.meas_5");
        assert!((points[0].1 - 12345.0).abs() < 0.1);
    }

    #[test]
    fn test_build_read_command() {
        let adapter = Iec103Adapter::new(
            "127.0.0.1:0".into(), 100, serde_json::json!({}),
        ).unwrap();
        let cmd = adapter.build_read_command(1, 0).unwrap();
        assert_eq!(cmd[0], 0x10);  // 帧头
        assert_eq!(cmd[3], 0x68);  // 帧头
        assert_eq!(cmd[4], 0x08);  // C_RD
        assert_eq!(cmd[8], 0x16);  // 结束符
    }

    #[test]
    fn test_checksum_calculation() {
        let adapter = Iec103Adapter::new(
            "127.0.0.1:0".into(), 100, serde_json::json!({}),
        ).unwrap();
        let cmd = adapter.build_read_command(1, 0).unwrap();
        // 校验和应为 cmd[4] ^ cmd[5] ^ cmd[6]
        let expected = cmd[4] ^ cmd[5] ^ cmd[6];
        assert_eq!(cmd[7], expected);
    }

    #[test]
    fn test_unknown_asdu_type() {
        let adapter = Iec103Adapter::new(
            "127.0.0.1:0".into(), 100, serde_json::json!({}),
        ).unwrap();
        let data = [99, 0, 1, 0];  // 未知 type_id
        let points = adapter.parse_asdu(&data).unwrap();
        assert!(points.is_empty());  // 未知类型返回空
    }
}

步骤 4:注册插件

将编译产物放入插件目录并配置 eneros.toml

# /etc/eneros/plugin.toml

[[plugins]]
name = "iec103-driver"
path = "/opt/eneros/plugins/libiec103.so"
type = "Protocol"
enabled = true
auto_load = true                 # 主进程启动时自动加载

[plugins.config]
device_address = 1
timeout_ms = 3000
reconnect_interval_ms = 5000

插件配置字段:

字段类型默认值说明
nameString插件唯一标识
pathString动态库绝对路径
typeEnum插件类型
enabledbooltrue是否启用
auto_loadboolfalse启动时自动加载
configObject{}传递给插件的配置(JSON)

步骤 5:构建与签名

# 1. 编译插件(生成 libiec103.so)
cargo build --release

# 2. 验证 ABI 入口函数
nm -D target/release/libiec103.so | grep eneros_plugin

# 3. 签名插件(生产环境强制要求)
eneros-cli plugin sign \
    --plugin libiec103.so \
    --key ~/.eneros/ed25519.key \
    --output libiec103.so.sig

# 4. 验证签名
eneros-cli plugin verify \
    --plugin libiec103.so \
    --sig libiec103.so.sig \
    --pubkey ~/.eneros/ed25519.pub

输出示例:

$ nm -D target/release/libiec103.so | grep eneros_plugin
0000000000012340 T eneros_plugin_create
0000000000012350 T eneros_plugin_destroy
0000000000012360 T eneros_plugin_metadata

$ eneros-cli plugin verify --plugin libiec103.so --sig libiec103.so.sig --pubkey ~/.eneros/ed25519.pub
✓ 签名验证通过
  插件: libiec103.so (124KB)
  签名者: eneros-trusted
  算法: Ed25519

步骤 6:热加载与调试

加载插件无需重启主进程:

use eneros_plugin::{PluginRegistry, loader::PluginLoader};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. 创建插件注册表
    let registry = PluginRegistry::new();
    let loader = PluginLoader::new(&registry);

    // 2. 加载插件(带签名验证)
    loader.load("/opt/eneros/plugins/libiec103.so").await?;
    println!("✓ 插件已加载");

    // 3. 查询已注册插件
    let plugins = registry.list();
    for p in &plugins {
        println!("  - {} v{} ({:?})",
            p.metadata.name, p.metadata.version, p.metadata.plugin_type);
    }

    // 4. 创建协议适配器实例
    let config = ProtocolPluginConfig {
        address: "10.0.0.30:2405".into(),
        protocol_config: serde_json::json!({"device_address": 1}),
        timeout_ms: 3000,
    };
    let adapter = registry
        .create_protocol_adapter("iec103-driver", &config)
        .await?;

    // 5. 连接设备并扫描
    adapter.connect().await?;
    let points = adapter.scan().await?;
    println!("扫描到 {} 个测点:", points.len());
    for (name, value) in &points {
        println!("  {} = {:.4}", name, value);
    }

    // 6. 卸载插件(热卸载)
    loader.unload("iec103-driver").await?;
    println!("✓ 插件已卸载");

    Ok(())
}

开发期使用 RUST_LOG=eneros_plugin=debug 查看插件日志,无需重启主进程:

# 实时查看插件日志
RUST_LOG=eneros_plugin=debug,iec103=trace eneros-os --config /etc/eneros/eneros.toml

# 重新加载插件(不重启主进程)
eneros-cli plugin reload iec103-driver

# 查看插件状态
eneros-cli plugin status iec103-driver

输出示例:

$ eneros-cli plugin status iec103-driver
插件: iec103-driver
版本: 0.1.0
类型: Protocol
状态: Running
加载时间: 2026-07-06 14:23:01
实例数: 2
连接数: 2
最后心跳: 2026-07-06 14:25:30

步骤 7:插件沙箱

生产环境强制启用沙箱,限制插件系统调用与资源使用:

# /etc/eneros/plugin.toml —— 沙箱配置
[[plugins]]
name = "iec103-driver"
path = "/opt/eneros/plugins/libiec103.so"
type = "Protocol"

[plugins.sandbox]
enabled = true
# seccomp 过滤器:仅允许必要的系统调用
seccomp_profile = "protocol-default"
# 文件系统隔离
fs_isolation = "/var/lib/eneros/plugins/iec103-driver"
allowed_paths = [
    "/var/lib/eneros/plugins/iec103-driver/",
    "/etc/eneros/certs/iec103/",
]
# 资源限制
max_memory_mb = 64
max_cpu_percent = 10
max_fds = 32
max_threads = 4
# 崩溃隔离(catch_unwind)
crash_isolation = true
max_crashes_per_hour = 5

沙箱提供的隔离保障:

维度隔离机制说明
系统调用seccomp BPF仅允许 read/write/accept 等必要调用
文件系统chroot + 白名单限制可访问路径
内存cgroups v2限制最大内存,超限 OOM kill
CPUcgroups cpu quota限制 CPU 占用百分比
网络网络命名空间(可选)限制可访问的端口
崩溃catch_unwind插件 panic 不影响主进程

步骤 8:发布到插件市场

EnerOS 提供插件市场(Plugin Market)用于插件分发与版本管理:

# 1. 打包插件(含动态库、清单、签名)
eneros-cli plugin pack \
    --lib target/release/libiec103.so \
    --manifest plugin.toml \
    --sig libiec103.so.sig \
    --output iec103-driver-0.1.0.tar.gz

# 2. 上传到插件市场
eneros-cli plugin publish \
    --file iec103-driver-0.1.0.tar.gz \
    --repo https://market.openeneros.com

# 3. 其他用户安装
eneros-cli plugin install iec103-driver --version 0.1.0

插件市场配置:

# ~/.eneros/market.toml
[[repos]]
name = "official"
url = "https://market.openeneros.com"
trusted_signers = ["eneros-trusted"]

[[repos]]
name = "internal"
url = "https://internal.eneros.local/market"
trusted_signers = ["corp-ops"]

步骤 9:集成测试

端到端测试

#[cfg(test)]
mod integration_tests {
    use super::*;
    use std::io::Write;

    /// 启动一个模拟 IEC 103 设备的 TCP 服务器
    async fn start_mock_device() -> (String, tokio::task::JoinHandle<()>) {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();

        let handle = tokio::spawn(async move {
            if let Ok((mut stream, _)) = listener.accept().await {
                // 等待读取命令
                let mut buf = [0u8; 9];
                use tokio::io::AsyncReadExt;
                stream.read_exact(&mut buf).await.unwrap();

                // 回复一帧断路器状态(type=1, asdu=1, value=1)
                let response = vec![
                    0x10, 0x05, 0x05, 0x68,  // 帧头
                    1, 0, 1, 0, 1,           // ASDU
                    1,                        // 校验和
                    0x16,                     // 结束符
                ];
                use tokio::io::AsyncWriteExt;
                stream.write_all(&response).await.unwrap();
            }
        });

        (addr.to_string(), handle)
    }

    #[tokio::test]
    async fn test_end_to_end_scan() {
        let (addr, _handle) = start_mock_device().await;

        let plugin = Iec103Plugin::new();
        let config = ProtocolPluginConfig {
            address: addr,
            protocol_config: serde_json::json!({"device_address": 1}),
            timeout_ms: 1000,
        };

        let mut adapter = plugin.create_adapter(&config).await.unwrap();
        adapter.connect().await.unwrap();

        let points = adapter.scan().await.unwrap();
        assert!(!points.is_empty());
        assert!(points.iter().any(|(n, _)| n.contains("breaker_state")));
    }
}

沙箱测试

# 在沙箱中运行插件,检查资源使用
eneros-cli plugin test iec103-driver \
    --sandbox \
    --device 127.0.0.1:2405 \
    --duration 60s

# 输出示例:
# ✓ 测试通过
#   内存峰值: 8.2 MB / 64 MB
#   CPU 占用: 2.3% / 10%
#   崩溃次数: 0 / 5
#   系统调用违规: 0

验证

按以下指标验证插件的正确性:

指标期望值说明
编译产物libiec103.socdylib 格式
ABI 入口3 个导出符号eneros_plugin_create/destroy/metadata
加载时间< 100ms从 dlopen 到 init 完成
签名验证通过Ed25519 签名
单元测试100% 通过cargo test -p iec103-driver
沙箱隔离0 次违规seccomp/cgroups/fs 均正常
热加载< 1s不影响其他协议通道
崩溃隔离主进程存活插件 panic 不影响主进程

调试技巧

# 查看插件加载详情
RUST_LOG=eneros_plugin=trace eneros-os --config /etc/eneros/eneros.toml

# 检查动态库依赖
ldd /opt/eneros/plugins/libiec103.so

# 用 strace 观察插件系统调用
strace -f -e trace=network -p <eneros-os-pid>

# 用 valgrind 检查内存泄漏
valgrind --leak-check=full target/release/iec103-driver-test

# 强制触发插件崩溃(测试隔离)
eneros-cli plugin crash iec103-driver --type panic

常见问题排查

现象可能原因解决方案
加载失败api_version 不匹配检查清单中的 api_version 字段
符号未找到未导出 eneros_plugin_create确认 #[no_mangle]extern "C"
签名失败公钥不匹配检查 market.toml 的 trusted_signers
崩溃频繁空指针或越界cargo testvalgrind 排查
内存超限未释放资源检查 drop 实现,使用 Rc<RefCell<>> 替代裸指针
系统调用被拒seccomp 配置过严调整 seccomp_profile 或联系安全团队

下一步