Writing External Modules
When to Write a Module
Write an external module when you need to:
- Interface with hardware that AutoCore doesn’t support natively (cameras, custom sensors, robotic controllers)
- Run code that should operate independently of the control loop (long-running tasks, blocking SDK calls)
- Add a service that other components can call by name (e.g., a barcode scanner service)
Module Lifecycle
Server starts
│
├─ Spawns module process with --ipc-address, --module-name, --config
│
├─ Module connects to IPC server
│
├─ Server sends "initialize" command
│ └─ Module calls on_initialize()
│
├─ Server sends "configure_shm" (if module uses shared memory)
│ └─ Module calls on_shm_configured()
│
├─ Module handles incoming requests via handle_message()
│ (continues until shutdown)
│
├─ Server sends "finalize" command
│ └─ Module calls on_finalize()
│
└─ Module process exits
Step-by-Step Module Development
Step 1: Create the Crate
cargo init my-module
cd my-module
Add dependencies to Cargo.toml:
[package]
name = "my-module"
version = "1.0.0"
edition = "2024"
[dependencies]
mechutil = "0.7"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde_json = "1"
anyhow = "1"
log = "0.4"
simplelog = "0.12"
Step 2: Implement the ModuleHandler Trait
#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use mechutil::ipc::{CommandMessage, IpcClient, ModuleArgs, ModuleHandler};
use mechutil::shm::ShmMap;
use simplelog::{Config, LevelFilter, SimpleLogger};
struct MyModule {
domain: String,
shm: Option<ShmMap>,
}
impl MyModule {
fn new(domain: &str) -> Self {
Self {
domain: domain.to_string(),
shm: None,
}
}
}
#[async_trait]
impl ModuleHandler for MyModule {
fn domain(&self) -> &str {
&self.domain
}
async fn on_initialize(&mut self) -> Result<()> {
log::info!("{} initialized", self.domain);
Ok(())
}
async fn on_finalize(&mut self) -> Result<()> {
log::info!("{} shutting down", self.domain);
Ok(())
}
async fn handle_message(&mut self, msg: CommandMessage) -> CommandMessage {
let subtopic = msg.subtopic().to_string();
match subtopic.as_str() {
"status" => {
msg.into_response(serde_json::json!({
"ok": true,
"shm_connected": self.shm.is_some(),
}))
}
_ => msg.into_error_response(
&format!("Unknown command: {}", subtopic)
),
}
}
fn shm_variable_names(&self) -> Vec<String> {
vec![] // Return variable names if your module uses shared memory
}
async fn on_shm_configured(&mut self, shm_map: ShmMap) -> Result<()> {
self.shm = Some(shm_map);
Ok(())
}
}
}
Step 3: Write main()
#[tokio::main]
async fn main() -> Result<()> {
SimpleLogger::init(LevelFilter::Info, Config::default())?;
let args = ModuleArgs::from_env()?;
log::info!("Starting {} at {}", args.module_name, args.ipc_address);
let handler = MyModule::new(&args.module_name);
let client = IpcClient::connect(&args.ipc_address, handler).await?;
client.run().await?;
Ok(())
}
Step 4: Register the Module
Add to project.json:
{
"modules": {
"my_module": {
"enabled": true,
"config": {
"setting1": "value1"
}
}
}
}
Add to config.ini:
[modules]
my_module = /path/to/my-module/target/release/my-module
Step 5: Test
# Build the module
cargo build --release
# Restart the server (it will spawn the module automatically)
sudo systemctl restart autocore_server
# Verify the module is connected
acctl cmd my_module.status
Real-World Example: Camera Integration
The autocore-labelit module demonstrates a production-quality module pattern. It manages a Basler GigE camera for label inspection:
- Handle/Worker split: Camera SDK calls are blocking, so they run on a dedicated OS thread. The async
ModuleHandlercommunicates with the camera thread through channels. - IPC commands map to subtopics:
camera_start,camera_snap,camera_shutdown,status. - Timeouts on every operation: Each camera operation is wrapped in
tokio::time::timeoutso a stuck camera cannot hang the IPC loop. - Graceful lifecycle: The camera worker is spawned in
on_initialize()and shut down inon_finalize().