Standalone nodes
Constantly adding and running nodes through the node stack is highly inefficient during development. In that scenario, you want to be able to run your node as a regular Rust/Python program and use your favorite IDE to debug it.
Debugging a node
Section titled “Debugging a node”While debugging the nodes based on the logs can be quite helpful, nothing beats the ability to fire up the debugger to inspect the code that is supposed to run inside the Peppy node stack.
To support this, peppy can run a node in “standalone mode”: it communicates with other nodes in the stack but runs as a regular program outside of it, allowing you to use standard debugging tools.
Let’s create a new node:
-
Initialize the node:
Terminal window peppy node init --toolchain uv standaloneTerminal window peppy node init --toolchain cargo standalone -
Navigate into the directory:
Terminal window cd standalone
Then modify the peppy.json5 configuration to look like this:
{ peppy_schema: "node/v1", manifest: { name: "standalone", tag: "v1", }, interfaces: {}, execution: { language: "python", // A bunch of fake parameters required to start our node parameters: { device_path: "string", video: { frame_rate: "u16", resolution: { width: "u16", height: "u16", }, encoding: "string", }, }, build_cmd: [ "uv", "sync" ], run_cmd: [ "uv", "run", "standalone" ] }}{ peppy_schema: "node/v1", manifest: { name: "standalone", tag: "v1", }, interfaces: {}, execution: { language: "rust", // A bunch of fake parameters required to start our node parameters: { device_path: "string", video: { frame_rate: "u16", resolution: { width: "u16", height: "u16", }, encoding: "string", }, }, build_cmd: [ "cargo", "build", "--release" ], run_cmd: [ "./target/release/standalone" ] }}Now sync the node:
peppy node sync(You can also pass --sync/-s to peppy node add if you want to sync and add in one step, e.g. peppy node add . -sb to sync, add, and build together.)
Now if you try to run the node:
uv run standaloneYou’ll run into the following error:
RuntimeError: missing required parameter(s) for standalone mode: device_path, video. Provide them via StandaloneConfig().with_parameters()cargo runYou’ll run into the following error:
Error: NodeArgumentsValidation(MissingParameters(["device_path", "video"]))These parameters are usually provided during peppy node run, but since we want this node to run as a standalone program, we need to pass them outside of the Peppy daemon environment.
We can define our parameters in a params.json file at the root of the project:
{ "device_path": "/dev/video0", "video": { "frame_rate": 30, "resolution": { "width": 1920, "height": 1080 }, "encoding": "h264" }}{ "device_path": "/dev/video0", "video": { "frame_rate": 30, "resolution": { "width": 1920, "height": 1080 }, "encoding": "h264" }}Then modify the source file to read from this file:
import json
from peppygen import NodeBuilder, NodeRunner, StandaloneConfigfrom peppygen.parameters import Parameters
async def setup(params: Parameters, node_runner: NodeRunner): print("Inside the setup callback!")
def main(): # Parameters can also be defined directly in code: # # from peppygen.parameters import Video, VideoResolution # # params = Parameters( # device_path="/dev/video0", # video=Video( # frame_rate=30, # resolution=VideoResolution( # width=1920, # height=1080, # ), # encoding="h264", # ), # )
with open("params.json") as f: params = json.load(f)
standalone_config = StandaloneConfig().with_parameters(params) NodeBuilder().standalone(standalone_config).run(setup)
if __name__ == "__main__": main()Now if we run the following command again:
uv run standaloneuse peppygen::{NodeBuilder, Parameters, Result};use peppylib::runtime::StandaloneConfig;
fn main() -> Result<()> { // Parameters can also be defined directly in code: // // use peppygen::parameters::video::{Video, VideoResolution}; // // let params = Parameters { // device_path: "/dev/video0".to_string(), // video: Video { // frame_rate: 30, // resolution: VideoResolution { // width: 1920, // height: 1080, // }, // encoding: "h264".to_string(), // }, // };
let json = std::fs::read_to_string("params.json") .expect("failed to read params.json"); let params: Parameters = serde_json::from_str(&json) .expect("failed to parse params.json");
let standalone_config = StandaloneConfig::new().with_parameters(¶ms); NodeBuilder::new() .standalone(standalone_config) .run(|args: Parameters, node_runner| async { println!("Inside the run closure!"); let _ = args; let _ = node_runner; Ok(()) })}Now if we run the following command again:
cargo runThe node should run without a crash.
The standalone object allows us to load parameters from an external JSON file and pass them to the node, which in turn allows us to run our node as a regular Rust/Python program.
Note that the standalone config is completely ignored when a node is run with peppy node run; all parameters provided during the node run operation take precedence.
Seeding slots
Section titled “Seeding slots”Parameters are not the only thing a launch resolves. A node that declares pairings, producer-binding slots, or observer slots reads those from the boot config the daemon hands it, and standalone mode has no daemon to write one. The standalone config carries one builder per slot kind so the node still starts:
| Builder | Stands in for |
|---|---|
with_peer_pin | the daemon’s --pair delivery for a depends_on.pairings slot |
with_bound_producer | one --link occurrence, or one launcher links: entry, for a depends_on.nodes / depends_on.contracts slot |
with_vacant_producer_slot | a launcher writing a producer-binding slot { vacant: "<why>" } |
with_observed_source | one member of the set the daemon stamps into an observer slot at spawn |
Every builder takes the local slot’s link_id first. The three that name a remote instance follow it with that instance’s (core_node, instance_id) address, and the two of those that also name a slot on the other side (a pairing’s peer and an observed pairing’s source) take that slot’s link_id last. with_vacant_producer_slot names only the local slot, because there is no instance to point at:
standalone_config = ( StandaloneConfig() .with_parameters(params) .with_peer_pin("arm", "core_x", "arm_1", "controller") .with_bound_producer("camera", "core_x", "front_camera") .with_vacant_producer_slot("greeter") .with_observed_source("watch", "core_x", "arm_1", "controller"))let standalone_config = StandaloneConfig::new() .with_parameters(¶ms) .with_peer_pin("arm", "core_x", "arm_1", "controller") .with_bound_producer("camera", "core_x", "front_camera") .with_vacant_producer_slot("greeter") .with_observed_source("watch", "core_x", "arm_1", "controller");Repeat calls for the same link_id accumulate in call order, exactly as repeated --link flags do, so the Nth call is the Nth member the node reads back.
Each slot’s seeded set has to satisfy the slot’s declared cardinality at startup, which is the rule launch validation applies at plan time: exactly one for one, at most one for zero_or_one, at least one for one_or_more, any number for zero_or_more. So a slot whose cardinality has a floor of one must be seeded, and the node refuses to start otherwise rather than running a slot its own manifest says cannot be empty. The two zero-floor cardinalities may be left out entirely: an unseeded observer slot observes nothing, and an unseeded zero_or_more producer-binding slot binds nothing. A zero_or_one producer-binding slot is the one case that needs a word either way, because omitting it is how you forget it: with_vacant_producer_slot is the standalone spelling of a deployment writing the slot vacant.
Naming a link_id the manifest does not declare is a typo rather than a shape error, so it warns and is ignored instead of failing the run.