13. Plugin Developers Guide - gordtulloch/Galileo GitHub Wiki

13. Plugin Developers Guide

This guide is for developers who want to extend Galileo without modifying core application code: adding a new device backend, a new sequencer instruction/condition/trigger, or a new UI panel.

Status check: Galileo's plugin framework (galileo.plugins) implements loading, versioning, fault isolation, and the PluginContext callback surface described below. The two extension points β€” device backends (PLUG-010) and sequencer instructions/UI panels (PLUG-020) β€” both work today and are exercised by the first-party VSTarget plugin (see Reference implementation below). What is not built yet is the in-app plugin manager / marketplace (PLUG-030): third-party plugin discovery today is manual (you import and register your plugin class yourself), not importlib.metadata entry-points install/update/remove from a repository. If you're writing a plugin today, you're writing code that runs inside the Galileo process alongside its first-party plugins, not a separately-installable package yet.

1. Is a plugin the right tool?

Galileo has no separate "plugin API" β€” a plugin registers against exactly the same port interfaces (galileo.core.devices.DeviceBackend, galileo.sequencer.advanced.BaseInstruction/BaseCondition/BaseTrigger) that the built-in INDI/Alpaca adapters and built-in sequencer blocks use. This is a deliberate architectural choice (Software Design Description Β§2.2, Β§6.3): if core's own code and a plugin's code go through the same door, that door is forced to stay well-designed, and a disabled plugin leaves nothing special behind.

Write a plugin when you want to:

  • Add support for a device category's new hardware (e.g. a software-only Tier 2 safety monitor that classifies cloud cover from an all-sky camera, rather than reading one from a dedicated sensor) β€” see Β§3.
  • Add a new block type to the Sessions screen's instruction/condition/trigger palette (e.g. a custom dither pattern, a custom pause condition) β€” see Β§4.
  • Add a whole new top-level or nested screen to the app (VSTarget's "Variable Stars" tab is the reference example) β€” see Β§5.
  • Call back into a specific core service (submitting a Scheduler job, reading the event bus) without a hard import dependency on that core module β€” see Β§6.

Don't write a plugin for a change that belongs in core: a bug fix to an existing adapter, a change to an existing screen's layout, or anything that needs to reach into UI/adapter internals a plugin isn't given access to (see Β§8).

2. Plugin structure

Every plugin is a Python class subclassing PluginBase (galileo/plugins/__init__.py):

from galileo.plugins import PluginBase, PluginContext

class MyPlugin(PluginBase):
    name = "MyPlugin"            # unique identifier used by the PluginManager
    version = "1.0.0"            # your plugin's own version
    api_version = "1"            # Galileo's plugin API version you were built against
    panel_level = "secondary"    # "primary" (top-level tab) or "secondary" (nested)
    panel_label = "My Plugin"    # leave "" to register no UI panel at all

    def activate(self, ctx: PluginContext) -> None:
        """Called once when the plugin is loaded and enabled.

        Register device backends, instruction types, etc. here.
        """

    def deactivate(self) -> None:
        """Called when the plugin is disabled or unloaded. Release any
        resources you acquired in activate() β€” timers, open connections,
        subscriptions you made directly (bus subscriptions you made through
        a granted service are the service owner's responsibility to clean up).
        """

activate/deactivate are the entire lifecycle contract. There is no __init__ argument injection β€” PluginManager.load() instantiates your class with no constructor arguments, so do any one-time setup in activate, not __init__.

Loading your plugin

Until PLUG-030's marketplace lands, there are two ways a plugin reaches PluginManager:

  • First-party pre-loaded (how VSTarget ships): PluginManager.initialize_preloaded() directly imports and calls self.load(YourPluginClass). This is how Galileo bundles a plugin with the application itself β€” appropriate for plugins the project maintainer ships, not third-party ones.
  • Manual registration (today's path for an out-of-tree plugin): import your plugin class and call plugin_manager.load(MyPlugin) yourself, e.g. from a startup script or a thin loader you maintain. PluginManager makes no assumption about how your class reached it β€” only that it subclasses PluginBase.
from galileo.plugins import PluginManager

mgr = PluginManager()
mgr.load(MyPlugin)                 # instantiates, calls activate(), registers UI panel if any
mgr.is_loaded("MyPlugin")          # True
mgr.disable("MyPlugin")            # deactivates without unloading β€” get_ui_panels() now returns []
mgr.enable("MyPlugin")
mgr.unload("MyPlugin")             # calls deactivate(), drops the instance

Fault isolation (PLUG-040)

PluginManager.load() wraps instantiation and activate() in a try/except. If your plugin raises during activation, the exception is logged, your plugin is marked faulted (mgr.is_faulted("MyPlugin")), and the rest of the application keeps running. A faulted plugin is not retried automatically β€” fix the bug and reload. Don't rely on this as a substitute for your own error handling inside activate(); it exists so your bug can't take down someone else's session.

API version checking (PLUG-050)

Galileo's current plugin API version is _CURRENT_API_VERSION in galileo/plugins/__init__.py (currently "1"). If your plugin's declared api_version doesn't match, load() issues a PluginApiVersionWarning but still loads the plugin β€” this is a compatibility warning, not a hard gate, since the API has not yet had a breaking revision to actually gate against. Treat a mismatch warning in your own testing as "verify nothing changed under you," not as something to silently ignore.

3. Registering a device backend

Subclass galileo.core.devices.DeviceBackend and implement its abstract surface β€” the same base class galileo.adapters.indi and galileo.adapters.alpaca implement:

from galileo.core.devices import DeviceBackend, DeviceCategory
from galileo.core.capabilities import DeviceCapabilities

class MyCloudSensorBackend(DeviceBackend):
    backend = "my_cloud_sensor"          # a short identifier for this backend
    device_type = DeviceCategory.SAFETY_MONITOR.value

    async def connect(self) -> None: ...
    async def disconnect(self) -> None: ...

    @property
    def is_connected(self) -> bool: ...

    def get_capabilities(self) -> DeviceCapabilities:
        return DeviceCapabilities()

    def get_properties(self) -> dict:
        return {}

Then, in activate(), register it against the category it serves through PluginContext:

def activate(self, ctx: PluginContext) -> None:
    ctx.register_device_backend(DeviceCategory.SAFETY_MONITOR, MyCloudSensorBackend)

This appends your class to DeviceBackend.registered_backends[DeviceCategory.SAFETY_MONITOR] β€” the same registry the device-connection UI scans when building its "pick a backend" list for that category. From the sequencer/safety module's point of view, your backend is indistinguishable from a built-in INDI or Alpaca adapter; this is what the Software Requirements Specification means by ARCH-070 ("the device-abstraction layer shall be the sole integration point plugins use to register new device backends").

The clearest real case for this extension point, per the Software Design Description Β§4.16/Β§4.20, is a Tier 2 safety signal β€” a software/ML-computed safety classification (e.g. all-sky-camera cloud detection) with no single physical sensor behind it, exposed as a standard Safety Monitor device so galileo.safety can't tell it apart from a physical one.

Only implement the four DeviceBackend abstract methods plus whatever category-specific surface your device category's controller actually calls (e.g. a Safety Monitor's is_safe()/equivalent) β€” see the existing INDI/Alpaca adapter for your category in galileo/adapters/ for the exact surface its *Controller counterpart drives.

4. Registering a sequencer instruction

Subclass one of galileo.sequencer.advanced's three base types:

from dataclasses import dataclass
from galileo.sequencer.advanced import BaseInstruction

@dataclass
class WaitForCloudClearInstruction(BaseInstruction):
    label: str = "Wait for Clear Sky"
    timeout_seconds: float = 3600.0

    async def execute(self, context: dict) -> None:
        # context is the running sequence's shared execution context β€”
        # device handles, logger, elapsed time, etc.
        ...

Making your instruction a @dataclass gets you to_dict()/from_dict() (template save/load) for free, matching every built-in instruction's own pattern β€” only override from_dict yourself if your instruction needs non-trivial constructor logic beyond "set these fields." BaseCondition.evaluate(context) -> bool and BaseTrigger.check(context) -> bool follow the same shape for loop conditions and mid-sequence triggers.

Register it in activate():

def activate(self, ctx: PluginContext) -> None:
    ctx.register_instruction_type(WaitForCloudClearInstruction)

This adds your class to InstructionRegistry.instance() β€” the same registry the Sessions screen's block palette reads from, and the same registry load_template() consults when rebuilding a saved .gtpl sequence template. Two things follow from that:

  • Name collisions are first-registration-wins. InstructionRegistry.find(name) matches by class name and returns the first registration β€” a plugin cannot shadow a built-in block by reusing its name. Pick a distinctive class name.
  • A template referencing your instruction type requires your plugin to be loaded. load_template() raises ValueError rather than silently dropping a step if the instruction type it names isn't registered β€” so a .gtpl file built with your plugin enabled will fail loudly (not silently run short) if loaded without it. Don't treat this as a bug to work around; it's intentional ("fail loudly: silently dropping a step would run a shorter sequence than was saved").

5. Registering a UI panel

Set panel_label (and optionally panel_level) as class attributes β€” no separate registration call is needed; PluginManager.load() reads them directly:

class MyPlugin(PluginBase):
    name = "MyPlugin"
    version = "1.0.0"
    api_version = "1"
    panel_level = "primary"      # top-level tab, peer to Imaging/Focus/Guiding/...
    panel_label = "My Plugin"    # the tab/section's visible label

    def activate(self, ctx: PluginContext) -> None:
        pass

    def deactivate(self) -> None:
        pass
  • panel_level = "primary" inserts a top-level tab, a peer to built-in sections like Imaging or Library.
  • panel_level = "secondary" nests your panel within an existing section instead.

Leave panel_label as the empty string (the PluginBase default) if your plugin registers no UI at all β€” a device-backend-only or instruction-only plugin, for instance.

Disabling a plugin (mgr.disable("MyPlugin")) must leave it fully inert per PLUG-060: get_ui_panels("MyPlugin") returns [] while disabled, even though the plugin is still loaded. Don't hold a reference to your own panel widget as "still live" after deactivation β€” the UI shell won't ask for it again until you're re-enabled.

The actual Qt widget your panel displays is not constructed by anything shown here β€” PluginManager/UiRegistry only track the panel's label and level; wiring a label to an actual widget class is part of the UI shell's own panel-insertion code, which is this framework's thinnest-built corner today. If you're building a real UI panel plugin, read galileo/ui/app_window/ for how the shell currently inserts VSTarget's panel before assuming a stable public hook exists for arbitrary third-party widgets.

Help for your panel

Galileo has an in-app help system (hover text, F1, the ? button on each screen). To give your panel help, put Markdown files in a help/ folder next to your plugin's entry module (so it ships in your plugin ZIP). Galileo registers that folder when the plugin loads. Name each file for your screen id β€” a panel in the Science menu is science.<your plugin's name>.md:

# My Plugin

What this screen is for.

## Exposure

The first paragraph is also the hover text for the control labelled "Exposure".

Each ## heading documents one control and is matched by the control's visible text: a form-row label, a button's text, a caption beside the field or a table column header. For a control with no usable text (an icon button or a plot), set widget.setProperty("helpKey", "my key") and use that as the heading. Controls shared with Galileo's own screens (Connect, Save, Driver…) fall back to Galileo's shared help automatically.

6. The PluginContext service surface

PluginContext is deliberately narrow (Software Design Description Β§6.3): it exposes only what you were explicitly granted, not the whole domain core.

def activate(self, ctx: PluginContext) -> None:
    scheduler = ctx.get_service("scheduler")   # raises ServiceAccessDenied if not granted
    scheduler.submit_job(...)

get_service(name) raises galileo.plugins.ServiceAccessDenied for anything not in the granted_services dict the context was built with. For first-party pre-loaded plugins, the Software Design Description names a SchedulerJobSubmitter handle as the intended scheduler grant (what VSTarget's planning module uses to submit a target/plan directly to galileo.scheduler's job queue, VST-090) β€” but note that today's PluginContext.create_context() (what PluginManager.load() actually calls) always passes granted_services=None, so no service is currently granted to any plugin at load time; the grant mechanism itself works (see tests/test_plug.py::test_tc_plug_080_...), but nothing in the current loader wires a real scheduler handle through it yet. If your plugin needs a granted service today, you'll need your own loader that calls PluginManager.create_context(granted_services={...}) directly rather than relying on initialize_preloaded()/default load().

ctx.register_device_backend(...) and ctx.register_instruction_type(...) (used in Β§3/Β§4 above) are also methods on PluginContext β€” they don't need a grant, since registering against a port interface is the extension-point surface itself, not a core service call.

7. Reference implementation: VSTarget

The VSTarget plugin (galileo/plugins/vstarget/) is the concrete, working demonstration of this entire framework β€” read its code alongside this guide if an example would help:

What it demonstrates Where
Pre-loaded plugin registration, independent enable/disable (PLUG-060) galileo.plugins.PluginManager.initialize_preloaded() loads VSTPlugin and VSTAnalysisPlugin as two separate, independently-toggleable plugins
Primary-level UI panel insertion (PLUG-070) VSTPlugin.panel_level = "primary", panel_label = "Variable Stars" β€” a peer tab to the built-in Sky Atlas/Targets section, not nested under it
PluginContext service callback (PLUG-080) The planning module's AAVSO target/plan submission goes through a granted SchedulerJobSubmitter-style handle rather than importing galileo.scheduler directly
A plugin reusing core domain logic without duplicating it The analysis module calls core's existing galileo.platesolve (not its own bundled ASTAP integration) and reuses galileo.planning.sky_atlas's visibility computation for its observable-only filter
A plugin's own dependency choices coexisting with core's photutils (aperture photometry) alongside core's SEP-everywhere policy β€” a deliberate, documented exception, not an inconsistency to "fix"

VSTarget's own requirements/design/traceability live in their own parallel document chain β€” docs/plugins/vstarget/ (PSD, SRS, SDD, RTM) β€” structurally mirroring core's own chain but scoped to the plugin, consuming core requirements (PLUG-* and others) by reference rather than restating them. If you're building a plugin substantial enough to warrant its own requirements doc, that's the pattern to copy.

8. Constraints and things plugins cannot do

  • No sandboxing. A plugin runs in-process with full application privileges β€” this framework provides fault isolation (a crash in activate()/deactivate() is caught and logged), not memory or security sandboxing. Don't treat "it's a plugin" as a security boundary.
  • No import of adapter or UI internals. A plugin may depend only on the device port interfaces (galileo.core.devices), the SequencerNode/instruction-condition-trigger interface (galileo.sequencer.advanced), and PluginContext. Importing galileo.adapters.indi/galileo.adapters.alpaca or reaching into galileo.ui.* internals directly defeats the same module-boundary rule core code is held to.
  • No event-bus bypass. Cross-module communication goes through galileo.bus.get_bus() (EventBus.subscribe/publish), the same as core modules β€” don't call into unrelated domain-core modules directly just because you're a plugin and the usual import-boundary lint rule doesn't apply to you contractually (it isn't enforced against plugin code the way it's enforced against core, but violating it defeats the fault-isolation guarantee the rest of this framework is built around).
  • Licensing. Galileo is GPL-3.0-or-later. Because plugins load in-process (not out-of-process/sandboxed), a plugin is very likely a "combined work" under GPL-3.0 and needs a GPL-compatible license to be distributed alongside Galileo. This is a documented consequence of the in-process loading design, not a policy that might loosen later without an architecture change.
  • No marketplace yet (PLUG-030). PluginManager.install()/.update() currently raise NotImplementedError. There's no entry-points-based discovery or versioned install mechanism in the repository today β€” see the status note at the top of this guide.

9. Checklist

  1. Subclass PluginBase; set name, version, api_version.
  2. Implement activate(ctx)/deactivate().
  3. In activate(), register whatever extension points you need: ctx.register_device_backend(...), ctx.register_instruction_type(...), and/or set panel_level/panel_label as class attributes.
  4. If you need a granted core service, build your own PluginContext via PluginManager.create_context(granted_services={...}) rather than relying on the default loader β€” nothing is granted by default yet.
  5. Test with a real PluginManager: load, verify registration took effect against the real registry (DeviceBackend.registered_backends, InstructionRegistry.instance(), UiRegistry), disable, verify inertness, unload.
  6. If your plugin is substantial, write its own requirements/design chain mirroring docs/plugins/vstarget/ rather than asking core's docs/SRS.md/SDD.md to describe it.

Elsewhere

  • 12. Science β€” Variable Stars β€” the user-facing guide to VSTarget, the plugin this guide uses as its reference implementation.
  • Design documents β€” docs/SDD.md Β§4.20 (galileo.plugins) and Β§6.3 (Plugin API Surface) are the authoritative architecture reference this guide summarizes.
  • VSTarget plugin documents β€” the parallel PSD/SRS/SDD/RTM chain for the reference plugin.