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 thePluginContextcallback 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), notimportlib.metadataentry-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 callsself.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.PluginManagermakes no assumption about how your class reached it β only that it subclassesPluginBase.
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()raisesValueErrorrather than silently dropping a step if the instruction type it names isn't registered β so a.gtplfile 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), theSequencerNode/instruction-condition-trigger interface (galileo.sequencer.advanced), andPluginContext. Importinggalileo.adapters.indi/galileo.adapters.alpacaor reaching intogalileo.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 raiseNotImplementedError. 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
- Subclass
PluginBase; setname,version,api_version. - Implement
activate(ctx)/deactivate(). - In
activate(), register whatever extension points you need:ctx.register_device_backend(...),ctx.register_instruction_type(...), and/or setpanel_level/panel_labelas class attributes. - If you need a granted core service, build your own
PluginContextviaPluginManager.create_context(granted_services={...})rather than relying on the default loader β nothing is granted by default yet. - Test with a real
PluginManager: load, verify registration took effect against the real registry (DeviceBackend.registered_backends,InstructionRegistry.instance(),UiRegistry), disable, verify inertness, unload. - If your plugin is substantial, write its own requirements/design chain mirroring
docs/plugins/vstarget/rather than asking core'sdocs/SRS.md/SDD.mdto 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.