Creating Output Plugins

Output plugins are post-processing hooks that run after every export. They receive the exported files, device metadata, and the full binding model, so your plugin can deliver diagrams wherever they need to go.

Quick Start

Here's a minimal output plugin:

main.py
from joystick_diagrams.plugins.output_plugin_interface import (
    ExportResult,
    OutputPluginInterface,
)
from joystick_diagrams.plugins.plugin_settings import PluginMeta, PluginSettings


class MySettings(PluginSettings):
    pass


class OutputPlugin(OutputPluginInterface):
    plugin_meta = PluginMeta(
        name="My Plugin",
        version="1.0.0",
        icon_path="img/icon.ico",
    )
    plugin_settings_model = MySettings

    def process_export(self, results: list[ExportResult]) -> bool:
        for result in results:
            print(f"{result.profile_name} - {result.device_name}")
        return True

Plugin Structure

your_plugin/
  __init__.py
  main.py
  img/
    icon.ico

The main.py file must define an OutputPlugin class that inherits from OutputPluginInterface.

Installation & Distribution

Users install output plugins from the Settings page. See Installing Plugins for the end-user walkthrough. As an author, your job is to produce a zip that validates against the installer.

Zip Layout

The archive must contain a single plugin folder at its root:

my_plugin.zip
└── my_plugin/
    ├── __init__.py        # required, can be empty
    ├── main.py            # required, defines OutputPlugin class
    ├── plugin.sig         # optional, Ed25519 signature (see below)
    └── img/
        └── icon.ico

The installer validates that __init__.py and main.py exist, that main.py defines an OutputPlugin class subclassing OutputPluginInterface, and that the class instantiates without errors.

Install Sources

The installer accepts three source types:

  • Local folder: point at an unzipped plugin on disk.
  • Zip file: the usual distribution format.
  • URL: a direct link to a zip (e.g. a GitHub release asset).

User-Side Folder Drop

Users can also install by dropping a plugin folder directly into %APPDATA%\Roaming\Joystick Diagrams\output_plugins\, then restarting the app. Useful for development: symlink your working tree in and iterate.

Uninstalling

The trash icon on the plugin's Settings card removes the files but preserves the plugin's saved settings, so a reinstall picks up where the user left off.

A community plugin cannot share a name with a bundled plugin; bundled plugins always take priority.

Plugin Signing

Joystick Diagrams embeds an Ed25519 public key and verifies signed plugins on load. Signed plugins load silently; unsigned plugins prompt the user via a trust dialog on first use.

What Gets Signed

The signature covers a deterministic SHA-256 digest of every file in the plugin, excluding:

  • plugin.sig itself
  • Any __pycache__ directories

Re-signing is required whenever any other file changes, including icons, templates, or bundled data.

Getting a Plugin Signed

The Joystick Diagrams developer holds the signing key. If you'd like an official signature on your plugin (for distribution to users who want the verified badge), reach out on Discord. The source of truth for the signing flow is joystick_diagrams/plugins/plugin_signing.py.

An unsigned plugin is not blocked. The user is asked to explicitly trust it once, then the decision is remembered. You don't need a signature to publish a plugin, only to skip that one-time prompt.

PluginMeta

FieldTypeDescription
namestrDisplay name shown in the UI
versionstrPlugin version string
icon_pathstrPath to icon, relative to the plugin directory

Plugin Settings

Same Pydantic model approach as parser plugins. Define a PluginSettings subclass. Output plugins additionally support str fields with dropdown options:

main.py
from pathlib import Path
from pydantic import Field

class MySettings(PluginSettings):
    output_path: Path | None = Field(
        default=None,
        title="Output Folder",
        json_schema_extra={"is_folder": True, "default_path": "~/Documents"},
    )
    enabled_feature: bool = Field(default=True, title="Enable Feature")
    mode: str = Field(
        default="default",
        title="Mode",
        json_schema_extra={"options": ["default", "advanced"]},
    )

Supported Field Types

Field TypeUI Control
Path | NoneFolder/file browse button
boolCheckbox
strText field
str with optionsDropdown (QComboBox)

Path Field Options

Configure path fields via json_schema_extra:

KeyTypeDefaultDescription
is_folderboolTrueFolder dialog vs file dialog
default_pathstrnoneStarting directory for the browse dialog
extensionslist[str]noneAllowed file extensions (e.g. [".xml"])
requiredboolTruePlugin not ready until this path is set

Access settings with self.get_setting("field_name"). Settings persist to JSON automatically.

Ready State

By default, returns True when all required Path fields are set. Override the ready property for custom logic:

main.py
@property
def ready(self) -> bool:
    mode = self.get_setting("mode")
    if mode == "dcs":
        return self.get_setting("dcs_path") is not None
    return self.get_setting("output_path") is not None

ExportResult

Each exported device produces one ExportResult. This is the core data your plugin receives:

FieldTypeDescription
profile_namestrDisplay name of the profile (e.g. "A-10C II")
device_namestrDisplay name of the hardware device
device_guidstrUnique device GUID
source_pluginstrName of the parser plugin that produced this profile
template_namestr | NoneSVG template filename used, or None
export_formatstr"SVG" or "PNG"
file_pathPathAbsolute path to the exported file
export_directoryPathRoot export directory for this run
deviceDevice_Full device model with all inputs and bindings

Working with the Device Model

Access parsed binding data through result.device:

example.py
def process_export(self, results: list[ExportResult]) -> bool:
    for result in results:
        device = result.device

        # Device properties
        print(device.guid)   # str, device GUID
        print(device.name)   # str, display name

        # All inputs grouped by type
        grouped = device.get_inputs()
        for button_id, input_obj in grouped["buttons"].items():
            print(f"{button_id}: {input_obj.command}")

        # All inputs in a flat dict
        for input_id, input_obj in device.get_combined_inputs().items():
            print(f"{input_id}: {input_obj.command}")

            # Check for modifiers
            for mod in input_obj.modifiers:
                print(f"  {mod.modifiers} -> {mod.command}")

    return True
For the complete data model (all control types, identifier formats, and profile merging) see the Input Library Reference.

Lifecycle

  1. Discovery. Plugin is found in the output_plugins/ directory at startup.
  2. Settings load. Settings are loaded from disk and enabled state is restored from the database.
  3. UI. Plugin appears in Settings > Output Plugins with an enable toggle and setup panel.
  4. Export. After every export, all enabled and ready plugins receive process_export(results).
Threading: For SVG exports, plugins run on the export worker thread. For PNG exports, plugins run on a separate thread pool worker after PNG conversion completes.

Full Example: Bindings to JSON

This example exports all binding data to JSON files, one per device per profile:

main.py
import json
from pathlib import Path

from pydantic import Field

from joystick_diagrams.plugins.output_plugin_interface import (
    ExportResult,
    OutputPluginInterface,
)
from joystick_diagrams.plugins.plugin_settings import PluginMeta, PluginSettings


class BindingsExportSettings(PluginSettings):
    output_path: Path | None = Field(
        default=None,
        title="Output Folder",
        json_schema_extra={"is_folder": True, "default_path": "~/Documents"},
    )


class OutputPlugin(OutputPluginInterface):
    plugin_meta = PluginMeta(
        name="Bindings JSON Export",
        version="1.0.0",
        icon_path="img/icon.ico",
    )
    plugin_settings_model = BindingsExportSettings

    def process_export(self, results: list[ExportResult]) -> bool:
        output_path = self.get_setting("output_path")
        if output_path is None:
            return False

        for result in results:
            bindings = {}
            for input_id, input_obj in result.device.get_combined_inputs().items():
                entry = {"command": input_obj.command}
                if input_obj.modifiers:
                    entry["modifiers"] = [
                        {"keys": sorted(m.modifiers), "command": m.command}
                        for m in input_obj.modifiers
                    ]
                bindings[input_id] = entry

            data = {
                "profile": result.profile_name,
                "device": result.device_name,
                "source": result.source_plugin,
                "bindings": bindings,
            }

            filename = f"{result.device_guid[:5]}-{result.profile_name}.json"
            file_path = Path.joinpath(Path(output_path), filename)
            with open(file_path, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)

        return True

Constraints

Plugins can only use standard library packages or dependencies already shipped with Joystick Diagrams. If you need something else, open a discussion on GitHub.

References