The IPython Controller#

Constellation ships with an interactive command-line controller built on IPython. It gives operators a full Python environment from which they can discover satellites, inspect their state, send commands, load configuration files, emit log messages into the shared log stream, and run ad-hoc Python expressions β€” all without writing a script first.

This tutorial walks through a complete session: starting a small Constellation, initializing and launching it, taking a run, and recovering from a simple error. It assumes that the SatelliteSputnik satellite has been started and that the reader is familiar with the Satellite concept.

See also

The MissionControl controller covers the same fundamental workflow through a graphical user interface. For automated, script-driven operation β€” running without an interactive session β€” the ScriptableController can be used for example to set up Parameter Scans with Python.

Starting the Controller#

The interactive controller is provided by the cli extra of the Constellation Python package and can be installed via:

pip install "ConstellationDAQ[cli]"
pip install --no-build-isolation -e ".[cli]"

Once installed, it is launched with the Controller command. The only required argument is the Constellation group name, which must match the group the satellites belong to:

Controller -g edda

Optionally, the controller can be given a name to identify it e.g. in log messages emitted to the Constellation:

Controller -g edda -n mycontroller

By default, the controller uses the host name of the machine it is started on.

An optional configuration file can be supplied directly at startup, making it available as the cfg object inside the session as covered in detail below:

Controller -g edda --config myconfiguration.toml

At startup, the controller connects to the running Constellation and displays the IPython prompt.

|2026-05-09 14:43:05| INFO     [LINK] Using interfaces addresses ['127.0.0.1']
|2026-05-09 14:43:06| INFO     [LINK] Registered heartbeating check for tcp://127.0.0.1:45951
|2026-05-09 14:43:06| INFO     [BASECONTROLLER] Satellite Sputnik.One connected

Welcome to the Constellation CLI IPython Controller!

You can interact with the discovered Satellites via the `constellation` array:
         > constellation.get_state()

To get help for any of its methods, call it with a question mark:
         > constellation.get_state?

   Happy hacking :)

Starting IPython Controller for Constellation
πŸ“‘ v0.8.1 (Caelum) πŸ›° 2 πŸ˜€ NEW ipython
edda >

The status line displays the Constellation version of the controller, the number of connected satellites, and the current global state of the Constellation along with descriptive emojis. The Constellation group name is shown as the prompt prefix and identifies which Constellation is being controlled.

Two objects are pre-defined and available in every session:

  • constellation β€” represents the Constellation and provides access to commands that can be either sent individually to each of the discovered satellite, to a certain type of satellite or to the entire Constellation.

  • ctrl β€” the underlying Python controller instance. It provides utility methods such as await_state, which pauses execution until all satellites have reached a target state.

Note

The controller is stateless. It can be closed and restarted at any time without affecting the satellites. When it reconnects, it rediscovers the running Constellation automatically and displays their current FSM states as well as the last active run identifier.

Discovering Satellites#

As soon as the controller is running it begins listening for CHIRP discovery messages and builds a live map of the satellites in the Constellation. This map is always accessible via constellation.satellites:

edda > constellation.satellites
{"Sputnik.One": SatelliteCommLink(type=Sputnik, name=One), "Sputnik.Two": SatelliteCommLink(type=Sputnik, name=Two)}

The dictionary keys are canonical names in the form Type.Name. The controller updates this map whenever satellites join or leave the group, so the view is always current.

Individual satellites can be addressed directly by their canonical name using attribute access. Tab completion works throughout, so pressing Tab after constellation. displays all available commands and discovered satellite, pressing Tab after constellation.Sputnik. lists the commands and the discovered satellites of this type, and pressing Tab after constellation.Sputnik.One. lists every command that particular satellite exposes:

edda > constellation.Sputnik.One.get_name()
SatelliteResponse(msg="Sputnik.One")

edda > constellation.Sputnik.One.get_state()
edda > constellation.Sputnik.One.get_state()
SatelliteResponse(
    msg="NEW",
    payload=16,
    meta={"last_changed": datetime.datetime(2026, 5, 9, 12, 42, 52, 329643, tzinfo=datetime.timezone.utc)},
)

Every command returns a SatelliteResponse object. Its most useful fields are:

  • msg β€” a human-readable string, typically the state name or an acknowledgment message.

  • payload β€” the machine-readable data accompanying the response (for example the state encoded as an integer or a configuration dictionary). The contents depend on the command.

  • meta β€” a dictionary of additional key-value pairs, such as last_changed, the timestamp when the satellite last changed state.

The same commands can be issued to all satellites at once by calling them on constellation directly. The return value is then a dictionary mapping each canonical name to its individual SatelliteResponse:

edda > constellation.get_state()
{
    "Sputnik.One": SatelliteResponse(msg="NEW", payload=16, meta={...}),
    "Sputnik.Two": SatelliteResponse(msg="NEW", payload=16, meta={...}),
}

All satellites of a given type can be addressed together as well, which is convenient when multiple instances of the same type exist:

edda > constellation.Sputnik.get_state()
{
    "Sputnik.One": SatelliteResponse(msg="NEW", payload=16, meta={...}),
    "Sputnik.Two": SatelliteResponse(msg="NEW", payload=16, meta={...}),
}

Because the session is a full Python interpreter, the standard output methods can be used to format responses in any way that helps. The following loop, for example, prints a compact state summary:

edda > for name, sat in constellation.satellites.items():
  ...:     print(f"{name:30s}  {sat.get_state().msg}")
  ...:
Sputnik.One                     NEW
Sputnik.Two                     NEW

Loading a Configuration#

Satellites require a configuration for their initialization. Configuration files are TOML or YAML files containing the configuration parameters for all satellites in the Constellation. Their format are described in detail in the Configuration Files section.

If a configuration file was provided on the command line at startup, it is already available via the cfg object. To load a file after startup, or to switch to a different file mid-session, the load_config helper reads and parses a file:

edda > cfg = load_config("myconfiguration.toml")

The cfg object is a ControllerConfiguration whose structure mirrors the configuration file. It can be inspected directly:

edda > cfg
{"Sputnik.One": {"interval": 2500}, "Sputnik.Two": {"interval": 3000}}

Initializing and Launching#

With the configuration ready, the satellites can be initialized. The initialize command accepts the full cfg object and the controller takes care of routing each section to its corresponding satellite:

edda > constellation.initialize(cfg)
{
    "Sputnik.One": SatelliteResponse(msg="Transition initialize is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition initialize is being initiated"),
}

The response is immediate since the satellites confirm their entering into the initializing state in which they perform their initialization work asynchronously. Once all satellites are in the INIT state, they can be launched to the ORBIT state, which is when instrument hardware is fully powered and ready for data taking:

edda > constellation.launch()
{
    "Sputnik.One": SatelliteResponse(msg="Transition launch is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition launch is being initiated"),
}

The state and status message of individual satellites described in the Satellite chapter can be polled at any time:

edda > constellation.Sputnik.One.get_state()
SatelliteResponse(
    msg="ORBIT",
    payload=48,
    meta={"last_changed": datetime.datetime(2026, 5, 11, 8, 53, 55, 143613, tzinfo=datetime.timezone.utc)},
)
edda > constellation.Sputnik.One.get_status()
SatelliteResponse(msg="Satellite launched successfully")

Starting and Stopping a Run#

From the ORBIT state, a run is started by providing a run identifier to the start command. The run identifier is a free-form string composed of alphanumeric characters, underscores, or dashes:

edda > constellation.start("run_0001")
{
    "Sputnik.One": SatelliteResponse(msg="Transition start is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition start is being initiated"),
}

All satellites are now in the RUN state and data is being acquired. The run identifier can be confirmed at any time:

edda > constellation.Sputnik.One.get_run_id()
SatelliteResponse(msg="run_0001")

The controller can be closed without interrupting the run. Satellites operate autonomously and continue taking data regardless of whether a controller is connected. Reconnecting by starting a new controller with Controller -g edda will rediscover the running satellites and report their current state and run identifier automatically.

To end the run, the stop command is issues and satellites will to return to ORBIT:

edda > constellation.stop()
{
    "Sputnik.One": SatelliteResponse(msg="Transition stop is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition stop is being initiated"),
}

Sending Commands to Individual Satellites#

Sometimes it is necessary to interact with a specific satellite rather than the group as a whole. Any satellite in the constellation.satellites dictionary can be addressed individually.

A useful first step with an unfamiliar satellite is to ask it what commands it supports:

edda > constellation.Sputnik.One.get_commands()
SatelliteResponse(
                   msg='16 commands known, list attached in payload',
                   payload={"get_channel_reading": 'This example command reads the a device value from the channel number provided as argument. ...',
                            "get_commands": 'Get commands supported by satellite (returned in payload as flat MessagePack dict with strings as keys)',
                            "get_config": 'Get config of satellite (returned in payload as flat MessagePack dict with strings as keys)',
                            "get_name": 'Get canonical name of satellite',
                            "get_role": 'Get role of satellite',
                            "get_run_id": 'Current or last run identifier',
                            "get_state": 'Get state of satellite',
                            "get_status": 'Get status of satellite',
                            ...})

The currently active configuration of a satellite can be retrieved with the get_config command. This is especially useful when the controller was restarted without access to the original configuration file, or when verifying that the intended parameters were applied:

edda > constellation.Sputnik.One.get_config()
SatelliteResponse(
    msg="Configuration attached in payload",
    payload={"_autonomy": {"max_heartbeat_interval": 30, "role": "DYNAMIC"}, "interval": 2500, "launch_delay": 0},
)

Satellite implementations may expose additional custom commands beyond the standard set. These appear in the get_commands output alongside the built-in ones, such as the get_channel_reading command above, and are called in exactly the same way. Arguments to custom commands need to be provided as Python list:

edda > constellation.Sputnik.One.get_channel_reading([42])
SatelliteResponse(msg="Command returned: 579.6", payload=579.6)

Similarly to accessing individual satellites, also all satellites of a specific type can be called via attribute access:

edda > constellation.Sputnik.get_status()
{
    "Sputnik.Two": SatelliteResponse(msg="Satellite stopped run successfully"),
    "Sputnik.One": SatelliteResponse(msg="Satellite stopped run successfully"),
}

Reconfiguring Without Relaunching#

The reconfiguring transition lets a satellite update selected parameters from the ORBIT state directly, avoiding having to land, re-initialize, and re-launch. Only satellites that explicitly implement the reconfiguring transition support this, unsupported satellites will reject the command and remain in ORBIT.

A partial configuration dictionary, containing only the keys that should change, is passed as the argument. Unchanged parameters are left as they are:

edda > constellation.Sputnik.One.reconfigure({"interval": 500})
{"Sputnik.One": SatelliteResponse(msg="Transition reconfigure is being initiated")}

It is also possible to reconfigure all satellites of a given type simultaneously:

edda > constellation.Sputnik.reconfigure({"interval": 500})
{
    "Sputnik.One": SatelliteResponse(msg="Transition reconfigure is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition reconfigure is being initiated"),
}

See also

The Parameter Scans with Python how-to guide shows how to combine reconfigure and start/stop in a loop to automate multi-step measurement campaigns from within the interactive session.

Handling Errors#

When a satellite encounters a problem it cannot recover from automatically it enters the ERROR state. The error details are usually available in the satellite’s status message and in the log output:

edda > constellation.Sputnik.One.get_state()
SatelliteResponse(msg='ERROR', payload=240, ...)

edda > constellation.Sputnik.One.get_status()
SatelliteResponse(msg='Communication timeout with device on /dev/ttyUSB0')

Depending on their _autonomy.role configuration, other satellites will follow the error into the SAFE state:

edda > constellation.Sputnik.Two.get_state()
SatelliteResponse(msg='SAFE', payload=224, ...)

edda > constellation.Sputnik.Two.get_status()
SatelliteResponse(msg='Interrupting satellite operation: Sputnik.One reports state ERROR')

In addition, any satellite error state will color the edda > prompt in red.

The ERROR and SAFE states must be resolved by manual intervention. After investigating and fixing the underlying cause, for example by reconnecting a device, correcting the configuration, or restarting the hardware, the respective satellite is reset with a initialize call.

Sending Log Messages#

The controller participates in the Constellation logging system, described in the Logging & Verbosity Levels section, and can therefore emit log messages that can be received by any connected listener. This makes it possible to insert operator notes directly into the shared log stream, so that actions taken from the interactive session are recorded alongside the satellite log output.

Log messages are sent through the ctrl object using log along with the desired log level:

edda > ctrl.log.status("Starting high-voltage ramp-up sequence")

All messages sent this way are published under the OP log topic, which is the standardized topic for operator actions. Listeners can therefore subscribe specifically to LOG/STATUS/OP or any other level with the OP topic to follow operator activity without receiving the full satellite log output.

In listener interfaces such as Observatory, messages from the controller appear in the log display like any satellite message, with the canonical name of the controller as the sender.

Tip

Log messages are a useful way to mark significant moments in a measurement campaign, for example, noting when a cable was reconnected, when a hardware parameter was adjusted by hand, or when a run was intentionally cut short. Because they are stored alongside satellite logs, they remain available for later inspection and can be correlated with the data.

Shutting Down#

Individual satellites, or all of them, can be shut down via the shutdown command. Satellites only accept shutdown from the NEW, INIT, SAFE, or ERROR states. A Constellation that is currently in ORBIT or running a measurement must be landed first:

edda > constellation.land()
{
    "Sputnik.One": SatelliteResponse(msg="Transition land is being initiated"),
    "Sputnik.Two": SatelliteResponse(msg="Transition land is being initiated"),
}

edda > constellation.shutdown()
{
    "Sputnik.One": SatelliteResponse(msg="Shutting down satellite"),
    "Sputnik.Two": SatelliteResponse(msg="Shutting down satellite"),
}

The constellation.satellites dictionary will become empty once the satellites have exited.

Closing the Controller#

The interactive session is closed with the quit command or by pressing ctrl-d twice. This has no effect on the running satellites.