Event bus
Two-way events: plugin → host/front end uses
emit_event; host → plugin useshandle_host_event. Polling host-private state is forbidden.
1. Plugin → host / front end
python
self.emit_event(event_name: str, payload: Any = None) -> None- Thread-safe, callable from any thread.
- Bridged to the QML signal
pluginEvent(plugin_id, event_name, payload: dict)(listen for it on the QML side). - A non-dict
payloadis wrapped into{"value": payload};Nonebecomes{}. - To have the front end call the plugin, use
bridge.invokePluginAction/invokePluginActionAsync→ the plugin'saction_*.
Common event names (custom ones are fine, but the host has defined behaviour for these):
| Event | payload | Host behaviour |
|---|---|---|
device.switching | {"device": str, "reason": str} | The settings-page device row shows "switching" and disables the dropdown; reason is shown preferentially |
device.ready | {"device": str} | Ends the "switching" state |
stream_chunk / stream_done | free-form (assistant typewriter) | Consumed by the plugin's own QML |
2. Host → plugin (downlink)
Override the base-class method:
python
def handle_host_event(self, event_name: str, payload: dict) -> None: ...Two ways to subscribe (either one, or both):
mod.json:
json
"host_events": ["subtitle.updated", "pipeline.state"]- At runtime:
python
host.subscribe_event(event_name: str,
callback: Callable[[str, dict], None] | None = None,
plugin_id: str | None = None) -> bool
host.unsubscribe_event(event_name: str, plugin_id: str | None = None) -> bool
host.unsubscribe_all_events(plugin_id: str | None = None) -> bool- When
callbackis omitted the plugin'shandle_host_eventis called back. - Events that were not subscribed are not pushed to that plugin;
host_events: ["*"]subscribes to all. - On plugin
shutdownthe host callsunsubscribe_all_eventsautomatically.
3. Standard host events
payloads are all dicts, and fields are only ever added, never removed:
| Event | payload | When |
|---|---|---|
subtitle.updated | {"source", "translation", "speaker", "is_partial"} | Caption changed (including the typewriter intermediate state is_partial=True) |
subtitle.final | same as above with is_partial=False | Finalised sentence |
history.appended | {"id", "speaker", "transcript", "translation", "time"} | A history entry is added |
history.updated | {"id", "speaker", "transcript", "translation"} | An existing history entry is updated (e.g. the translation / latency is filled in) |
pipeline.state | same shape as get_pipeline_state() + "reason" | Start / stop / load / scene switch |
provider.changed | {"plugin_type", "plugin_id", "track"} | Engine or theme switch (track looks like "inbound") |
locale.changed | {"locale"} | UI language switch |
config.changed | {"plugin_id", "key", "value"} | This plugin's config was changed by the UI |
plugin.enabled / plugin.disabled | {"plugin_id"} | Any plugin enabled or disabled |
hotkey.triggered | {"plugin_id", "action", "key"} | This plugin's hotkey was pressed (before handle_action) |
Scope restriction
The config.changed and hotkey.triggered payloads contain this plugin's private data, so the host delivers them only to the plugin matching plugin_id — other plugins do not receive them even if they subscribed to *. All other events are broadcast by subscription.
4. Typical usage
python
class MyPlugin(BasePlugin):
def initialize(self, config=None):
super().initialize(config)
self.host.subscribe_event("pipeline.state", self._on_pipeline) # runtime subscription
# or declare host_events in mod.json and only implement handle_host_event
def _on_pipeline(self, event_name: str, payload: dict) -> None:
self.host.log("info", f"pipeline running={payload.get('running')}")
def handle_host_event(self, event_name: str, payload: dict) -> None:
if event_name == "subtitle.final":
...Related
- Session & host state —
get_pipeline_state() - Speech & feedback
- Threading contract
- assistant —
emit_eventtypewriter
