Skip to main content

Custom Plugin Development

Tutorials

Custom Plugin Development

This tutorial demonstrates how to develop a custom plugin for EnerOS to support private protocol parsing or third-party algorithm integration. EnerOS plugins are based on dynamic libraries (cdylib) and trait interfaces for hot-loading, allowing system capabilities to be extended without restarting the main process. This tutorial will develop a complete IEC 60870-5-103 (relay protection device protocol) plugin, from scratch to publishing on the plugin market.

Plugin Architecture Overview

The EnerOS plugin system consists of the following components:

┌─────────────────────────────────────────────────────────────┐
│                     EnerOS main process                      │
│                                                              │
│  ┌────────────────┐   ┌────────────────┐   ┌─────────────┐ │
│  │ PluginRegistry │──→│ PluginLoader   │──→│  Sandbox    │ │
│  │  (registry)    │   │ (dynamic load) │   │ (seccomp)   │ │
│  └────────────────┘   └────────────────┘   └─────────────┘ │
│         │                      │                  │         │
│         │                      │                  │         │
│  ┌──────▼───────┐    ┌────────▼─────────┐  ┌──────▼──────┐ │
│  │ Protocol     │    │ Agent Strategy   │  │ Analysis    │ │
│  │ Plugin Slot  │    │ Plugin Slot      │  │ Plugin Slot │ │
│  └──────┬───────┘    └────────┬─────────┘  └──────┬──────┘ │
└─────────┼──────────────────────┼───────────────────┼────────┘
          │                      │                   │
          ▼                      ▼                   ▼
   ┌──────────────┐      ┌──────────────┐    ┌──────────────┐
   │ libiec103.so │      │ libmystrat.so│    │ liboptflow.so│
   │ (this        │      │              │    │              │
   │  tutorial)   │      │              │    │              │
   └──────────────┘      └──────────────┘    └──────────────┘

EnerOS supports four types of plugins:

Plugin TypeImplemented InterfacePurpose
ProtocolProtocolPlugin traitProtocol adapters (IEC 103, Modbus, private protocols)
AgentAgentPlugin traitAgent decision strategies (voltage control, fault recovery)
AnalysisAnalysisPlugin traitAnalysis modules (state estimation, harmonic analysis)
StrategyStrategyPackRefStrategy packs (hot-swappable Agent decision packs)

Prerequisites

  • Install Rust 1.78+ and cargo
  • Read the Project Structure to understand crate division
  • Be familiar with the basic concepts of dynamic libraries (cdylib)

This tutorial involves the following crates:

CratePurpose
eneros-pluginPlugin framework core (trait, loader, registry, sandbox)
eneros-plugin-macrosProcedural macros (simplify plugin declaration)
eneros-plugin-apiStable plugin API (ABI compatibility layer)

Step 1: Create the Plugin Crate

Use cargo new to create a dynamic library crate:

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

Declare the plugin interface dependencies in 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"]   # Generate both dynamic and static libraries

[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 selection cdylib generates .so (Linux) / .dll (Windows) / .dylib (macOS) for dynamic loading by the main process. rlib is used for static linking during unit testing. :::

Step 2: Write the Plugin Manifest

Each plugin must provide a TOML-format manifest describing metadata, dependencies, and security information:

# iec103-driver/plugin.toml

[plugin]
name = "iec103-driver"
version = "0.1.0"
api_version = "0.47.0"           # Must be compatible with the main program API version
plugin_type = "Protocol"          # Protocol / Agent / Analysis / Strategy
description = "IEC 60870-5-103 relay protection device communication protocol driver"
author = "EnerOS Community"

[dependencies]
plugins = []                      # Does not depend on other plugins

[security]
signer = "eneros-trusted"         # Signer identifier
public_key = "ed25519:Base64..."  # Public key (for signature verification)

Manifest field descriptions:

FieldTypeRequiredDescription
plugin.nameStringYesPlugin unique identifier (matches crate name)
plugin.versionStringYesSemantic version
plugin.api_versionStringYesEnerOS API version used at compile time
plugin.plugin_typeEnumYesPlugin type (Protocol/Agent/Analysis/Strategy)
plugin.descriptionStringNoHuman-readable description
plugin.authorStringNoAuthor information
dependencies.pluginsVecNoNames of other plugins it depends on
security.signerStringNoSigner identifier
security.public_keyStringNoEd25519 public key

Step 3: Implement the Protocol Plugin Trait

Implement the ProtocolPlugin trait to handle upstream messages and produce time-series points. Below is the complete IEC 103 driver implementation:

// 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 protocol plugin
#[derive(Debug)]
pub struct Iec103Plugin {
    metadata: PluginMetadata,
    /// Number of adapter instances created (for diagnostics)
    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 relay protection device communication protocol driver".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 relay protection device communication protocol, used to access protection and control devices"
    }

    /// Create a protocol adapter instance (called once per device connection)
    async fn create_adapter(
        &self,
        config: &ProtocolPluginConfig,
    ) -> Result<Box<dyn ProtocolAdapterInstance>, PluginError> {
        info!(
            addr = %config.address,
            timeout_ms = config.timeout_ms,
            "Creating IEC 103 adapter instance"
        );

        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 protocol adapter instance (corresponds to one device connection)
pub struct Iec103Adapter {
    address: String,
    timeout_ms: u64,
    /// Device address (ASDU address in IEC 103)
    device_address: u8,
    /// TCP connection (only set after connecting)
    stream: Option<tokio::net::TcpStream>,
    /// Receive callback
    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 {
    /// Connect to the device
    async fn connect(&mut self) -> Result<(), PluginError> {
        debug!(addr = %self.address, "Connecting to IEC 103 device");
        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!("Connection timeout: {}", self.address)))?
        .map_err(|e| PluginError::Connection(format!("Connection failed: {}", e)))?;

        // Set read/write timeout
        let _ = stream.set_nodelay(true);
        self.stream = Some(stream);

        info!(addr = %self.address, device = self.device_address, "IEC 103 device connected");
        Ok(())
    }

    /// Disconnect
    async fn disconnect(&mut self) -> Result<(), PluginError> {
        if let Some(stream) = self.stream.take() {
            debug!(addr = %self.address, "Disconnecting from IEC 103 device");
            // Explicitly drop to close the connection
            drop(stream);
        }
        Ok(())
    }

    /// Send a message (raw bytes)
    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 not connected".into()))?;
        stream.write_all(data).await
            .map_err(|e| PluginError::Io(format!("Send failed: {}", e)))?;
        debug!(len = data.len(), "Message sent");
        Ok(())
    }

    /// Receive a message (returns raw bytes and parsed points)
    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 not connected".into()))?;

        // Read IEC 103 frame header: fixed 4 bytes (10H + LEN + LEN + 68H)
        let mut header = [0u8; 4];
        stream.read_exact(&mut header).await
            .map_err(|e| PluginError::Io(format!("Failed to read frame header: {}", e)))?;

        if header[0] != 0x10 || header[3] != 0x68 {
            return Err(PluginError::Protocol(format!(
                "Invalid frame header: {:02X?} (expected 10 .. 68)", header
            )));
        }

        let data_len = header[1] as usize;
        if data_len == 0 || data_len > 253 {
            return Err(PluginError::Protocol(format!(
                "Abnormal data length: {}", data_len
            )));
        }

        // Read data segment + checksum + end marker
        let mut payload = vec![0u8; data_len + 2];
        stream.read_exact(&mut payload).await
            .map_err(|e| PluginError::Io(format!("Failed to read data segment: {}", e)))?;

        // Verify end marker
        if payload[data_len + 1] != 0x16 {
            return Err(PluginError::Protocol(
                format!("End marker error: {:02X}", payload[data_len + 1])
            ));
        }

        // Verify checksum
        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], "Checksum error");
            return Err(PluginError::Protocol("Checksum mismatch".into()));
        }

        // Parse ASDU
        let points = self.parse_asdu(&payload[..data_len])?;
        debug!(points = points.len(), "Parsed points");
        Ok(points)
    }

    /// Query device status (synchronous scan)
    async fn scan(&mut self) -> Result<Vec<(String, f64)>, PluginError> {
        // IEC 103 scanning is implemented by sending the C_RD 8-0 command
        let cmd = self.build_read_command(self.device_address, 0)?;
        self.send(&cmd).await?;

        // Wait for response (may be returned in multiple frames)
        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,  // Timeout, end scan
            }
        }
        Ok(all_points)
    }
}

impl Iec103Adapter {
    /// Parse ASDU (Application Service Data Unit)
    fn parse_asdu(&self, data: &[u8]) -> Result<Vec<(String, f64)>, PluginError> {
        if data.len() < 4 {
            return Err(PluginError::Protocol("ASDU too short".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 {
            // Type 1: Single-point information with time tag (breaker state)
            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));
            }
            // Type 4: Measured value with time tag
            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));
                }
            }
            // Type 7: Fault record
            7 => {
                warn!(dev = asdu_addr, "Received fault record ASDU");
                let point_name = format!("iec103.dev{}.fault_flag", asdu_addr);
                points.push((point_name, 1.0));
            }
            // Type 23: Ground fault
            23 => {
                warn!(dev = asdu_addr, "Ground fault alarm");
                let point_name = format!("iec103.dev{}.ground_fault", asdu_addr);
                points.push((point_name, 1.0));
            }
            _ => {
                debug!(type_id, "Unknown ASDU type, skipping");
            }
        }

        Ok(points)
    }

    /// Build a read command (C_RD 8-0)
    fn build_read_command(&self, asdu_addr: u8, record: u8) -> Result<Vec<u8>, PluginError> {
        // Fixed-length frame format: 10H + LEN + LEN + 68H + C_RD + 0 + ASDU_ADDR + 16H
        let cmd = vec![
            0x10, 0x03, 0x03, 0x68,
            0x08,                      // C_RD: read command
            record,
            asdu_addr,
            0x08 ^ record ^ asdu_addr, // Checksum
            0x16,                      // End marker
        ];
        Ok(cmd)
    }
}

// ============================================================================
// C ABI entry functions — the main process loads these via dlopen
// ============================================================================

/// Plugin creation entry (must be extern "C")
/// Returns a raw pointer to 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))
}

/// Plugin destruction entry (for safe release)
#[no_mangle]
pub extern "C" fn eneros_plugin_destroy(ptr: *mut dyn ProtocolPlugin) {
    if !ptr.is_null() {
        unsafe { drop(Box::from_raw(ptr)) };
    }
}

/// Plugin metadata entry (for the loader to query info without creating an instance)
#[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 relay protection device communication protocol driver".into(),
        author: "EnerOS Community".into(),
    }
}

// ============================================================================
// Unit tests
// ============================================================================

#[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,
        };
        // Run async function synchronously
        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();
        // Simulate 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();
        // Simulate 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);  // Frame header
        assert_eq!(cmd[3], 0x68);  // Frame header
        assert_eq!(cmd[4], 0x08);  // C_RD
        assert_eq!(cmd[8], 0x16);  // End marker
    }

    #[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();
        // Checksum should be 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];  // Unknown type_id
        let points = adapter.parse_asdu(&data).unwrap();
        assert!(points.is_empty());  // Unknown types return empty
    }
}

Step 4: Register the Plugin

Place the build artifact in the plugin directory and configure eneros.toml:

# /etc/eneros/plugin.toml

[[plugins]]
name = "iec103-driver"
path = "/opt/eneros/plugins/libiec103.so"
type = "Protocol"
enabled = true
auto_load = true                 # Auto-load when the main process starts

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

Plugin configuration fields:

FieldTypeDefaultDescription
nameStringPlugin unique identifier
pathStringAbsolute path to the dynamic library
typeEnumPlugin type
enabledbooltrueWhether enabled
auto_loadboolfalseAuto-load on startup
configObject{}Configuration passed to the plugin (JSON)

Step 5: Build and Sign

# 1. Build the plugin (generates libiec103.so)
cargo build --release

# 2. Verify ABI entry functions
nm -D target/release/libiec103.so | grep eneros_plugin

# 3. Sign the plugin (mandatory in production)
eneros-cli plugin sign \
    --plugin libiec103.so \
    --key ~/.eneros/ed25519.key \
    --output libiec103.so.sig

# 4. Verify the signature
eneros-cli plugin verify \
    --plugin libiec103.so \
    --sig libiec103.so.sig \
    --pubkey ~/.eneros/ed25519.pub

Example output:

$ 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
✓ Signature verification passed
  Plugin: libiec103.so (124KB)
  Signer: eneros-trusted
  Algorithm: Ed25519

Step 6: Hot Loading and Debugging

Loading a plugin does not require restarting the main process:

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Create the plugin registry
    let registry = PluginRegistry::new();
    let loader = PluginLoader::new(&registry);

    // 2. Load the plugin (with signature verification)
    loader.load("/opt/eneros/plugins/libiec103.so").await?;
    println!("✓ Plugin loaded");

    // 3. Query registered plugins
    let plugins = registry.list();
    for p in &plugins {
        println!("  - {} v{} ({:?})",
            p.metadata.name, p.metadata.version, p.metadata.plugin_type);
    }

    // 4. Create a protocol adapter instance
    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. Connect to the device and scan
    adapter.connect().await?;
    let points = adapter.scan().await?;
    println!("Scanned {} points:", points.len());
    for (name, value) in &points {
        println!("  {} = {:.4}", name, value);
    }

    // 6. Unload the plugin (hot unload)
    loader.unload("iec103-driver").await?;
    println!("✓ Plugin unloaded");

    Ok(())
}

During development, use RUST_LOG=eneros_plugin=debug to view plugin logs without restarting the main process:

# View plugin logs in real time
RUST_LOG=eneros_plugin=debug,iec103=trace eneros-os --config /etc/eneros/eneros.toml

# Reload a plugin (without restarting the main process)
eneros-cli plugin reload iec103-driver

# View plugin status
eneros-cli plugin status iec103-driver

Example output:

$ eneros-cli plugin status iec103-driver
Plugin: iec103-driver
Version: 0.1.0
Type: Protocol
Status: Running
Loaded at: 2026-07-06 14:23:01
Instances: 2
Connections: 2
Last heartbeat: 2026-07-06 14:25:30

Step 7: Plugin Sandbox

Production environments enforce the sandbox to restrict plugin system calls and resource usage:

# /etc/eneros/plugin.toml — sandbox configuration
[[plugins]]
name = "iec103-driver"
path = "/opt/eneros/plugins/libiec103.so"
type = "Protocol"

[plugins.sandbox]
enabled = true
# seccomp filter: only allow necessary system calls
seccomp_profile = "protocol-default"
# Filesystem isolation
fs_isolation = "/var/lib/eneros/plugins/iec103-driver"
allowed_paths = [
    "/var/lib/eneros/plugins/iec103-driver/",
    "/etc/eneros/certs/iec103/",
]
# Resource limits
max_memory_mb = 64
max_cpu_percent = 10
max_fds = 32
max_threads = 4
# Crash isolation (catch_unwind)
crash_isolation = true
max_crashes_per_hour = 5

Isolation guarantees provided by the sandbox:

DimensionIsolation MechanismDescription
System callsseccomp BPFOnly allows necessary calls like read/write/accept
Filesystemchroot + whitelistRestricts accessible paths
Memorycgroups v2Limits max memory, OOM kill on exceed
CPUcgroups cpu quotaLimits CPU usage percentage
NetworkNetwork namespace (optional)Restricts accessible ports
Crashescatch_unwindPlugin panic does not affect the main process

Step 8: Publish to the Plugin Market

EnerOS provides a Plugin Market for plugin distribution and version management:

# 1. Package the plugin (including dynamic library, manifest, signature)
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. Upload to the plugin market
eneros-cli plugin publish \
    --file iec103-driver-0.1.0.tar.gz \
    --repo https://market.openeneros.com

# 3. Other users install
eneros-cli plugin install iec103-driver --version 0.1.0

Plugin market configuration:

# ~/.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"]

Step 9: Integration Testing

End-to-End Testing

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

    /// Start a TCP server that simulates an IEC 103 device
    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 {
                // Wait to read a command
                let mut buf = [0u8; 9];
                use tokio::io::AsyncReadExt;
                stream.read_exact(&mut buf).await.unwrap();

                // Reply with one frame of breaker state (type=1, asdu=1, value=1)
                let response = vec![
                    0x10, 0x05, 0x05, 0x68,  // Frame header
                    1, 0, 1, 0, 1,           // ASDU
                    1,                        // Checksum
                    0x16,                     // End marker
                ];
                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")));
    }
}

Sandbox Testing

# Run the plugin in the sandbox and check resource usage
eneros-cli plugin test iec103-driver \
    --sandbox \
    --device 127.0.0.1:2405 \
    --duration 60s

# Example output:
# ✓ Test passed
#   Memory peak: 8.2 MB / 64 MB
#   CPU usage: 2.3% / 10%
#   Crashes: 0 / 5
#   Syscall violations: 0

Validation

Validate the correctness of the plugin against the following metrics:

MetricExpectedDescription
Build artifactlibiec103.socdylib format
ABI entry3 exported symbolseneros_plugin_create/destroy/metadata
Load time< 100msFrom dlopen to init completion
Signature verificationPassEd25519 signature
Unit tests100% passcargo test -p iec103-driver
Sandbox isolation0 violationsseccomp/cgroups/fs all normal
Hot loading< 1sDoes not affect other protocol channels
Crash isolationMain process survivesPlugin panic does not affect the main process

Debugging Tips

# View plugin loading details
RUST_LOG=eneros_plugin=trace eneros-os --config /etc/eneros/eneros.toml

# Check dynamic library dependencies
ldd /opt/eneros/plugins/libiec103.so

# Observe plugin syscalls with strace
strace -f -e trace=network -p <eneros-os-pid>

# Check memory leaks with valgrind
valgrind --leak-check=full target/release/iec103-driver-test

# Force-trigger a plugin crash (test isolation)
eneros-cli plugin crash iec103-driver --type panic

Common Issue Troubleshooting

SymptomPossible CauseSolution
Load failureapi_version mismatchCheck the api_version field in the manifest
Symbol not foundeneros_plugin_create not exportedConfirm #[no_mangle] and extern "C"
Signature failurePublic key mismatchCheck market.toml trusted_signers
Frequent crashesNull pointer or out-of-boundsInvestigate with cargo test and valgrind
Memory exceededResources not releasedCheck drop implementations, use Rc<RefCell<>> instead of raw pointers
Syscall rejectedseccomp config too strictAdjust seccomp_profile or contact the security team

Next Steps