Headless Mode — Developer Guide¶
This guide explains how to add headless and MCP support to your Frasy application.
Overview¶
The Frasy framework provides headless execution at the framework level. To enable it in your application, you need to:
- Implement a
ProductProviderclass - Register it in your application's Interpreter constructor
- Optionally refactor your
makeOrchestratorto use the same provider
Implementing ProductProvider¶
Create a class that inherits from Frasy::Headless::ProductProvider:
#include <utils/headless/product_provider.h>
class MyProductProvider : public Frasy::Headless::ProductProvider {
public:
bool validateSerialNumber(const std::string& serial) override
{
// Validate serial format for your product
// Return true if valid, false otherwise
return serial.size() == 12 && serial.starts_with("SN");
}
bool setup(Frasy::Lua::Orchestrator& orchestrator,
Frasy::CanOpen::CanOpen& canOpen,
const std::string& product,
const std::string& envPath,
const std::string& testsDir) override
{
// 1. Select product-specific configuration
selectProduct(product);
// 2. Set Lua values (available at Context.values.gui)
orchestrator.setLoadUserValues([this](sol::state_view lua) {
return loadLuaValues(lua);
});
// 3. Load user files
if (!orchestrator.loadUserFiles(envPath, testsDir)) {
return false;
}
// 4. Configure CANopen
canOpen.stop();
canOpen.clearNodes();
const auto& [ibs, uuts, teams] = orchestrator.getMap();
for (const auto& ib : ibs | std::views::values) {
canOpen.addNode(ib.nodeId, ib.name, ib.edsPath);
}
// 5. Start CANopen
canOpen.start();
// 6. Set Lua functions
orchestrator.setLoadUserFunctions([this](sol::state_view lua) {
loadLuaFunctions(lua);
});
return true;
}
void onTestComplete(Frasy::Lua::Orchestrator& orchestrator) override
{
// Optional: post-test actions (send reports, signal LEDs, etc.)
}
private:
void selectProduct(const std::string& product) { /* product routing */ }
sol::table loadLuaValues(sol::state_view lua) { return lua.create_table(); }
void loadLuaFunctions(sol::state_view lua) { /* register custom Lua functions */ }
};
Method Responsibilities¶
validateSerialNumber(serial)¶
Called before tests start for each serial provided via --serial or run_tests. Return false to reject invalid serials (headless mode exits with code 2, MCP returns an error).
setup(orchestrator, canOpen, product, envPath, testsDir)¶
Called once before test execution. Must configure the orchestrator and hardware for the given product. This is the single source of truth — both the GUI path and headless/MCP paths call this method.
The order of operations matters:
- Select product-specific config (routing logic)
orchestrator.setLoadUserValues(...)— before loadUserFilesorchestrator.loadUserFiles(envPath, testsDir)— loads environment.lua and test files- Configure CANopen nodes from the IB map
canOpen.start()orchestrator.setLoadUserFunctions(...)— after loadUserFiles
onTestComplete(orchestrator)¶
Called after the test run finishes (regardless of pass/fail). Use it for:
- Sending reports via email
- Setting signaling LEDs
- Logging to external systems
Registering the Provider¶
In your application's Interpreter constructor, register the provider before pushing the main layer:
class MyInterpreter : public Frasy::Interpreter {
public:
MyInterpreter() : Interpreter("My App")
{
setProductProvider(std::make_unique<MyProductProvider>());
pushLayer(new MyMainApplicationLayer());
}
};
The provider is accessible from anywhere via:
Refactoring makeOrchestrator¶
Your GUI layer's makeOrchestrator can now delegate to the same provider:
Before¶
void MyLayer::makeOrchestrator(const std::string& name,
const std::string& envPath,
const std::string& testPath) {
// Duplicated setup logic...
if (m_orchestrator.loadUserFiles(envPath, testPath)) {
m_canOpen.stop();
m_canOpen.clearNodes();
// ... configure CANopen ...
m_canOpen.start();
m_orchestrator.setLoadUserFunctions([&](auto lua) { ... });
// GUI-specific setup
m_activeProduct = name;
m_serials.resize(uuts.size() + 1);
} else {
Brigerad::warningDialog("Frasy", "Failed!");
}
}
After¶
void MyLayer::makeOrchestrator(const std::string& name,
const std::string& envPath,
const std::string& testPath) {
auto* provider = Frasy::Interpreter::Get().getProductProvider();
if (provider && provider->setup(m_orchestrator, m_canOpen, name, envPath, testPath)) {
// GUI-specific setup only
m_activeProduct = name;
const auto& [ibs, uuts, teams] = m_orchestrator.getMap();
m_serials.resize(uuts.size() + 1);
} else {
Brigerad::warningDialog("Frasy", "Failed!");
}
}
Product Routing¶
If your application has multiple products with different configurations, implement the routing in setup():
bool setup(Orchestrator& orchestrator, CanOpen& canOpen,
const std::string& product, const std::string& envPath,
const std::string& testsDir) override
{
// Select product-specific behavior
if (product == "product_a") {
orchestrator.setLoadUserValues([](sol::state_view lua) {
auto t = lua.create_table();
t["voltage"] = 24.0;
return t;
});
} else if (product == "product_b") {
orchestrator.setLoadUserValues([](sol::state_view lua) {
auto t = lua.create_table();
t["voltage"] = 12.0;
return t;
});
}
// Common setup
if (!orchestrator.loadUserFiles(envPath, testsDir)) return false;
canOpen.stop();
canOpen.clearNodes();
const auto& [ibs, uuts, teams] = orchestrator.getMap();
for (const auto& ib : ibs | std::views::values) {
canOpen.addNode(ib.nodeId, ib.name, ib.edsPath);
}
canOpen.start();
return true;
}
Testing Headless Mode¶
Quick test from command line¶
With verbose logging¶
JSON output for scripting¶
MCP mode testing¶
Configure as an MCP server in your Kiro agent and use the tools interactively:
{
"mcpServers": {
"frasy": {
"command": "cmd",
"args": ["/c", "cd /d C:\\path\\to\\bin && frasy.exe --mcp-server"]
}
}
}
Then use list_products, run_tests, get_status, get_pending_popup, respond_to_popup, and get_results tools from the agent.